Compare commits
23 Commits
799155f249
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3bec9031 | |||
| fed4d9b590 | |||
| 4bb855a940 | |||
| 94f917b116 | |||
| 25fa7507ce | |||
| 034f13b717 | |||
| 69753e35f9 | |||
| 278a48a2e4 | |||
| e2ce8778e0 | |||
| bdc667b2ed | |||
| b6249c2b8e | |||
| 2fe8aef805 | |||
| 4ee139ad8f | |||
| 7b20ffa376 | |||
| ac4448ed44 | |||
| ca158a7946 | |||
| 637b51b999 | |||
| bb9b5d709a | |||
| b6903af625 | |||
| 92f130b3bd | |||
| 1e03227788 | |||
| 7df7fd4c6f | |||
| e2d05d2c33 |
+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,9 +64,11 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
### Launch PySide6 QML App
|
### Launch PySide6 QML App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -71,7 +78,7 @@ The project uses `uv` for dependency management and tooling, `Ruff` for linting
|
|||||||
A `Makefile` is provided to simplify common development and verification tasks:
|
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
|
||||||
@@ -187,17 +197,20 @@ Window {
|
|||||||
- If no window appears, ensure you are running in a desktop session with GUI access.
|
- If no window appears, ensure you are running in a desktop session with GUI access.
|
||||||
|
|
||||||
- **Windows COM Port Connection Issues:**
|
- **Windows COM Port Connection Issues:**
|
||||||
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
- Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||||
|
|
||||||
### Step-by-Step Windows Simulator Connection Guide
|
### Step-by-Step Windows Simulator Connection Guide
|
||||||
|
|
||||||
#### Step 1: Create the Virtual Serial Port Bridge
|
#### Step 1: Create the Virtual Serial Port Bridge
|
||||||
|
|
||||||
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"scipy>=1.17.1",
|
"scipy>=1.17.1",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"dtw-python",
|
"dtw-python",
|
||||||
|
"cryptography>=41.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+113
-55
@@ -1,25 +1,20 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// TODO P3.1 + P3.2: license grid, "Load License" picker, version binding
|
|
||||||
// THINKING: C# parity requires the dgvLicense grid (Wafer SN, Mfg Date,
|
|
||||||
// License Level, License Date) fed from AES-128-CBC .bin files under
|
|
||||||
// <app_data>/licenses/ — CryptoHelper (backend/crypto/crypto_helper.py)
|
|
||||||
// already has the primitives; the missing piece is the license file model
|
|
||||||
// (P3.1 backend). "Version 0.1.0" below is hardcoded and will drift from
|
|
||||||
// pyproject.toml — expose importlib.metadata.version("pygui") via a context
|
|
||||||
// property instead (P3.2). See docs/pending/alpha-release-polish-plan.md §3.
|
|
||||||
Popup {
|
Popup {
|
||||||
id: root
|
id: root
|
||||||
modal: true
|
modal: true
|
||||||
dim: true
|
dim: true
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
width: 420
|
width: 600
|
||||||
height: 340
|
height: 500
|
||||||
anchors.centerIn: Overlay.overlay
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
onOpened: licenseModel.refresh()
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: Theme.radiusMd
|
radius: Theme.radiusMd
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
@@ -27,10 +22,17 @@ Popup {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: licenseFileDialog
|
||||||
|
title: "Load License"
|
||||||
|
nameFilters: ["License files (*.bin)"]
|
||||||
|
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 24
|
anchors.margins: 28
|
||||||
spacing: 16
|
spacing: 20
|
||||||
|
|
||||||
// ── Title ──
|
// ── Title ──
|
||||||
Label {
|
Label {
|
||||||
@@ -42,27 +44,20 @@ Popup {
|
|||||||
|
|
||||||
// ── App info ──
|
// ── App info ──
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
spacing: 4
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "ISenseCloud (ISC)"
|
text: "ISenseCloud"
|
||||||
font.pixelSize: Theme.fontLg
|
font.pixelSize: Theme.fontXl
|
||||||
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "Version 0.1.0"
|
text: "Version " + appVersion
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Temperature-sensing wafer monitoring"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
color: Theme.bodyColor
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Separator ──
|
// ── Separator ──
|
||||||
@@ -75,7 +70,8 @@ Popup {
|
|||||||
// ── License grid ──
|
// ── License grid ──
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 100
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 150
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
color: Theme.panelBackground
|
color: Theme.panelBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
@@ -83,23 +79,38 @@ Popup {
|
|||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 10
|
anchors.margins: 14
|
||||||
spacing: 2
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
RowLayout {
|
||||||
text: "LICENSE"
|
Layout.fillWidth: true
|
||||||
color: Theme.panelTitleText
|
|
||||||
font.pixelSize: Theme.fontXs
|
Label {
|
||||||
font.letterSpacing: 0.5
|
text: "LICENSE"
|
||||||
|
color: Theme.panelTitleText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.5
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: loadFailedLabel
|
||||||
|
visible: false
|
||||||
|
text: "Invalid license file"
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid header
|
// Grid header
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 4
|
spacing: 8
|
||||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
|
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
|
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||||
|
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -108,10 +119,27 @@ Popup {
|
|||||||
color: Theme.softBorder
|
color: Theme.softBorder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
visible: licenseModel.licenses.length > 0
|
||||||
|
model: licenseModel.licenses
|
||||||
|
delegate: RowLayout {
|
||||||
|
width: ListView.view.width
|
||||||
|
spacing: 8
|
||||||
|
Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
|
||||||
|
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
||||||
|
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
|
||||||
|
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
|
visible: licenseModel.licenses.length === 0
|
||||||
text: "No license loaded"
|
text: "No license loaded"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
@@ -120,25 +148,55 @@ Popup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Close ──
|
// ── Buttons ──
|
||||||
Button {
|
RowLayout {
|
||||||
text: "Close"
|
Layout.fillWidth: true
|
||||||
Layout.alignment: Qt.AlignRight
|
spacing: 12
|
||||||
Layout.preferredWidth: 100
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
onClicked: root.close()
|
|
||||||
|
|
||||||
background: Rectangle {
|
Button {
|
||||||
radius: Theme.radiusSm
|
text: "Load License"
|
||||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
Layout.preferredWidth: 140
|
||||||
border.color: Theme.fieldBorder
|
Layout.preferredHeight: 36
|
||||||
border.width: 1
|
onClicked: licenseFileDialog.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
contentItem: Label {
|
|
||||||
text: parent.text
|
Item { Layout.fillWidth: true }
|
||||||
color: Theme.buttonNeutralText
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
Button {
|
||||||
verticalAlignment: Text.AlignVCenter
|
text: "Close"
|
||||||
|
Layout.preferredWidth: 100
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
onClicked: root.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+316
-273
@@ -23,20 +23,38 @@ Rectangle {
|
|||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
streamController.unloadFile()
|
streamController.unloadFile()
|
||||||
|
} else if (streamController.mode === "live") {
|
||||||
|
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||||
|
streamController.setMode("review")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
property bool memoryRead: false
|
property bool memoryRead: false
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
|
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
||||||
|
// onDetectResult below via deviceController.waferLicensed() (slot from
|
||||||
|
// P1.2 — the detectResult payload is opaque in QML, can't read serial
|
||||||
|
// from it). Then READ/ERASE buttons get
|
||||||
|
// `enabled: root.waferDetected && root.waferLicensed`.
|
||||||
|
// See docs/pending/license-gating-plan.md §2.2.
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onDetectResult(result){
|
function onDetectResult(result){
|
||||||
root.waferDetected = (result != null && result != undefined)
|
// detectResult payload is Signal(object): a dict may cross into QML
|
||||||
|
// as an opaque wrapper whose fields read as undefined, so only
|
||||||
|
// test truthiness (None on failure, dict on success).
|
||||||
|
root.waferDetected = !!result
|
||||||
}
|
}
|
||||||
function onParsedDataReady(result) {
|
function onParsedDataReady(result) {
|
||||||
if (result.success && result.csv_path) {
|
if (result.success && result.csv_path) {
|
||||||
streamController.loadFile(result.csv_path)
|
streamController.loadFile(result.csv_path)
|
||||||
|
// TODO P3.1: only auto-jump to Map when unlocked:
|
||||||
|
// `&& licenseModel.licenses.length > 0` — otherwise a
|
||||||
|
// successful read walks straight into the locked tab.
|
||||||
|
// See plan §3.1.
|
||||||
if (root.selectedTabIndex === 0) {
|
if (root.selectedTabIndex === 0) {
|
||||||
root.selectedTabIndex = 2
|
root.selectedTabIndex = 2
|
||||||
}
|
}
|
||||||
@@ -63,6 +81,7 @@ Rectangle {
|
|||||||
|
|
||||||
function _doDetect() {
|
function _doDetect() {
|
||||||
root.memoryRead = false
|
root.memoryRead = false
|
||||||
|
root.waferDetected = false
|
||||||
streamController.setMode("review")
|
streamController.setMode("review")
|
||||||
streamController.stopStream()
|
streamController.stopStream()
|
||||||
deviceController.detectWafer()
|
deviceController.detectWafer()
|
||||||
@@ -72,7 +91,9 @@ Rectangle {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save Data"
|
title: "Choose a folder to save Data"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
var dir = root.cleanFolderUrl(selectedFolder)
|
||||||
|
deviceController.setSaveDataDir(dir)
|
||||||
|
file_browser.setCurrentDirectory(dir)
|
||||||
root._doDetect()
|
root._doDetect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,8 +202,6 @@ Rectangle {
|
|||||||
Layout.preferredWidth: Theme.sideRailWidth
|
Layout.preferredWidth: Theme.sideRailWidth
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.sideRailBackground
|
color: Theme.sideRailBackground
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -201,9 +220,30 @@ Rectangle {
|
|||||||
|
|
||||||
ListModel {
|
ListModel {
|
||||||
id: pillModel
|
id: pillModel
|
||||||
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 }
|
ListElement {
|
||||||
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 }
|
label: "STATUS"
|
||||||
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 }
|
icon: "Tabs/icons/status.svg"
|
||||||
|
expandW: 86
|
||||||
|
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "DATA"
|
||||||
|
icon: "Tabs/icons/data.svg"
|
||||||
|
expandW: 66
|
||||||
|
desc: "Manage, review, compare (DTW), and split historical sensor data records."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "MAP"
|
||||||
|
icon: "Tabs/icons/map.svg"
|
||||||
|
expandW: 58
|
||||||
|
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "GRAPH"
|
||||||
|
icon: "Tabs/icons/bar-chart.svg"
|
||||||
|
expandW: 70
|
||||||
|
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
@@ -284,307 +324,306 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: pillBtn.hovered
|
||||||
|
text: "<b>" + model.label + "</b>: " + model.desc
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: root.selectedTabIndex = index
|
onClicked: root.selectedTabIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
id: runComparisonBox
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: runCompCol.implicitHeight + 28
|
||||||
Layout.minimumHeight: 120
|
visible: root.selectedTabIndex === 1
|
||||||
radius: Theme.sidePanelRadius
|
radius: Theme.sidePanelRadius
|
||||||
border.color: Theme.sideBorder
|
border.color: Theme.sideBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
color: Theme.sidePanelBackground
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
readonly property var dataTab: dataTabLoader.item
|
||||||
|
|
||||||
|
component RunSlot: Rectangle {
|
||||||
|
id: slotBox
|
||||||
|
property string title
|
||||||
|
property string filePath: ""
|
||||||
|
property color accent: Theme.primaryAccent
|
||||||
|
property bool active: false
|
||||||
|
property bool locked: false
|
||||||
|
property string placeholderText: "Click to select"
|
||||||
|
signal cleared()
|
||||||
|
signal armed()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 58
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
opacity: slotBox.locked ? 0.65 : 1.0
|
||||||
|
border.width: slotBox.active ? 2 : 1
|
||||||
|
border.color: slotBox.active ? accent : Theme.sideBorder
|
||||||
|
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !slotBox.locked
|
||||||
|
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
|
onClicked: slotBox.armed()
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 8; height: 8; radius: 4
|
||||||
|
color: slotBox.accent
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: slotBox.title
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: slotBox.filePath !== ""
|
||||||
|
? slotBox.filePath.split("/").pop()
|
||||||
|
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||||
|
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
visible: slotBox.filePath !== "" && !slotBox.locked
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 24; implicitHeight: 24
|
||||||
|
onClicked: slotBox.cleared()
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "Tabs/icons/x.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
|
id: runCompCol
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 14
|
anchors.margins: 14
|
||||||
spacing: 8
|
spacing: 10
|
||||||
|
|
||||||
StackLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
spacing: 8
|
||||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
|
||||||
|
|
||||||
// ── Status tab: Hardware Actions ────────────────
|
Label {
|
||||||
ColumnLayout {
|
text: "RUN COMPARISON (DTW)"
|
||||||
spacing: 6
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: resetRunBtn
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 32
|
||||||
|
implicitHeight: 32
|
||||||
|
visible: true
|
||||||
|
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "Tabs/icons/rotate-ccw.svg"
|
||||||
|
width: 18; height: 18
|
||||||
|
sourceSize.width: 18
|
||||||
|
sourceSize.height: 18
|
||||||
|
color: Theme.bodyColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
AppToolTip {
|
||||||
|
visible: resetRunBtn.hovered
|
||||||
|
text: "Reset comparison"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: useMasterCheck
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Compare vs Master File"
|
||||||
|
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||||
|
onToggled: {
|
||||||
|
if (!runComparisonBox.dataTab) return
|
||||||
|
runComparisonBox.dataTab.useMasterFile = checked
|
||||||
|
if (checked) runComparisonBox.dataTab.activeBox = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||||
|
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
|
||||||
|
accent: Theme.primaryAccent
|
||||||
|
locked: useMaster
|
||||||
|
placeholderText: useMaster
|
||||||
|
? (runComparisonBox.dataTab.runBWaferType !== ""
|
||||||
|
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
|
||||||
|
: "Select Run B to resolve master")
|
||||||
|
: "Click to select"
|
||||||
|
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
|
||||||
|
filePath: runComparisonBox.dataTab
|
||||||
|
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
|
||||||
|
: ""
|
||||||
|
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
|
||||||
|
onCleared: {
|
||||||
|
if (!runComparisonBox.dataTab) return
|
||||||
|
runComparisonBox.dataTab.compareFileA = ""
|
||||||
|
runComparisonBox.dataTab.activeBox = 1
|
||||||
|
runComparisonBox.dataTab.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
title: "SESSION (RUN B)"
|
||||||
|
accent: Theme.themeSkill
|
||||||
|
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
|
||||||
|
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
|
||||||
|
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
|
||||||
|
onCleared: {
|
||||||
|
if (!runComparisonBox.dataTab) return
|
||||||
|
runComparisonBox.dataTab.compareFileB = ""
|
||||||
|
runComparisonBox.dataTab.activeBox = 2
|
||||||
|
runComparisonBox.dataTab.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: compareRunBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
readonly property var dt: runComparisonBox.dataTab
|
||||||
|
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||||
|
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||||
|
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||||
|
readonly property string gateError: !!dt ? dt.familyGateError : ""
|
||||||
|
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
|
||||||
|
AppToolTip {
|
||||||
|
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
|
||||||
|
text: compareRunBtn.masterMissing
|
||||||
|
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||||
|
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
|
||||||
|
: "Choose two different runs to compare"
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
dt.comparing = true
|
||||||
|
dt.clearResults()
|
||||||
|
streamController.compareFiles(fileA, dt.compareFileB)
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: compareRunBtn.enabled
|
||||||
|
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||||
|
: Theme.buttonNeutralBackground
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
}
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 8
|
||||||
|
anchors.centerIn: parent
|
||||||
Label {
|
Label {
|
||||||
text: "HARDWARE ACTIONS"
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
color: Theme.sideMutedText
|
text: !compareRunBtn.dt ? "Select both runs"
|
||||||
font.family: Theme.uiFontFamily
|
: compareRunBtn.dt.comparing
|
||||||
|
? "Comparing…"
|
||||||
|
: compareRunBtn.enabled
|
||||||
|
? "Run DTW Comparison"
|
||||||
|
: (compareRunBtn.masterMissing ? "No master for this type"
|
||||||
|
: compareRunBtn.sameFile ? "Choose different runs"
|
||||||
|
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
|
||||||
|
: "Select both runs")
|
||||||
|
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
topPadding: 6
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
|
BusyIndicator {
|
||||||
RailActionButton {
|
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||||
label: "DETECT WAFER"
|
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||||
iconSource: "../icons/detect.svg"
|
width: 14; height: 14
|
||||||
Layout.fillWidth: true
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillHeight: true }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Map tab: Source file browser ──────────────
|
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||||
SourcePanel {
|
StackLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
}
|
Layout.minimumHeight: 120
|
||||||
|
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||||
|
|
||||||
|
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||||
|
StatusActionsPanel {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map tab: Source file browser ──────────────
|
||||||
|
Rectangle {
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
SourcePanel {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||||
Rectangle {
|
ConnectionFooter {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Theme.sideFooterConnection
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 14
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
// Row 1: Title + status badge (unchanged)
|
|
||||||
RowLayout {
|
|
||||||
spacing: 8
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "CONNECTION FLOW"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
height: 18
|
|
||||||
width: badgeLabel.implicitWidth + 14
|
|
||||||
radius: 9
|
|
||||||
color: {
|
|
||||||
var s = deviceController.connectionStatus
|
|
||||||
if (s === "Connected") return Theme.statusSuccessColor
|
|
||||||
if (s === "Disconnected") return Theme.statusErrorColor
|
|
||||||
return Theme.statusWarningColor
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
id: badgeLabel
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: deviceController.connectionStatus || "Disconnected"
|
|
||||||
color: Theme.statusBadgeText
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 2: Status dot + descriptive text (replaces dotted trail)
|
|
||||||
Row {
|
|
||||||
spacing: 8
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: statusDot
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 8
|
|
||||||
height: 8
|
|
||||||
radius: 4
|
|
||||||
color: deviceController.connectionStatus === "Connected"
|
|
||||||
? Theme.statusSuccessColor : Theme.statusErrorColor
|
|
||||||
|
|
||||||
SequentialAnimation on opacity {
|
|
||||||
running: deviceController.connectionStatus === "Connected"
|
|
||||||
loops: Animation.Infinite
|
|
||||||
NumberAnimation { to: 0.4; duration: 1200 }
|
|
||||||
NumberAnimation { to: 1.0; duration: 1200 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: {
|
|
||||||
if (deviceController.connectionStatus === "Connected")
|
|
||||||
return deviceController.selectedPort || "Connected"
|
|
||||||
if (deviceController.selectedPort)
|
|
||||||
return deviceController.selectedPort
|
|
||||||
return "No port selected"
|
|
||||||
}
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 3: Mode chip — neutral gray tag
|
|
||||||
Row {
|
|
||||||
spacing: 6
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: modeChipLabel.implicitWidth + 12
|
|
||||||
height: 18
|
|
||||||
radius: 4
|
|
||||||
color: Theme.fieldBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Label {
|
|
||||||
id: modeChipLabel
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: streamController.mode
|
|
||||||
? streamController.mode.toUpperCase() : "REVIEW"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
font.bold: true
|
|
||||||
font.letterSpacing: 0.8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||||
Rectangle {
|
UtilityFooter {
|
||||||
|
id: utilFooter
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Theme.sideFooterUtility
|
settingsPopup: settingsPopup
|
||||||
radius: Theme.sidePanelRadius
|
aboutDialog: aboutDialog
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 4
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: settingsBtn
|
|
||||||
flat: true
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
icon.source: "Tabs/icons/settings.svg"
|
|
||||||
onClicked: settingsPopup.open()
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: 8
|
|
||||||
color: settingsBtn.hovered
|
|
||||||
? Theme.sideActiveBackground : "transparent"
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Row {
|
|
||||||
spacing: 6
|
|
||||||
anchors.centerIn: parent
|
|
||||||
|
|
||||||
IconImage {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 16
|
|
||||||
height: 16
|
|
||||||
source: "Tabs/icons/settings.svg"
|
|
||||||
sourceSize.width: 16
|
|
||||||
sourceSize.height: 16
|
|
||||||
color: Theme.headingColor
|
|
||||||
opacity: 0.75
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: "SETTINGS"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
opacity: 0.8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: 1
|
|
||||||
height: 24
|
|
||||||
color: Theme.sideBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: aboutBtn
|
|
||||||
flat: true
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
icon.source: "Tabs/icons/about.svg"
|
|
||||||
onClicked: aboutDialog.open()
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: 8
|
|
||||||
color: aboutBtn.hovered
|
|
||||||
? Theme.sideActiveBackground : "transparent"
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Row {
|
|
||||||
spacing: 6
|
|
||||||
anchors.centerIn: parent
|
|
||||||
|
|
||||||
IconImage {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 16
|
|
||||||
height: 16
|
|
||||||
source: "Tabs/icons/about.svg"
|
|
||||||
sourceSize.width: 16
|
|
||||||
sourceSize.height: 16
|
|
||||||
color: Theme.headingColor
|
|
||||||
opacity: 0.75
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: "ABOUT"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
opacity: 0.8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,8 +633,6 @@ Rectangle {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.workspaceBackground
|
color: Theme.workspaceBackground
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
StackLayout {
|
StackLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -613,6 +650,12 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
Loader {
|
Loader {
|
||||||
source: "Tabs/WaferMapTab.qml"
|
source: "Tabs/WaferMapTab.qml"
|
||||||
|
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
|
||||||
|
// so the tab never instantiates while locked. See plan §3.1.
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/GraphTab.qml"
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-731
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
|
// ===== Graph Tab =====
|
||||||
|
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
|
||||||
|
// snapshot recomputed whenever a file is loaded — not live-updating during
|
||||||
|
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
|
||||||
|
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
|
||||||
|
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!streamController.loadedFile) {
|
||||||
|
chart.seriesData = []
|
||||||
|
chart.sensorNames = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chart.sensorNames = streamController.graphSensorNames
|
||||||
|
? streamController.graphSensorNames.split(",") : []
|
||||||
|
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onLoadedFileChanged() { root.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
RunChart {
|
||||||
|
id: chart
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
title: "Sensor Temperature Over Time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import QtQuick.Controls.impl
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
import ISC
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|||||||
+305
-309
@@ -30,36 +30,45 @@ ColumnLayout {
|
|||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
Text {
|
Text {
|
||||||
id: valueText
|
id: valueText
|
||||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
font.pixelSize: valSize
|
font.pixelSize: valSize
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.fillHeight: true
|
||||||
}
|
Layout.fillWidth: true
|
||||||
Text {
|
horizontalAlignment: Text.AlignHCenter
|
||||||
id: labelText
|
verticalAlignment: Text.AlignVCenter
|
||||||
color: Theme.sideMutedText
|
fontSizeMode: Text.HorizontalFit
|
||||||
font.pixelSize: Theme.fontXs
|
minimumPixelSize: Theme.fontMd
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
component GridCell : RowLayout {
|
// Stacked stat block: small label over big value, centered in its grid
|
||||||
|
// quadrant so the card's height is used instead of leaving dead space.
|
||||||
|
component GridCell : ColumnLayout {
|
||||||
property alias label: labelText.text
|
property alias label: labelText.text
|
||||||
property alias value: valueText.text
|
property alias value: valueText.text
|
||||||
property bool active: root.waferDetected
|
property bool active: root.waferDetected
|
||||||
|
|
||||||
spacing: 8
|
spacing: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
Text {
|
Text {
|
||||||
id: labelText
|
id: labelText
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
@@ -67,17 +76,15 @@ ColumnLayout {
|
|||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
id: valueText
|
id: valueText
|
||||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontXl
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Aliases for external wiring ──
|
// ── Aliases for external wiring ──
|
||||||
@@ -115,21 +122,75 @@ ColumnLayout {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save."
|
title: "Choose a folder to save."
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder));
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
root.parseAndSavePendingRead();
|
root.parseAndSavePendingRead();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// Directory-only picker for the DIRECTORY card button — sets the save dir
|
||||||
// ROW 1: Connection Status (100%)
|
// without kicking off a parse/save of pending read data.
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
FolderDialog {
|
||||||
RowLayout {
|
id: dirOnlyDialog
|
||||||
Layout.fillWidth: true
|
title: "Choose a folder to save Data"
|
||||||
Layout.preferredHeight: 90
|
onAccepted: {
|
||||||
Layout.maximumHeight: 90
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
spacing: 8
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||||
|
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
|
||||||
|
Dialog {
|
||||||
|
id: editCsvDialog
|
||||||
|
parent: Overlay.overlay
|
||||||
|
modal: true
|
||||||
|
title: "Edit CSV Metadata"
|
||||||
|
width: 460
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Save | Dialog.Cancel
|
||||||
|
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.csvPath.split("/").pop()
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label { text: "Wafer" }
|
||||||
|
TextField { id: metaWafer; Layout.fillWidth: true }
|
||||||
|
Label { text: "Date" }
|
||||||
|
TextField { id: metaDate; Layout.fillWidth: true }
|
||||||
|
Label { text: "Chamber" }
|
||||||
|
TextField { id: metaChamber; Layout.fillWidth: true }
|
||||||
|
Label { text: "Notes" }
|
||||||
|
TextField { id: metaNotes; Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// ROWS 1-2 (3-column bento):
|
||||||
|
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
|
||||||
|
// [Directory (span 2) ] [ (rowspan) ]
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 188
|
||||||
|
Layout.maximumHeight: 188
|
||||||
|
columns: 3
|
||||||
|
rowSpacing: 8
|
||||||
|
columnSpacing: 8
|
||||||
|
|
||||||
|
// ── Connection Status: two cells wide, top-left ──
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
@@ -138,43 +199,24 @@ ColumnLayout {
|
|||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: connStatusCol
|
anchors.fill: parent
|
||||||
anchors.left: parent.left
|
anchors.margins: Theme.panelPadding
|
||||||
anchors.right: parent.right
|
spacing: 6
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
anchors.leftMargin: Theme.panelPadding
|
|
||||||
anchors.rightMargin: Theme.panelPadding
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "CONNECTION STATUS"
|
text: "CONNECTION STATUS"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
font.weight: Font.Medium
|
font.bold: true
|
||||||
font.letterSpacing: 1.2
|
font.letterSpacing: 1.5
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 0
|
spacing: 8
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 1
|
|
||||||
Text {
|
|
||||||
text: "HOST PC"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "localhost"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -239,221 +281,204 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 1
|
|
||||||
Text {
|
|
||||||
text: "COM PORT"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.portName
|
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: 90
|
|
||||||
Layout.maximumHeight: 90
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.preferredWidth: 65
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "DIRECTORY"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: deviceController.saveDataDir || "Not configured"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.italic: true
|
|
||||||
opacity: 0.7
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: root.csvPath !== ""
|
|
||||||
width: sessionSavedLabel.implicitWidth + 12
|
|
||||||
height: 18
|
|
||||||
radius: 4
|
|
||||||
color: Theme.fieldBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Text {
|
|
||||||
id: sessionSavedLabel
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: "SESSION SAVED"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.letterSpacing: 0.6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: browseBtn
|
|
||||||
text: "Save As"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
implicitWidth: 70
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: browseBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: browseBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: saveDirDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 10
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "WAFER & SENSOR DATA"
|
text: root.portName
|
||||||
color: Theme.sideMutedText
|
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontMd
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.letterSpacing: 0.6
|
elide: Text.ElideMiddle
|
||||||
Layout.bottomMargin: 2
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.rowSpan: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "WAFER & SENSOR DATA"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
columns: 2
|
||||||
|
rowSpacing: 6
|
||||||
|
columnSpacing: 16
|
||||||
|
|
||||||
|
GridCell {
|
||||||
|
id: waferInfoFamilyCell
|
||||||
|
label: "Family Code"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridCell {
|
||||||
|
id: waferSerialCell
|
||||||
|
label: "Serial"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GridLayout {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: 1
|
||||||
columns: 2
|
color: Theme.cardBorder
|
||||||
rowSpacing: 4
|
opacity: 0.5
|
||||||
columnSpacing: 16
|
}
|
||||||
|
|
||||||
GridCell {
|
GridCell {
|
||||||
id: waferInfoFamilyCell
|
id: waferCyclesCell
|
||||||
label: "Family Code"
|
label: "Cycles Completed"
|
||||||
value: {
|
value: {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
GridCell {
|
active: root.waferDetected
|
||||||
id: waferCyclesCell
|
}
|
||||||
label: "Cycles Completed"
|
GridCell {
|
||||||
value: {
|
id: waferSensorsCell
|
||||||
var info = deviceController.lastWaferInfo;
|
label: "Sensors"
|
||||||
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
value: {
|
||||||
}
|
var info = deviceController.lastWaferInfo;
|
||||||
active: root.waferDetected
|
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.columnSpan: 2
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
opacity: 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
GridCell {
|
|
||||||
id: waferSerialCell
|
|
||||||
label: "Serial"
|
|
||||||
value: {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GridCell {
|
|
||||||
id: waferSensorsCell
|
|
||||||
label: "Sensors"
|
|
||||||
value: {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Directory: bottom-left cell ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "DIRECTORY"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.csvPath !== ""
|
||||||
|
implicitWidth: sessionSavedLabel.implicitWidth + 12
|
||||||
|
implicitHeight: 18
|
||||||
|
radius: 4
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: sessionSavedLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "SESSION SAVED"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Path pinned bottom-left
|
||||||
|
Text {
|
||||||
|
text: deviceController.saveDataDir || "Not configured"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.italic: true
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: browseBtn
|
||||||
|
text: "Select Folder"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: browseBtn.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: browseBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: dirOnlyDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Total Runtime: bottom center ──
|
||||||
StatCard {
|
StatCard {
|
||||||
id: runtimeCard
|
id: runtimeCard
|
||||||
Layout.preferredWidth: 35
|
label: "TOTAL RUNTIME"
|
||||||
value: {
|
value: {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—";
|
if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
|
||||||
|
var secs = parseFloat(info[4]);
|
||||||
|
if (isNaN(secs)) return info[4] + "s";
|
||||||
|
var m = Math.floor(secs / 60);
|
||||||
|
var s = Math.round(secs % 60);
|
||||||
|
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
|
||||||
}
|
}
|
||||||
label: "TOTAL RUNTIME"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,10 +498,20 @@ ColumnLayout {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 36
|
Layout.preferredHeight: 36
|
||||||
color: Theme.subtleSectionBackground
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
// Cover bottom corners so only top-left and top-right are rounded
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: parent.radius
|
||||||
|
color: parent.color
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -489,64 +524,22 @@ ColumnLayout {
|
|||||||
width: 14; height: 14
|
width: 14; height: 14
|
||||||
sourceSize.width: 14
|
sourceSize.width: 14
|
||||||
sourceSize.height: 14
|
sourceSize.height: 14
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
visible: root.csvPath !== ""
|
visible: root.csvPath !== ""
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
|
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
|
font.letterSpacing: 1.5
|
||||||
font.weight: Font.Medium
|
font.bold: true
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
|
||||||
id: refreshSessionBtn
|
|
||||||
text: "Refresh"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: refreshSessionBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: refreshSessionBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: deviceController.clearSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: clearLogBtn
|
|
||||||
text: "Clear"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: clearLogBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: clearLogBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: deviceController.clearActivityLog()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -567,7 +560,9 @@ ColumnLayout {
|
|||||||
TextArea {
|
TextArea {
|
||||||
id: activityLog
|
id: activityLog
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
// Bound to backend so log survives tab switches (Loader
|
||||||
|
// destroys this tab); clears only via Clear Log / restart.
|
||||||
|
text: deviceController.activityLog || qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||||
readOnly: true
|
readOnly: true
|
||||||
font.family: "monospace"
|
font.family: "monospace"
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
@@ -578,12 +573,6 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onActivityLogUpdated(newLog) {
|
|
||||||
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -635,6 +624,13 @@ ColumnLayout {
|
|||||||
root.dataRows = result.rows;
|
root.dataRows = result.rows;
|
||||||
root.dataCols = result.cols;
|
root.dataCols = result.cols;
|
||||||
root.csvPath = result.csv_path || "";
|
root.csvPath = result.csv_path || "";
|
||||||
|
if (root.csvPath) {
|
||||||
|
metaWafer.text = result.serialNumber || "";
|
||||||
|
metaDate.text = Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss");
|
||||||
|
metaChamber.text = settingsModel.chamberId || "";
|
||||||
|
metaNotes.text = "";
|
||||||
|
editCsvDialog.open();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
root.dataParsed = false;
|
root.dataParsed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Controls.impl
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Dialogs
|
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
import ISC.Tabs.components
|
import ISC.Tabs.components
|
||||||
@@ -11,39 +10,23 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
// Live elapsed-time counter
|
// Wire streamController.trendDelta (Signal(str) carrying one new
|
||||||
property int _liveSecs: 0
|
// [elapsed_s, value] pair per live frame) into the trend chart, which
|
||||||
Timer {
|
// accumulates its own bounded 60s window. trendReset clears it when a
|
||||||
id: liveTimer
|
// new stream starts. Parsing happens on the Python TrendChartItem side
|
||||||
interval: 1000
|
// so malformed payloads are logged there instead of crashing the QML
|
||||||
repeat: true
|
// binding.
|
||||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
|
||||||
onTriggered: root._liveSecs++
|
|
||||||
onRunningChanged: if (!running)
|
|
||||||
root._liveSecs = 0
|
|
||||||
}
|
|
||||||
function fmtTime(s) {
|
|
||||||
var m = Math.floor(s / 60);
|
|
||||||
var ss = s % 60;
|
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
|
||||||
}
|
|
||||||
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
|
|
||||||
// into the trend chart's data property. The slot parseJsonToData() is defined
|
|
||||||
// on the Python TrendChartItem; we use it so malformed payloads are logged
|
|
||||||
// there instead of crashing the QML binding.
|
|
||||||
property string loadErrorMessage: ""
|
property string loadErrorMessage: ""
|
||||||
Connections {
|
Connections {
|
||||||
target: streamController
|
target: streamController
|
||||||
function onTrendData(avgsJson) {
|
function onTrendDelta(deltaJson) {
|
||||||
trendChart.setDataFromJson(avgsJson);
|
trendChart.appendDelta(deltaJson);
|
||||||
|
}
|
||||||
|
function onTrendReset() {
|
||||||
|
trendChart.clearTrend();
|
||||||
}
|
}
|
||||||
// A file was picked from the rail (any tab) — loadFile() switches the
|
|
||||||
// backend to review mode; mirror that in the toolbar toggle so a
|
|
||||||
// successful load is never hidden behind a stale "Live" selection.
|
|
||||||
function onLoadedFileChanged() {
|
function onLoadedFileChanged() {
|
||||||
root.loadErrorMessage = "";
|
root.loadErrorMessage = "";
|
||||||
if (streamController.mode === "review")
|
|
||||||
modeBar.currentIndex = 0;
|
|
||||||
}
|
}
|
||||||
function onLoadFileError(message) {
|
function onLoadFileError(message) {
|
||||||
root.loadErrorMessage = message;
|
root.loadErrorMessage = message;
|
||||||
@@ -60,111 +43,6 @@ Item {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
// Mode toggle
|
|
||||||
TabBar {
|
|
||||||
id: modeBar
|
|
||||||
currentIndex: 0 // Default to "Review"
|
|
||||||
spacing: 2
|
|
||||||
padding: 2
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.subtleSectionBackground
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
text: "Review"
|
|
||||||
implicitWidth: 64
|
|
||||||
implicitHeight: 28
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
||||||
radius: Theme.radiusSm - 1
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
text: "Live"
|
|
||||||
enabled: deviceController.connectionStatus === "Connected"
|
|
||||||
opacity: enabled ? 1.0 : 0.4
|
|
||||||
implicitWidth: 58
|
|
||||||
implicitHeight: 28
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
||||||
radius: Theme.radiusSm - 1
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onCurrentIndexChanged: if (currentIndex === 1) {
|
|
||||||
// Guard: revert to Review if not connected
|
|
||||||
if (deviceController.connectionStatus !== "Connected") {
|
|
||||||
currentIndex = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Entering Live mode
|
|
||||||
streamController.setMode("live");
|
|
||||||
var fc = "";
|
|
||||||
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
|
||||||
fc = deviceController.lastWaferInfo[0];
|
|
||||||
}
|
|
||||||
streamController.startStream(deviceController.selectedPort, fc);
|
|
||||||
} else {
|
|
||||||
// Entering Review Mode (automatically calls stopStream in backend)
|
|
||||||
streamController.setMode("review");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live source info: WiFi icon + port · serial
|
|
||||||
RowLayout {
|
|
||||||
visible: streamController.mode === "live"
|
|
||||||
spacing: 5
|
|
||||||
// Live status dot (matches the connection-flow indicator dot elsewhere)
|
|
||||||
Rectangle {
|
|
||||||
id: liveIndicator
|
|
||||||
width: 8; height: 8
|
|
||||||
radius: 4
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
|
|
||||||
|
|
||||||
SequentialAnimation on opacity {
|
|
||||||
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
|
|
||||||
loops: Animation.Infinite
|
|
||||||
NumberAnimation {
|
|
||||||
to: 0.2
|
|
||||||
duration: 600
|
|
||||||
easing.type: Easing.InOutQuad
|
|
||||||
}
|
|
||||||
NumberAnimation {
|
|
||||||
to: 1.0
|
|
||||||
duration: 600
|
|
||||||
easing.type: Easing.InOutQuad
|
|
||||||
}
|
|
||||||
onRunningChanged: {
|
|
||||||
if (!running)
|
|
||||||
liveIndicator.opacity = 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
text: "Live stream"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
@@ -219,49 +97,6 @@ Item {
|
|||||||
font.letterSpacing: 1.2
|
font.letterSpacing: 1.2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record start/stop toggle (Live mode)
|
|
||||||
Button {
|
|
||||||
visible: streamController.mode === "live"
|
|
||||||
enabled: streamController.state !== "idle" || streamController.recording
|
|
||||||
text: streamController.recording ? "Stop REC" : "Record"
|
|
||||||
onClicked: {
|
|
||||||
if (streamController.recording) {
|
|
||||||
streamController.stopRecording();
|
|
||||||
} else {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
var serial = (info && info.length > 1) ? info[1] : "";
|
|
||||||
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
|
||||||
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
|
||||||
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream statistics popup (Live mode)
|
|
||||||
Button {
|
|
||||||
visible: streamController.mode === "live"
|
|
||||||
text: "Stats"
|
|
||||||
onClicked: streamStatsDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamStatsDialog {
|
|
||||||
id: streamStatsDialog
|
|
||||||
elapsedSecs: root._liveSecs
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "Export PNG"
|
|
||||||
onClicked: exportDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
FileDialog {
|
|
||||||
id: exportDialog
|
|
||||||
title: "Export Wafer Map"
|
|
||||||
fileMode: FileDialog.SaveFile
|
|
||||||
nameFilters: ["PNG files (*.png)"]
|
|
||||||
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load error banner ───────────────────────────────────────────
|
// ── Load error banner ───────────────────────────────────────────
|
||||||
@@ -309,55 +144,78 @@ Item {
|
|||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
spacing: 8
|
spacing: 16
|
||||||
|
|
||||||
WaferMapView {
|
WaferMapView {
|
||||||
id: waferView
|
id: waferView
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredHeight: 400
|
||||||
|
Layout.minimumHeight: 280
|
||||||
blend: readoutPanel.heatmapBlend
|
blend: readoutPanel.heatmapBlend
|
||||||
showLabels: readoutPanel.showLabels
|
showLabels: readoutPanel.showLabels
|
||||||
|
showExtremes: readoutPanel.showExtremes
|
||||||
showThickness: readoutPanel.showThickness
|
showThickness: readoutPanel.showThickness
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: trendPane
|
id: trendPane
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 160
|
Layout.fillHeight: false
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
Layout.minimumHeight: 120
|
||||||
visible: trendChart.hasData && streamController.mode === "live"
|
visible: trendChart.hasData && streamController.mode === "live"
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
radius: Theme.radiusMd
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
TrendChartItem {
|
ColumnLayout {
|
||||||
id: trendChart
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 8
|
anchors.margins: 8
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 16
|
visible: transportBar.hasContent
|
||||||
|
implicitHeight: trendPane.visible ? 52 : 16
|
||||||
}
|
}
|
||||||
TransportBar {
|
TransportBar {
|
||||||
|
id: transportBar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readout panel — floating wrapper box
|
// Readout panel — scrolls instead of clipping so the last card
|
||||||
Rectangle {
|
// (Thresholds) is always reachable even if content outgrows
|
||||||
Layout.preferredWidth: 220
|
// the available height (e.g. once E-C delta rows appear).
|
||||||
|
ScrollView {
|
||||||
|
Layout.preferredWidth: Theme.rightRailWidth
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: "transparent"
|
|
||||||
border.color: "transparent"
|
|
||||||
border.width: 0
|
|
||||||
clip: true
|
clip: true
|
||||||
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||||
|
|
||||||
ReadoutPanel {
|
ReadoutPanel {
|
||||||
id: readoutPanel
|
id: readoutPanel
|
||||||
anchors.fill: parent
|
width: parent.width
|
||||||
anchors.margins: 0
|
hasThicknessData: waferView.hasThickness
|
||||||
|
onExportRequested: function(filePath, extra) {
|
||||||
|
waferView.exportImage(filePath, extra);
|
||||||
|
}
|
||||||
|
onThicknessFileChosen: function(filePath) {
|
||||||
|
var err = waferView.loadThickness(filePath);
|
||||||
|
if (err !== "") {
|
||||||
|
root.loadErrorMessage = err;
|
||||||
|
readoutPanel.showThickness = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Single seam for tooltip chrome — every hover tip in the app instantiates
|
||||||
|
// this instead of styling QtQuick.Controls ToolTip.background/contentItem
|
||||||
|
// inline at each call site.
|
||||||
|
ToolTip {
|
||||||
|
id: root
|
||||||
|
delay: 400
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
text: root.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: root.font
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Curve overlay chart card with the alignment timeline scrubber.
|
||||||
|
PanelBox {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
required property var trendDataA
|
||||||
|
required property var trendDataB
|
||||||
|
required property int seriesLen
|
||||||
|
required property bool hasSeries
|
||||||
|
required property bool comparing
|
||||||
|
|
||||||
|
// Scrubber position — the internal Slider is private; consumers
|
||||||
|
// read/write frameIndex and call focusScrubber().
|
||||||
|
property alias frameIndex: scrubber.value
|
||||||
|
|
||||||
|
function focusScrubber() { scrubber.forceActiveFocus() }
|
||||||
|
|
||||||
|
implicitHeight: chartCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: chartCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "CURVE OVERLAY"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 16
|
||||||
|
visible: card.hasSeries
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||||
|
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 200
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: overlayCanvas
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: card.hasSeries
|
||||||
|
|
||||||
|
readonly property real padLeft: 50
|
||||||
|
readonly property real padRight: 16
|
||||||
|
readonly property real padTop: 24
|
||||||
|
readonly property real padBottom: 36
|
||||||
|
|
||||||
|
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
|
||||||
|
// so labels land on round numbers instead of raw data min/max.
|
||||||
|
function niceStep(rawStep) {
|
||||||
|
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
||||||
|
return [1, 2, 5, 10].map(function (s) { return s * mag })
|
||||||
|
.find(function (s) { return s >= rawStep })
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var plotH = height - padTop - padBottom
|
||||||
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
|
ctx.strokeStyle = Theme.chartGridLine
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.fillStyle = Theme.chartAxisText
|
||||||
|
ctx.font = "10px " + Theme.uiFontFamily
|
||||||
|
ctx.textAlign = "right"
|
||||||
|
|
||||||
|
var ySteps = Math.max(1, Math.round(range / yStep))
|
||||||
|
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
|
||||||
|
for (var yi = 0; yi <= ySteps; yi++) {
|
||||||
|
var yFrac = yi / ySteps
|
||||||
|
var yPx = padTop + plotH * yFrac
|
||||||
|
var yVal = maxV - yFrac * range
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(padLeft, yPx)
|
||||||
|
ctx.lineTo(padLeft + plotW, yPx)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
var xMax = dataLen - 1
|
||||||
|
if (xMax > 0) {
|
||||||
|
var xStep = niceStep(xMax / 5 || 1)
|
||||||
|
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
|
||||||
|
var xPx = padLeft + (xIdx / xMax) * plotW
|
||||||
|
ctx.fillText(xIdx.toString(), xPx, height - 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(14, padTop + plotH / 2)
|
||||||
|
ctx.rotate(-Math.PI / 2)
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.font = "12px " + Theme.uiFontFamily
|
||||||
|
ctx.fillText("Celcius (°C)", 0, 0)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.font = "12px " + Theme.uiFontFamily
|
||||||
|
ctx.fillText("Frame", padLeft + plotW / 2, height - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSeries(ctx, series, minV, range, color, dataLen) {
|
||||||
|
// x is mapped against the shared frame domain (dataLen), not the
|
||||||
|
// series' own length — otherwise a shorter run's line stretches to
|
||||||
|
// fill the whole plot instead of stopping where its data ends.
|
||||||
|
if (series.length < 2) return
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var plotH = height - padTop - padBottom
|
||||||
|
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
for (var i = 0; i < series.length; i++) {
|
||||||
|
var x = padLeft + (i / (dataLen - 1)) * plotW
|
||||||
|
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScrubMarker(ctx) {
|
||||||
|
if (card.seriesLen < 2) return
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var x = padLeft + (scrubber.value / (card.seriesLen - 1)) * plotW
|
||||||
|
ctx.strokeStyle = Theme.primaryAccent
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.setLineDash([3, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, padTop)
|
||||||
|
ctx.lineTo(x, height - padBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
if (!card.hasSeries) return
|
||||||
|
var all = card.trendDataA.concat(card.trendDataB)
|
||||||
|
var minV = Math.min.apply(null, all)
|
||||||
|
var maxV = Math.max.apply(null, all)
|
||||||
|
var padding = (maxV - minV) * 0.05
|
||||||
|
minV -= padding
|
||||||
|
maxV += padding
|
||||||
|
// Snap bounds to nice-step multiples -> stable round-number axis
|
||||||
|
var yStep = niceStep((maxV - minV) / 5 || 1)
|
||||||
|
minV = Math.floor(minV / yStep) * yStep
|
||||||
|
maxV = Math.ceil(maxV / yStep) * yStep
|
||||||
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
|
drawGrid(ctx, minV, maxV, yStep, card.seriesLen)
|
||||||
|
drawSeries(ctx, card.trendDataA, minV, range, Theme.primaryAccent, card.seriesLen)
|
||||||
|
drawSeries(ctx, card.trendDataB, minV, range, Theme.themeSkill, card.seriesLen)
|
||||||
|
drawScrubMarker(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: card
|
||||||
|
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||||||
|
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: scrubber
|
||||||
|
function onValueChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !card.hasSeries
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !card.comparing
|
||||||
|
source: "../icons/bar-chart.svg"
|
||||||
|
width: 32; height: 32
|
||||||
|
sourceSize.width: 32
|
||||||
|
sourceSize.height: 32
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: card.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !card.comparing
|
||||||
|
text: "Click a run box on the right, then select a file from the left panel."
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alignment Timeline Scrubber ────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
visible: card.hasSeries
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle {
|
||||||
|
text: "TIMELINE SCRUBBER"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider {
|
||||||
|
id: scrubber
|
||||||
|
Layout.fillWidth: true
|
||||||
|
leftPadding: overlayCanvas.padLeft
|
||||||
|
rightPadding: overlayCanvas.padRight
|
||||||
|
from: 0
|
||||||
|
to: Math.max(1, card.seriesLen - 1)
|
||||||
|
stepSize: 1
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Right-column bento stack: DTW readout, overlap settings, frame/time
|
||||||
|
// controls, and scrubber readout. Pure view — all comparison state lives
|
||||||
|
// on DataTab; scrubber writes go up via frameIndexRequested.
|
||||||
|
ColumnLayout {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
required property real warpingDistance
|
||||||
|
required property int frameOffset
|
||||||
|
required property real frameOffsetSeconds
|
||||||
|
required property real maxSensorDeviation
|
||||||
|
required property int seriesLen
|
||||||
|
required property bool hasSeries
|
||||||
|
required property var trendDataA
|
||||||
|
required property var trendDataB
|
||||||
|
required property var timeDataA
|
||||||
|
required property var timeDataB
|
||||||
|
required property var frameForTime // function(seconds) -> frame index
|
||||||
|
required property real frameIndex // bound from ComparisonChartCard.frameIndex
|
||||||
|
|
||||||
|
// Overlap settings outputs (consumed by OverlapMapCard via DataTab)
|
||||||
|
property alias overlapEnabled: enableOverlapCheck.checked
|
||||||
|
property alias blendAmount: blendSlider.value
|
||||||
|
|
||||||
|
signal frameIndexRequested(real idx)
|
||||||
|
signal scrubberFocusRequested()
|
||||||
|
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Bento 3: DTW Comparison Readout
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: readoutCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "DTW READOUT" }
|
||||||
|
|
||||||
|
ReadoutStat {
|
||||||
|
label: "DTW ALIGNMENT DISTANCE"
|
||||||
|
value: panel.warpingDistance >= 0 ? panel.warpingDistance.toFixed(2) : "--"
|
||||||
|
valueColor: panel.warpingDistance >= 0
|
||||||
|
? (panel.warpingDistance < 50 ? Theme.metricGood : panel.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "TEMPORAL FRAME OFFSET"
|
||||||
|
value: panel.warpingDistance >= 0
|
||||||
|
? ((panel.frameOffset >= 0 ? "+" : "") + panel.frameOffset + " frames / "
|
||||||
|
+ (panel.frameOffsetSeconds >= 0 ? "+" : "") + panel.frameOffsetSeconds.toFixed(1) + "s")
|
||||||
|
: "--"
|
||||||
|
valueColor: panel.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "MAX SENSOR DEVIATION"
|
||||||
|
value: panel.maxSensorDeviation >= 0 ? panel.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||||
|
valueColor: panel.maxSensorDeviation >= 0
|
||||||
|
? (panel.maxSensorDeviation < 1.0 ? Theme.metricGood : panel.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento 2: Overlap Map Settings
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: overlapCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: overlapCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "OVERLAP SETTINGS" }
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: enableOverlapCheck
|
||||||
|
Layout.fillWidth: true
|
||||||
|
checked: true
|
||||||
|
text: "Enable Overlap Map"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
visible: enableOverlapCheck.checked
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "Blend"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.bodyColor
|
||||||
|
Layout.preferredWidth: 40
|
||||||
|
}
|
||||||
|
PanelSlider {
|
||||||
|
id: blendSlider
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: 0; to: 100; value: 50
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: Math.round(blendSlider.value) + "%"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
Layout.preferredWidth: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff color legend
|
||||||
|
Row {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento: Frame / Time Controls
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: frameTimeCol.implicitHeight + 32
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: frameTimeCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 20
|
||||||
|
|
||||||
|
SectionTitle { text: "FRAME / TIME" }
|
||||||
|
|
||||||
|
EditableScrubStat {
|
||||||
|
label: "Frame"
|
||||||
|
value: scrubReadout.scrubIdx
|
||||||
|
unit: "/ " + Math.max(0, panel.seriesLen - 1) + (Math.max(0, panel.seriesLen - 1) <= 1 ? " frame" : " frames")
|
||||||
|
editable: panel.hasSeries
|
||||||
|
onValueEdited: (newValue) => {
|
||||||
|
var maxVal = Math.max(0, panel.seriesLen - 1)
|
||||||
|
var val = Math.max(0, Math.min(newValue, maxVal))
|
||||||
|
panel.frameIndexRequested(val)
|
||||||
|
}
|
||||||
|
onEditingDone: panel.scrubberFocusRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
EditableScrubStat {
|
||||||
|
label: "Time"
|
||||||
|
value: scrubReadout.scrubTime
|
||||||
|
unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s"
|
||||||
|
editable: panel.hasSeries
|
||||||
|
onValueEdited: (newValue) => {
|
||||||
|
var closestIdx = panel.frameForTime(newValue)
|
||||||
|
panel.frameIndexRequested(closestIdx)
|
||||||
|
}
|
||||||
|
onEditingDone: panel.scrubberFocusRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento: Scrub Readout
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: scrubReadout.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: scrubReadout
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
readonly property int scrubIdx: Math.round(panel.frameIndex)
|
||||||
|
readonly property bool hasA: scrubIdx < panel.trendDataA.length
|
||||||
|
readonly property bool hasB: scrubIdx < panel.trendDataB.length
|
||||||
|
readonly property real tempA: hasA ? panel.trendDataA[scrubIdx] : 0
|
||||||
|
readonly property real tempB: hasB ? panel.trendDataB[scrubIdx] : 0
|
||||||
|
readonly property real scrubTime: {
|
||||||
|
var t = scrubIdx < panel.timeDataA.length ? panel.timeDataA[scrubIdx]
|
||||||
|
: (scrubIdx < panel.timeDataB.length ? panel.timeDataB[scrubIdx] : 0)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
readonly property real maxTime: {
|
||||||
|
var ta = panel.timeDataA.length > 0 ? panel.timeDataA[panel.timeDataA.length - 1] : 0
|
||||||
|
var tb = panel.timeDataB.length > 0 ? panel.timeDataB[panel.timeDataB.length - 1] : 0
|
||||||
|
return Math.max(ta, tb)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle { text: "SCRUBBER READOUT" }
|
||||||
|
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Temp (Run A)"
|
||||||
|
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
|
||||||
|
valueColor: Theme.primaryAccent
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Temp (Run B)"
|
||||||
|
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
|
||||||
|
valueColor: Theme.themeSkill
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Difference"
|
||||||
|
value: (scrubReadout.hasA && scrubReadout.hasB)
|
||||||
|
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sideFooterConnection
|
||||||
|
visible: root.selectedTabIndex !== 1
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
// Row 1: Title
|
||||||
|
Label {
|
||||||
|
text: "CONNECTION FLOW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: Port / descriptive text
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: {
|
||||||
|
if (deviceController.connectionStatus === "Connected")
|
||||||
|
return deviceController.selectedPort || "Connected"
|
||||||
|
if (deviceController.selectedPort)
|
||||||
|
return deviceController.selectedPort
|
||||||
|
return "No port selected"
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
// Row 3: Mode chip left, status badge bottom-right
|
||||||
|
RowLayout {
|
||||||
|
spacing: 6
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: modeChipLabel.implicitWidth + 12
|
||||||
|
height: 18
|
||||||
|
radius: 4
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: modeChipLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: streamController.mode
|
||||||
|
? streamController.mode.toUpperCase() : "REVIEW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: 18
|
||||||
|
width: badgeLabel.implicitWidth + 14
|
||||||
|
radius: 9
|
||||||
|
color: {
|
||||||
|
var s = deviceController.connectionStatus
|
||||||
|
if (s === "Connected") return Theme.statusSuccessColor
|
||||||
|
if (s === "Disconnected") return Theme.statusErrorColor
|
||||||
|
return Theme.statusWarningColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: badgeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: deviceController.connectionStatus || "Disconnected"
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Editable stat box for scrubber readout (styled to match DTW ReadoutCard)
|
||||||
|
Rectangle {
|
||||||
|
id: edStat
|
||||||
|
property string label
|
||||||
|
property real value
|
||||||
|
property string unit
|
||||||
|
property string extraText: ""
|
||||||
|
property bool editable: true
|
||||||
|
signal valueEdited(real newValue)
|
||||||
|
// Fires when the input field commits (Enter/focus loss), even if the
|
||||||
|
// entered text was invalid — callers use it to hand focus back to the
|
||||||
|
// timeline scrubber, matching the pre-extraction behavior.
|
||||||
|
signal editingDone()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: edStatRow.implicitHeight + 16
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder
|
||||||
|
border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: edStatRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: edStat.label.toUpperCase()
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
TextField {
|
||||||
|
id: inputField
|
||||||
|
readOnly: !edStat.editable
|
||||||
|
hoverEnabled: edStat.editable
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
horizontalAlignment: TextInput.AlignHCenter
|
||||||
|
background: Rectangle {
|
||||||
|
visible: edStat.editable
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
padding: 0
|
||||||
|
leftPadding: edStat.editable ? 6 : 0
|
||||||
|
rightPadding: edStat.editable ? 6 : 0
|
||||||
|
topPadding: edStat.editable ? 5 : 0
|
||||||
|
bottomPadding: edStat.editable ? 5 : 0
|
||||||
|
selectByMouse: edStat.editable
|
||||||
|
inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly
|
||||||
|
Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth
|
||||||
|
Layout.preferredHeight: edStat.editable ? 32 : implicitHeight
|
||||||
|
|
||||||
|
text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||||
|
|
||||||
|
onEditingFinished: {
|
||||||
|
var val = parseFloat(text)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
edStat.valueEdited(val)
|
||||||
|
}
|
||||||
|
text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||||
|
edStat.editingDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: edStat
|
||||||
|
function onValueChanged() {
|
||||||
|
if (!inputField.activeFocus) {
|
||||||
|
inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: edStat.unit
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
visible: edStat.extraText !== ""
|
||||||
|
text: edStat.extraText
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: label
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: value
|
||||||
|
color: valueColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// Wafer overlap map card — renders per-sensor Run A/Run B temperature
|
||||||
|
// differences at the current scrub position.
|
||||||
|
PanelBox {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
required property var sensorLayout
|
||||||
|
required property var diffValues
|
||||||
|
required property var diffBands // function(values) -> band thresholds
|
||||||
|
required property string waferShape
|
||||||
|
required property real waferSize
|
||||||
|
required property bool overlapEnabled
|
||||||
|
required property real blendAmount // 0–100, percent
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "WAFER OVERLAP"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
WaferMapItem {
|
||||||
|
id: overlapMapItem
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: card.sensorLayout.length > 0 && card.overlapEnabled
|
||||||
|
sensors: card.sensorLayout
|
||||||
|
values: card.diffValues
|
||||||
|
bands: card.diffBands(overlapMapItem.values)
|
||||||
|
shape: card.waferShape
|
||||||
|
size: card.waferSize
|
||||||
|
target: 0
|
||||||
|
margin: 1.0
|
||||||
|
blend: card.blendAmount / 100
|
||||||
|
showLabels: true
|
||||||
|
ringColor: Theme.waferRingColor
|
||||||
|
axisColor: Theme.waferAxisColor
|
||||||
|
lowColor: Theme.sensorLow
|
||||||
|
inRangeColor: Theme.sensorInRange
|
||||||
|
highColor: Theme.sensorHigh
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: card.sensorLayout.length === 0 || !card.overlapEnabled
|
||||||
|
spacing: 4
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
source: "../icons/map.svg"
|
||||||
|
width: 24; height: 24
|
||||||
|
sourceSize.width: 24
|
||||||
|
sourceSize.height: 24
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: !card.overlapEnabled ? "Overlap map disabled"
|
||||||
|
: "No sensor map available"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Card container for the Data tab bento boxes.
|
||||||
|
Rectangle {
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
|
||||||
|
// indicator + check icon) instead of the platform-default CheckBox look.
|
||||||
|
CheckBox {
|
||||||
|
id: toggle
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
x: toggle.leftPadding
|
||||||
|
y: parent.height / 2 - height / 2
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: "../icons/check.svg"
|
||||||
|
width: 12; height: 12
|
||||||
|
sourceSize.width: 12
|
||||||
|
sourceSize.height: 12
|
||||||
|
color: Theme.panelBackground
|
||||||
|
opacity: toggle.checked ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
text: toggle.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
|
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Slider styled to match MapTab's right-rail heatmap slider (circular
|
||||||
|
// accent handle that grows slightly on press).
|
||||||
|
Slider {
|
||||||
|
id: panelSlider
|
||||||
|
handle: Rectangle {
|
||||||
|
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
|
||||||
|
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
radius: 9
|
||||||
|
color: Theme.primaryAccent
|
||||||
|
scale: panelSlider.pressed ? 1.15 : 1.0
|
||||||
|
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ Rectangle {
|
|||||||
property string label: ""
|
property string label: ""
|
||||||
property string iconSource: ""
|
property string iconSource: ""
|
||||||
property bool destructive: false
|
property bool destructive: false
|
||||||
|
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||||
|
// that's mid-operation), independent of hover/press state.
|
||||||
|
property bool toggled: false
|
||||||
property color _textColor: !root.enabled ? Theme.sideMutedText
|
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||||
: root.destructive ? Theme.statusErrorColor
|
: root.destructive ? Theme.statusErrorColor
|
||||||
: Theme.headingColor
|
: Theme.headingColor
|
||||||
@@ -20,7 +23,7 @@ Rectangle {
|
|||||||
|
|
||||||
implicitHeight: Theme.sideButtonHeight
|
implicitHeight: Theme.sideButtonHeight
|
||||||
radius: 8
|
radius: 8
|
||||||
color: mouseArea.containsMouse || mouseArea.pressed
|
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||||
? Theme.sideActiveBackground : "transparent"
|
? Theme.sideActiveBackground : "transparent"
|
||||||
border.width: 0
|
border.width: 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +1,26 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Controls.impl
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Readout Panel =====
|
// ===== Readout Panel =====
|
||||||
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS.
|
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
|
||||||
// Cards match the left-rail SOURCE / CONNECTION FLOW style.
|
// THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
|
||||||
|
// PanelCheckBox / PanelSlider components as the Data tab's
|
||||||
|
// ComparisonSidePanel, so both right rails share one visual language.
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
|
id: root
|
||||||
spacing: 6
|
spacing: 6
|
||||||
property var s: streamController.stats
|
property var s: streamController.stats
|
||||||
property alias showLabels: labelsToggle.checked
|
property alias showLabels: labelsToggle.checked
|
||||||
|
property alias showExtremes: extremesToggle.checked
|
||||||
property alias heatmapBlend: heatmapSlider.value
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
property alias showThickness: thicknessToggle.checked
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
property bool hasThicknessData: false
|
||||||
|
|
||||||
component PanelCheckBox: CheckBox {
|
signal exportRequested(string filePath, string extra)
|
||||||
id: toggle
|
signal thicknessFileChosen(string filePath)
|
||||||
indicator: Rectangle {
|
|
||||||
implicitWidth: 18
|
|
||||||
implicitHeight: 18
|
|
||||||
x: toggle.leftPadding
|
|
||||||
y: parent.height / 2 - height / 2
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
Behavior on border.color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component BadgePill: Rectangle {
|
component BadgePill: Rectangle {
|
||||||
implicitWidth: badgeLabel.implicitWidth + 10
|
implicitWidth: badgeLabel.implicitWidth + 10
|
||||||
@@ -75,279 +40,127 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
StreamControlPanel {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: readoutCard.implicitHeight + 24
|
onExportRequested: function(filePath, extra) {
|
||||||
color: Theme.sidePanelBackground
|
root.exportRequested(filePath, extra);
|
||||||
radius: Theme.sidePanelRadius
|
}
|
||||||
border.color: Theme.sideBorder
|
}
|
||||||
border.width: 1
|
|
||||||
|
// ── READOUT ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: readoutCard
|
id: readoutCol
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
spacing: 8
|
||||||
top: parent.top
|
|
||||||
topMargin: 12
|
|
||||||
}
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Header Inside the Box
|
SectionTitle { text: "READOUT" }
|
||||||
Label {
|
|
||||||
text: "READOUT"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.topMargin: 2
|
|
||||||
Layout.bottomMargin: 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Min Temp Row
|
ReadoutStat {
|
||||||
RowLayout {
|
label: "Min Temp"
|
||||||
Layout.fillWidth: true
|
value: s.min !== undefined
|
||||||
Layout.leftMargin: 14
|
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||||
Layout.rightMargin: 14
|
: "—"
|
||||||
Layout.preferredHeight: 32
|
valueColor: Theme.sensorLow
|
||||||
Label {
|
|
||||||
text: "MIN TEMP"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
RowLayout {
|
|
||||||
spacing: 4
|
|
||||||
Label {
|
|
||||||
text: s.min !== undefined ? s.min : "—"
|
|
||||||
color: Theme.sensorLow
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: (s.minIndex !== undefined) && s.minIndex >= 0
|
|
||||||
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
Layout.alignment: Qt.AlignBottom
|
|
||||||
bottomPadding: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "Max Temp"
|
||||||
Layout.fillWidth: true
|
value: s.max !== undefined
|
||||||
height: 1
|
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||||
color: Theme.cardBorder
|
: "—"
|
||||||
|
valueColor: Theme.sensorHigh
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Max Temp Row
|
label: "Diff"
|
||||||
RowLayout {
|
value: s.diff !== undefined ? s.diff : "—"
|
||||||
Layout.fillWidth: true
|
valueColor: Theme.diffAccent
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "MAX TEMP"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
RowLayout {
|
|
||||||
spacing: 4
|
|
||||||
Label {
|
|
||||||
text: s.max !== undefined ? s.max : "—"
|
|
||||||
color: Theme.sensorHigh
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
|
|
||||||
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
Layout.alignment: Qt.AlignBottom
|
|
||||||
bottomPadding: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "Average"
|
||||||
Layout.fillWidth: true
|
value: s.avg !== undefined ? s.avg : "—"
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Differential Row
|
label: "Sigma (Σ)"
|
||||||
RowLayout {
|
value: s.sigma !== undefined ? s.sigma : "—"
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "DIFF"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.diff !== undefined ? s.diff : "—"
|
|
||||||
color: Theme.diffAccent
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "3Σ Value"
|
||||||
Layout.fillWidth: true
|
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Average Row
|
visible: s.ecMinDelta !== undefined
|
||||||
RowLayout {
|
label: "E-C Δ Min"
|
||||||
Layout.fillWidth: true
|
value: s.ecMinDelta !== undefined
|
||||||
Layout.leftMargin: 14
|
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||||
Layout.rightMargin: 14
|
: "—"
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "AVERAGE"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.avg !== undefined ? s.avg : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
visible: s.ecMaxDelta !== undefined
|
||||||
Layout.fillWidth: true
|
label: "E-C Δ Max"
|
||||||
height: 1
|
value: s.ecMaxDelta !== undefined
|
||||||
color: Theme.cardBorder
|
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||||
}
|
: "—"
|
||||||
|
|
||||||
// Sigma Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "SIGMA (Σ)"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.sigma !== undefined ? s.sigma : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-Sigma Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "3Σ VALUE"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: displayCard.implicitHeight + 24
|
Layout.preferredHeight: displayCard.implicitHeight + 24
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: displayCard
|
id: displayCard
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "DISPLAY" }
|
||||||
text: "DISPLAY"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.bottomMargin: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: thicknessToggle
|
id: thicknessToggle
|
||||||
text: "Show Thickness"
|
text: "Show Thickness"
|
||||||
checked: false
|
checked: false
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
|
// First check with no data prompts for the customer CSV;
|
||||||
|
// unchecking only hides the overlay, data stays loaded.
|
||||||
|
onToggled: {
|
||||||
|
if (checked && !root.hasThicknessData)
|
||||||
|
thicknessDialog.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: thicknessDialog
|
||||||
|
title: "Select Thickness Data File"
|
||||||
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
|
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
|
onRejected: thicknessToggle.checked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: labelsToggle
|
id: labelsToggle
|
||||||
text: "Labels"
|
text: "Labels"
|
||||||
checked: true
|
checked: true
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: extremesToggle
|
||||||
|
text: "Highlight Min/Max"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: clusterAverageToggle
|
id: clusterAverageToggle
|
||||||
text: "Average Clusters"
|
text: "Average Clusters"
|
||||||
checked: streamController.clusterAveragingEnabled
|
checked: streamController.clusterAveragingEnabled
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,35 +176,25 @@ ColumnLayout {
|
|||||||
Label {
|
Label {
|
||||||
text: "Heatmap"
|
text: "Heatmap"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
Layout.preferredWidth: 52
|
Layout.preferredWidth: 52
|
||||||
}
|
}
|
||||||
Slider {
|
PanelSlider {
|
||||||
id: heatmapSlider
|
id: heatmapSlider
|
||||||
from: 0
|
from: 0
|
||||||
to: 1
|
to: 1
|
||||||
value: 0
|
value: 0
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
ToolTip.visible: hovered
|
|
||||||
ToolTip.text: Math.round(value * 100) + "%"
|
|
||||||
|
|
||||||
handle: Rectangle {
|
AppToolTip {
|
||||||
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
|
visible: heatmapSlider.hovered
|
||||||
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
implicitWidth: 18
|
|
||||||
implicitHeight: 18
|
|
||||||
radius: 9
|
|
||||||
color: Theme.primaryAccent
|
|
||||||
scale: heatmapSlider.pressed ? 1.15 : 1.0
|
|
||||||
Behavior on scale {
|
|
||||||
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
Layout.preferredWidth: 32
|
Layout.preferredWidth: 32
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
@@ -400,38 +203,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// ── THRESHOLDS ──────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: thresholdCard.implicitHeight + 24
|
Layout.preferredHeight: thresholdCard.implicitHeight + 24
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: thresholdCard
|
id: thresholdCard
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "THRESHOLDS" }
|
||||||
text: "THRESHOLDS"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.bottomMargin: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "Set Point (°C)"
|
text: "Set Point (°C)"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
TextField {
|
TextField {
|
||||||
id: spField
|
id: spField
|
||||||
@@ -461,7 +249,7 @@ ColumnLayout {
|
|||||||
Label {
|
Label {
|
||||||
text: "Margin (±°C)"
|
text: "Margin (±°C)"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
TextField {
|
TextField {
|
||||||
id: mgField
|
id: mgField
|
||||||
@@ -492,7 +280,7 @@ ColumnLayout {
|
|||||||
id: autoCheck
|
id: autoCheck
|
||||||
text: "Auto range (mean ± 1σ)"
|
text: "Auto range (mean ± 1σ)"
|
||||||
checked: true
|
checked: true
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
onCheckedChanged: pushThresholds()
|
onCheckedChanged: pushThresholds()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Small labeled value tile — DTW readout panel, scrubber readout, and any
|
||||||
|
// other compact label/value stat display share this one shape.
|
||||||
|
Rectangle {
|
||||||
|
id: readoutStat
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 52
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: readoutStat.label.toUpperCase()
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: readoutStat.value
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: readoutStat.valueColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// ===== Run Chart =====
|
||||||
|
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
|
||||||
|
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
|
||||||
|
// the strip below shows the aggregate min/max/avg over the *visible* range
|
||||||
|
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
|
||||||
|
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
|
||||||
|
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias sensorNames: chart.sensorNames
|
||||||
|
property alias seriesData: chart.seriesData
|
||||||
|
property alias title: chart.title
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: chart
|
||||||
|
anchors.fill: parent
|
||||||
|
showMinMaxMarkers: true
|
||||||
|
xLabel: "Measurement Interval"
|
||||||
|
yLabel: "Temperature (°C)"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: interaction
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
property real lastX: 0
|
||||||
|
|
||||||
|
onWheel: function(wheel) {
|
||||||
|
var frac = Math.max(0, Math.min(1, wheel.x / width));
|
||||||
|
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
|
||||||
|
chart.zoomAtFraction(frac, factor);
|
||||||
|
wheel.accepted = true;
|
||||||
|
}
|
||||||
|
onPressed: function(mouse) {
|
||||||
|
lastX = mouse.x;
|
||||||
|
}
|
||||||
|
onPositionChanged: function(mouse) {
|
||||||
|
if (pressed && width > 0) {
|
||||||
|
chart.panByFraction(-(mouse.x - lastX) / width);
|
||||||
|
lastX = mouse.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Min:"
|
||||||
|
color: Theme.sensorLow
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Max:"
|
||||||
|
color: Theme.sensorHigh
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Avg:"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewAvg.toFixed(2)
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: resetZoomBtn
|
||||||
|
implicitHeight: 32
|
||||||
|
hoverEnabled: true
|
||||||
|
text: "Reset Zoom"
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: resetZoomBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: resetZoomBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: chart.resetZoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Uppercase card heading — shared by the Data tab cards.
|
||||||
|
Label {
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
+1
-1
@@ -186,7 +186,7 @@ Dialog {
|
|||||||
implicitHeight: 32
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,26 +160,44 @@ ColumnLayout {
|
|||||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
|
readonly property var dataTab: {
|
||||||
readonly property bool isActive:{
|
|
||||||
try {
|
try {
|
||||||
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
|
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||||
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
|
} catch (e) { return null; }
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
|
||||||
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
// Run A's effective file: the master file when "Compare vs Master
|
||||||
|
// File" is toggled on, otherwise the manually selected file.
|
||||||
|
readonly property string effectiveRunAFile: fileItem.dataTab
|
||||||
|
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
|
||||||
|
: ""
|
||||||
|
readonly property bool isRunA: fileItem.dataTab !== null
|
||||||
|
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
|
||||||
|
readonly property bool isRunB: fileItem.dataTab !== null
|
||||||
|
&& modelData.fileName === fileItem.dataTab.compareFileB
|
||||||
|
|
||||||
|
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
|
||||||
|
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
|
||||||
|
readonly property bool isActive: fileItem.dataTab !== null
|
||||||
|
? (fileItem.isRunA || fileItem.isRunB)
|
||||||
|
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
|
||||||
|
|
||||||
|
// A file registered as any family's master in Settings → Master Files.
|
||||||
|
readonly property bool isMasterFile: {
|
||||||
|
var m = settingsModel.masters
|
||||||
|
for (var fam in m) {
|
||||||
|
if (m[fam] && m[fam] === modelData.fileName) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// "live_<serial>_<timestamp>.csv" is SessionController's recording
|
|
||||||
// naming convention (see FileBrowser._refresh_files) — grey these out
|
|
||||||
// unconditionally rather than only while actively being written, since
|
|
||||||
// a stale directory listing can't tell "mid-write" from "just finished".
|
|
||||||
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
||||||
|
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
|
||||||
|
|
||||||
visible: matchesFilter
|
visible: matchesFilter
|
||||||
height: matchesFilter ? implicitHeight : 0
|
height: matchesFilter ? implicitHeight : 0
|
||||||
enabled: !isLiveFile
|
enabled: !liveFileBlocked
|
||||||
opacity: isLiveFile ? 0.45 : 1.0
|
opacity: liveFileBlocked ? 0.45 : 1.0
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
NumberAnimation { duration: Theme.durationFast }
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
@@ -236,6 +224,8 @@ ColumnLayout {
|
|||||||
bottomMargin: 5
|
bottomMargin: 5
|
||||||
}
|
}
|
||||||
color: {
|
color: {
|
||||||
|
if (fileItem.isRunA) return Theme.primaryAccent;
|
||||||
|
if (fileItem.isRunB) return Theme.themeSkill;
|
||||||
var t = modelData.waferType;
|
var t = modelData.waferType;
|
||||||
if (t === "A" || t === "E" || t === "P")
|
if (t === "A" || t === "E" || t === "P")
|
||||||
return Theme.familyBlueAccent;
|
return Theme.familyBlueAccent;
|
||||||
@@ -293,6 +283,16 @@ ColumnLayout {
|
|||||||
anchors.topMargin: -2
|
anchors.topMargin: -2
|
||||||
anchors.rightMargin: -2
|
anchors.rightMargin: -2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accent ring — this file is registered as a master (Settings → Master Files).
|
||||||
|
Rectangle {
|
||||||
|
visible: fileItem.isMasterFile
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Theme.primaryAccent
|
||||||
|
border.width: 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typography hierarchy
|
// Typography hierarchy
|
||||||
@@ -332,6 +332,25 @@ ColumnLayout {
|
|||||||
font.letterSpacing: 0.6
|
font.letterSpacing: 0.6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: fileItem.isMasterFile
|
||||||
|
radius: 4
|
||||||
|
color: Qt.alpha(Theme.primaryAccent, 0.18)
|
||||||
|
implicitWidth: masterLabel.implicitWidth + 10
|
||||||
|
implicitHeight: masterLabel.implicitHeight + 4
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: masterLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "MASTER"
|
||||||
|
color: Theme.primaryAccent
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary: date · time
|
// Secondary: date · time
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ Popup {
|
|||||||
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Item { Layout.preferredWidth: 80 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +268,31 @@ Popup {
|
|||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: exportBtn
|
||||||
|
text: "Export"
|
||||||
|
Layout.preferredWidth: 80
|
||||||
|
Layout.preferredHeight: 26
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: streamController.exportSegment(index)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: exportBtn.pressed ? Theme.buttonNeutralPressed
|
||||||
|
: exportBtn.hovered ? Theme.buttonNeutralHover
|
||||||
|
: Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: exportBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +321,16 @@ Popup {
|
|||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Export feedback ───────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
id: exportStatus
|
||||||
|
visible: text !== ""
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backend Connection ────────────────────────────────────────
|
// ── Backend Connection ────────────────────────────────────────
|
||||||
@@ -302,6 +338,7 @@ Popup {
|
|||||||
target: streamController
|
target: streamController
|
||||||
function onSplitResult(result) {
|
function onSplitResult(result) {
|
||||||
root.segmenting = false;
|
root.segmenting = false;
|
||||||
|
exportStatus.text = "";
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
root.segments = result.segments || [];
|
root.segments = result.segments || [];
|
||||||
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||||
@@ -310,6 +347,13 @@ Popup {
|
|||||||
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSegmentExported(result) {
|
||||||
|
if (result && result.success)
|
||||||
|
exportStatus.text = "Exported: " + result.path.split("/").pop();
|
||||||
|
else
|
||||||
|
exportStatus.text = "Export failed: " + (result.error || "unknown");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
|
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: Theme.sideRailSpacing
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: hwActionsCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "HARDWARE ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "DETECT WAFER"
|
||||||
|
iconSource: "../icons/detect.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !deviceController.operationInProgress
|
||||||
|
onClicked: root._doDetect()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "READ MEMORY"
|
||||||
|
iconSource: "../icons/read.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.readMemoryAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "ERASE MEMORY"
|
||||||
|
iconSource: "../icons/erase.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
destructive: true
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.eraseMemory(
|
||||||
|
deviceController.selectedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "READ DEBUG"
|
||||||
|
iconSource: "../icons/read.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
// One-shot toggle: "on" while the debug read is in flight,
|
||||||
|
// resets automatically once deviceController flips status.
|
||||||
|
toggled: deviceController.connectionStatus === "Reading debug..."
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: logActionsCol.implicitHeight + 28
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: logActionsCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "LOG ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "REFRESH SESSION"
|
||||||
|
iconSource: "../icons/refresh.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "CLEAR LOG"
|
||||||
|
iconSource: "../icons/x.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
onClicked: deviceController.clearActivityLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: contentCol.implicitHeight + 24
|
||||||
|
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
signal exportRequested(string filePath, string extra)
|
||||||
|
|
||||||
|
property int _liveSecs: 0
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onModeChanged() {
|
||||||
|
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: liveTimer
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
|
onTriggered: root._liveSecs++
|
||||||
|
onRunningChanged: if (!running) root._liveSecs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
var ss = s % 60;
|
||||||
|
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToReview() {
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: contentCol
|
||||||
|
anchors {
|
||||||
|
fill: parent
|
||||||
|
topMargin: 10
|
||||||
|
leftMargin: 12
|
||||||
|
rightMargin: 12
|
||||||
|
bottomMargin: 12
|
||||||
|
}
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
TabBar {
|
||||||
|
id: modeBar
|
||||||
|
currentIndex: 0
|
||||||
|
spacing: 2
|
||||||
|
padding: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 28
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TabButton {
|
||||||
|
text: "Review"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusXs - 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TabButton {
|
||||||
|
id: liveTab
|
||||||
|
text: "Live"
|
||||||
|
enabled: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: disabledHover.containsMouse && !liveTab.enabled
|
||||||
|
text: "Connect a wafer to enable Live mode"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: disabledHover
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !liveTab.enabled
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusXs - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
anchors.centerIn: parent
|
||||||
|
Rectangle {
|
||||||
|
id: liveTabDot
|
||||||
|
width: 5; height: 5; radius: 2.5
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
||||||
|
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||||
|
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: liveTab.text
|
||||||
|
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font: liveTab.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
height: parent.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCurrentIndexChanged: {
|
||||||
|
if (currentIndex === 1) {
|
||||||
|
if (deviceController.connectionStatus !== "Connected") {
|
||||||
|
currentIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamController.setMode("live");
|
||||||
|
var fc = "";
|
||||||
|
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
||||||
|
fc = deviceController.lastWaferInfo[0];
|
||||||
|
}
|
||||||
|
streamController.startStream(deviceController.selectedPort, fc);
|
||||||
|
} else {
|
||||||
|
streamController.setMode("review");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "METRICS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Received Frames"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Errors"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
|
||||||
|
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Resyncs"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Elapsed"
|
||||||
|
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: recordBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 28
|
||||||
|
enabled: streamController.mode === "live"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
text: streamController.recording ? "Stop REC" : "Record"
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: recordBtn.hovered && !recordBtn.enabled
|
||||||
|
text: "Connect to Live to start recording"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.recording) {
|
||||||
|
streamController.stopRecording();
|
||||||
|
} else {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
var serial = (info && info.length > 1) ? info[1] : "";
|
||||||
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||||
|
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||||
|
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: recordBtn.down ? Theme.transportButtonHover
|
||||||
|
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: recordBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: exportBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 28
|
||||||
|
text: "Export"
|
||||||
|
// Nothing to export until a file is loaded or a stream has played
|
||||||
|
enabled: streamController.sensorValues.length > 0
|
||||||
|
opacity: enabled ? 1 : 0.4
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
// Default into <saveDataDir>/Export (created on demand)
|
||||||
|
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
|
||||||
|
exportDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: exportBtn.down ? Theme.transportButtonHover
|
||||||
|
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: exportBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: exportDialog
|
||||||
|
title: "Export Wafer Map"
|
||||||
|
fileMode: FileDialog.SaveFile
|
||||||
|
defaultSuffix: "png"
|
||||||
|
nameFilters: ["PNG files (*.png)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = String(selectedFile).replace(/^file:\/\//, "");
|
||||||
|
var extra = "";
|
||||||
|
if (streamController.mode === "live") {
|
||||||
|
extra = "Frames: " + streamController.receivedCount
|
||||||
|
+ " Errors: " + streamController.errorCount
|
||||||
|
+ " Resyncs: " + streamController.resyncCount
|
||||||
|
+ " Elapsed: " + root.fmtTime(root._liveSecs);
|
||||||
|
}
|
||||||
|
root.exportRequested(path, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: primaryBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 32
|
||||||
|
text: streamController.mode === "live" ? "STOP" : "START"
|
||||||
|
enabled: streamController.mode === "live"
|
||||||
|
? true
|
||||||
|
: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: primaryBtn.hovered && !primaryBtn.enabled
|
||||||
|
text: "Connect a wafer to enable Live mode"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.mode === "live") {
|
||||||
|
streamController.stopStream();
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
} else {
|
||||||
|
modeBar.currentIndex = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: primaryBtn.down ? Theme.transportButtonHover
|
||||||
|
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: primaryBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Controls.impl
|
|
||||||
import ISC
|
|
||||||
|
|
||||||
// ===== Stream Stats Dialog =====
|
|
||||||
// Popup showing live stream statistics: received frames, errors,
|
|
||||||
// resync count, and elapsed time. Opened from the Live toolbar.
|
|
||||||
|
|
||||||
Popup {
|
|
||||||
id: root
|
|
||||||
modal: true
|
|
||||||
dim: true
|
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
|
||||||
width: 320
|
|
||||||
implicitHeight: content.implicitHeight + 40
|
|
||||||
anchors.centerIn: Overlay.overlay
|
|
||||||
|
|
||||||
// Elapsed seconds supplied by the Live timer in WaferMapTab.
|
|
||||||
property int elapsedSecs: 0
|
|
||||||
|
|
||||||
function fmtTime(s) {
|
|
||||||
var m = Math.floor(s / 60);
|
|
||||||
var ss = s % 60;
|
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
component StatRow: RowLayout {
|
|
||||||
property string label
|
|
||||||
property string value
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: label
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
text: value
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: content
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 20
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
// ── Header ────────────────────────────────────────────────
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "STREAM DATA"
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.headingColor
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
flat: true
|
|
||||||
Layout.preferredWidth: 32
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
onClicked: root.close()
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
}
|
|
||||||
contentItem: IconImage {
|
|
||||||
source: "../icons/x.svg"
|
|
||||||
width: 14; height: 14
|
|
||||||
sourceSize.width: 14
|
|
||||||
sourceSize.height: 14
|
|
||||||
color: Theme.bodyColor
|
|
||||||
anchors.centerIn: parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats box ─────────────────────────────────────────────
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
implicitHeight: statRows.implicitHeight + 20
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
color: Theme.subtleSectionBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: statRows
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 10
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
StatRow {
|
|
||||||
label: "Received frames"
|
|
||||||
value: streamController.receivedCount
|
|
||||||
}
|
|
||||||
StatRow {
|
|
||||||
label: "Errors"
|
|
||||||
value: streamController.errorCount
|
|
||||||
}
|
|
||||||
StatRow {
|
|
||||||
label: "Resyncs"
|
|
||||||
value: streamController.resyncCount
|
|
||||||
}
|
|
||||||
StatRow {
|
|
||||||
label: "Elapsed"
|
|
||||||
value: root.fmtTime(root.elapsedSecs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,10 @@ import QtQuick.Layouts
|
|||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Control Bar =====
|
// ===== Control Bar =====
|
||||||
// Mode-aware footer: cross-fades between Live and Review layouts.
|
// Review-mode transport footer (frame counter, play/pause/step, speed).
|
||||||
// Both variants render as a single centered pill — no full-width bar.
|
// Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM"
|
||||||
|
// card so this bar can collapse to zero height and free vertical space for
|
||||||
|
// the trend chart while streaming.
|
||||||
Item {
|
Item {
|
||||||
id: bar
|
id: bar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -16,120 +18,13 @@ Item {
|
|||||||
}
|
}
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
// Hide entirely when in review mode with no file loaded
|
// Only shown in review mode, and only once a file is loaded
|
||||||
readonly property bool hasContent: {
|
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
|
||||||
if (streamController.mode === "live") return true;
|
|
||||||
return streamController.loadedFile !== "";
|
|
||||||
}
|
|
||||||
readonly property bool isLive: streamController.mode === "live"
|
|
||||||
|
|
||||||
// ── LIVE MODE ─────────────────────────────────────────────────
|
|
||||||
Item {
|
|
||||||
id: liveContent
|
|
||||||
anchors.fill: parent
|
|
||||||
opacity: bar.isLive ? 1.0 : 0.0
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single centered pill for Received + Errors + Stop
|
|
||||||
Rectangle {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
implicitWidth: liveRow.implicitWidth + 48
|
|
||||||
implicitHeight: 68
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Row {
|
|
||||||
id: liveRow
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 24
|
|
||||||
|
|
||||||
// Received counter
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: "Received: " + streamController.receivedCount
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
Rectangle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 1
|
|
||||||
height: 36
|
|
||||||
color: Theme.sideBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errors counter
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: "Errors: " + streamController.errorCount
|
|
||||||
color: streamController.errorCount > 0
|
|
||||||
? Theme.statusWarningColor : Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
Rectangle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 1
|
|
||||||
height: 36
|
|
||||||
color: Theme.sideBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// STOP button
|
|
||||||
Button {
|
|
||||||
id: stopBtn
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
implicitWidth: 112
|
|
||||||
implicitHeight: 48
|
|
||||||
hoverEnabled: true
|
|
||||||
text: "STOP"
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.0
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: 8
|
|
||||||
color: stopBtn.pressed ? Theme.transportButtonHover
|
|
||||||
: Theme.transportButtonBg
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font: parent.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: streamController.stopStream()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── REVIEW MODE ──────────────────────────────────────────────
|
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||||
Item {
|
Item {
|
||||||
id: reviewContent
|
id: reviewContent
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
opacity: !bar.isLive ? 1.0 : 0.0
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single centered pill for frame counter + transport + speed
|
// Single centered pill for frame counter + transport + speed
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -147,15 +42,69 @@ Item {
|
|||||||
spacing: 24
|
spacing: 24
|
||||||
|
|
||||||
// Frame counter
|
// Frame counter
|
||||||
Text {
|
Row {
|
||||||
|
spacing: 6
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Frame " + (streamController.frameIndex + 1)
|
|
||||||
+ " / " + streamController.frameTotal
|
Label {
|
||||||
color: Theme.sideMutedText
|
text: "Frame"
|
||||||
font.family: Theme.uiFontFamily
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontMd
|
font.family: Theme.uiFontFamily
|
||||||
font.weight: Font.Medium
|
font.pixelSize: Theme.fontMd
|
||||||
leftPadding: 8
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: frameInput
|
||||||
|
text: String(streamController.frameIndex + 1)
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
horizontalAlignment: TextInput.AlignHCenter
|
||||||
|
selectByMouse: true
|
||||||
|
inputMethodHints: Qt.ImhDigitsOnly
|
||||||
|
padding: 0
|
||||||
|
width: Math.max(44, contentWidth + 16)
|
||||||
|
height: 28
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditingFinished: {
|
||||||
|
var val = parseInt(text)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
|
||||||
|
streamController.seek(targetFrame - 1)
|
||||||
|
}
|
||||||
|
text = String(streamController.frameIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onFrameUpdated() {
|
||||||
|
if (!frameInput.activeFocus) {
|
||||||
|
frameInput.text = String(streamController.frameIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "/ " + streamController.frameTotal
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separator 1
|
// Separator 1
|
||||||
@@ -229,7 +178,7 @@ Item {
|
|||||||
Button {
|
Button {
|
||||||
id: playPauseBtn
|
id: playPauseBtn
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
implicitWidth: 64
|
implicitWidth: 56
|
||||||
implicitHeight: 56
|
implicitHeight: 56
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: utilFooter
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sideFooterUtility
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
property var settingsPopup: null
|
||||||
|
property var aboutDialog: null
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 4
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: settingsBtn
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
icon.source: "../icons/settings.svg"
|
||||||
|
onClicked: if (settingsPopup) settingsPopup.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: settingsBtn.hovered
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 6
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
source: "../icons/settings.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.headingColor
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "SETTINGS"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 1
|
||||||
|
height: 24
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: aboutBtn
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
icon.source: "../icons/about.svg"
|
||||||
|
onClicked: if (aboutDialog) aboutDialog.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: aboutBtn.hovered
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 6
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
source: "../icons/about.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.headingColor
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "ABOUT"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
property real blend: 0.0
|
property real blend: 0.0
|
||||||
property bool showLabels: true
|
property bool showLabels: true
|
||||||
|
property alias showExtremes: map.showExtremes
|
||||||
property alias showThickness: map.showThickness
|
property alias showThickness: map.showThickness
|
||||||
|
property alias hasThickness: map.hasThickness
|
||||||
|
|
||||||
WaferMapItem {
|
WaferMapItem {
|
||||||
id: map
|
id: map
|
||||||
@@ -39,7 +41,9 @@ Item {
|
|||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hoverHandler
|
id: hoverHandler
|
||||||
onPointChanged: {
|
onPointChanged: {
|
||||||
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
map.hoveredIndex = streamController.mode === "live"
|
||||||
|
? -1
|
||||||
|
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||||
}
|
}
|
||||||
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||||
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
@@ -47,6 +51,8 @@ Item {
|
|||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
onTapped: ev => {
|
onTapped: ev => {
|
||||||
|
if (streamController.mode === "live")
|
||||||
|
return;
|
||||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
replaceDialog.openFor(idx);
|
replaceDialog.openFor(idx);
|
||||||
@@ -58,7 +64,11 @@ Item {
|
|||||||
id: replaceDialog
|
id: replaceDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportImage(filePath) {
|
function exportImage(filePath, extra) {
|
||||||
return map.export_image(filePath);
|
return map.export_image(filePath, extra || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThickness(filePath) {
|
||||||
|
return map.loadThickness(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,21 @@ TransportBar 1.0 TransportBar.qml
|
|||||||
WaferMapView 1.0 WaferMapView.qml
|
WaferMapView 1.0 WaferMapView.qml
|
||||||
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||||
RailActionButton 1.0 RailActionButton.qml
|
RailActionButton 1.0 RailActionButton.qml
|
||||||
|
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||||
SplitDialog 1.0 SplitDialog.qml
|
SplitDialog 1.0 SplitDialog.qml
|
||||||
StreamStatsDialog 1.0 StreamStatsDialog.qml
|
StreamControlPanel 1.0 StreamControlPanel.qml
|
||||||
|
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
||||||
|
ConnectionFooter 1.0 ConnectionFooter.qml
|
||||||
|
UtilityFooter 1.0 UtilityFooter.qml
|
||||||
|
PanelCheckBox 1.0 PanelCheckBox.qml
|
||||||
|
ReadoutStat 1.0 ReadoutStat.qml
|
||||||
|
MetricRow 1.0 MetricRow.qml
|
||||||
|
AppToolTip 1.0 AppToolTip.qml
|
||||||
|
SectionTitle 1.0 SectionTitle.qml
|
||||||
|
PanelBox 1.0 PanelBox.qml
|
||||||
|
PanelSlider 1.0 PanelSlider.qml
|
||||||
|
EditableScrubStat 1.0 EditableScrubStat.qml
|
||||||
|
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||||
|
OverlapMapCard 1.0 OverlapMapCard.qml
|
||||||
|
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
||||||
|
RunChart 1.0 RunChart.qml
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ QtObject {
|
|||||||
readonly property int rightPaneGap: 6
|
readonly property int rightPaneGap: 6
|
||||||
readonly property int panelPadding: 12
|
readonly property int panelPadding: 12
|
||||||
// Side rail layout
|
// Side rail layout
|
||||||
readonly property int sideRailWidth: 280
|
readonly property int sideRailWidth: 300
|
||||||
|
readonly property int rightRailWidth: 280
|
||||||
readonly property int sideRailMargin: 16
|
readonly property int sideRailMargin: 16
|
||||||
readonly property int sideRailSpacing: 16
|
readonly property int sideRailSpacing: 16
|
||||||
readonly property int sideButtonHeight: 44
|
readonly property int sideButtonHeight: 44
|
||||||
|
|||||||
+43
-16
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtQml import QQmlApplicationEngine
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
@@ -8,11 +9,22 @@ from PySide6.QtWidgets import QApplication
|
|||||||
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
from pygui.backend.data.constants import default_data_dir
|
||||||
from pygui.backend.data.file_browser import FileBrowser
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
from pygui.backend.license.license_model import LicenseModel
|
||||||
|
|
||||||
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
||||||
|
from pygui.backend.visualization import (
|
||||||
|
graph_quick_item as graph_quick_item, # noqa: F401
|
||||||
|
)
|
||||||
|
from pygui.backend.visualization import (
|
||||||
|
trend_chart_item as trend_chart_item, # noqa: F401
|
||||||
|
)
|
||||||
|
from pygui.backend.visualization import (
|
||||||
|
wafer_map_item as wafer_map_item, # noqa: F401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===== Application Entry Point =====
|
# ===== Application Entry Point =====
|
||||||
@@ -30,34 +42,49 @@ def main() -> int:
|
|||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
engine = QQmlApplicationEngine()
|
engine = QQmlApplicationEngine()
|
||||||
|
|
||||||
# ===== Shared Models =====
|
# ===== Shared Settings =====
|
||||||
settings_model = LocalSettingsModel()
|
# One LocalSettings instance, one disk read. LocalSettingsModel and
|
||||||
|
# DeviceController both hold this same object, so a save from the
|
||||||
|
# Settings tab is visible to the device layer immediately — no reload,
|
||||||
|
# no second copy to go stale.
|
||||||
|
data_dir = str(default_data_dir())
|
||||||
|
raw_settings = LocalSettings.read_settings(data_dir)
|
||||||
|
|
||||||
|
settings_model = LocalSettingsModel(raw_settings, data_dir)
|
||||||
select_file_dialog_model = FileBrowser()
|
select_file_dialog_model = FileBrowser()
|
||||||
settings_model.loadSettings()
|
settings_model.loadSettings()
|
||||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||||
|
|
||||||
|
# App version from package metadata so the About dialog can't drift
|
||||||
|
# from pyproject.toml.
|
||||||
|
try:
|
||||||
|
app_version = version("pygui")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
app_version = "dev"
|
||||||
|
engine.rootContext().setContextProperty("appVersion", app_version)
|
||||||
|
|
||||||
# ===== Device Controller (serial comm) =====
|
# ===== Device Controller (serial comm) =====
|
||||||
data_dir = str(settings_model._data_dir)
|
# TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
|
||||||
raw_settings = LocalSettings.read_settings(data_dir)
|
# license_lookup=license_model.levelForWafer into the ctor.
|
||||||
|
# THINKING: controller must answer "is this serial licensed?" at
|
||||||
|
# read/erase time (P2.1); a bound Callable[[str], str] avoids a
|
||||||
|
# LicenseModel import in the controller and keeps it testable with a
|
||||||
|
# lambda. See docs/pending/license-gating-plan.md §1.1.
|
||||||
device_controller = DeviceController(raw_settings, data_dir)
|
device_controller = DeviceController(raw_settings, data_dir)
|
||||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
|
# Source panel browses the same folder recordings/reads are saved to.
|
||||||
|
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
|
||||||
|
|
||||||
|
# ===== License Model (About dialog grid, replay trial) =====
|
||||||
|
license_model = LicenseModel(data_dir)
|
||||||
|
engine.rootContext().setContextProperty("licenseModel", license_model)
|
||||||
|
|
||||||
# ===== Session Controller (live/review wafer dashboard) =====
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
|
stream_controller = SessionController()
|
||||||
stream_controller = SessionController(settings=raw_settings_dict)
|
|
||||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
# Persist session state back to settings when it changes
|
|
||||||
def _persist_session_settings():
|
|
||||||
session_state = stream_controller.collect_settings()
|
|
||||||
for k, v in session_state.items():
|
|
||||||
if hasattr(raw_settings, k):
|
|
||||||
setattr(raw_settings, k, v)
|
|
||||||
LocalSettings.save_settings(data_dir, raw_settings)
|
|
||||||
|
|
||||||
stream_controller.settingsChanged.connect(_persist_session_settings)
|
|
||||||
|
|
||||||
# ===== QML Startup =====
|
# ===== QML Startup =====
|
||||||
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||||
# package directory is the import path the engine searches for qmldir.
|
# package directory is the import path the engine searches for qmldir.
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# ===== Backend Package =====
|
|
||||||
# Backward-compatible re-exports so existing callers keep working
|
|
||||||
# while imports are migrated to the new sub-package paths.
|
|
||||||
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
|
|
||||||
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
|
|
||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
|
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
|
|
||||||
from pygui.backend.data.data_records import read_data_records # noqa: F401
|
|
||||||
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
|
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
|
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
|
|
||||||
from pygui.backend.models.frame import Frame # noqa: F401
|
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
|
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
|
|
||||||
from pygui.backend.models.session_model import SessionModel # noqa: F401
|
|
||||||
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
|
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
|
|
||||||
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
|
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
|
|
||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
|
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# ===== Controllers Sub-package =====
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController
|
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
|
||||||
|
|
||||||
__all__ = ["DeviceController", "SessionController"]
|
|
||||||
|
|||||||
@@ -13,24 +13,20 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
|
convert_to_debug_temperatures,
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
|
save_debug_csv,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
from pygui.serialcomm.serial_port import WaferInfo
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
|
|
||||||
# import pygui.backend.wafer_map_item
|
|
||||||
|
|
||||||
stream_controller = SessionController()
|
|
||||||
# engine.rootContext().setContextProperty("streamController", stream_controller)
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +40,7 @@ class DeviceController(QObject):
|
|||||||
portsUpdated = Signal(list)
|
portsUpdated = Signal(list)
|
||||||
connectionStatusChanged = Signal()
|
connectionStatusChanged = Signal()
|
||||||
operationInProgressChanged = Signal()
|
operationInProgressChanged = Signal()
|
||||||
|
waferDetectedChanged = Signal()
|
||||||
selectedPortChanged = Signal()
|
selectedPortChanged = Signal()
|
||||||
detectResult = Signal(object) # WaferInfo dict or None
|
detectResult = Signal(object) # WaferInfo dict or None
|
||||||
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
||||||
@@ -73,7 +70,17 @@ class DeviceController(QObject):
|
|||||||
self._activity_log: list[str] = []
|
self._activity_log: list[str] = []
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
||||||
|
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
|
||||||
|
# (default lambda s: "") and add:
|
||||||
|
# def _wafer_license_level(self) -> str:
|
||||||
|
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
|
||||||
|
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
|
||||||
|
# THINKING: single shared guard — per-call-site checks are the
|
||||||
|
# sibling-caller bug (fix read, forget erase). Empty serial → ""
|
||||||
|
# → denied = safe default. Called by P2.1.
|
||||||
|
# See docs/pending/license-gating-plan.md §1.2.
|
||||||
self._last_wafer_info: dict[str, Any] = {}
|
self._last_wafer_info: dict[str, Any] = {}
|
||||||
|
self._wafer_detected: bool = False
|
||||||
self._last_csv_path: str = ""
|
self._last_csv_path: str = ""
|
||||||
self._selected_port: str = ""
|
self._selected_port: str = ""
|
||||||
self._data_row_count: int = 0
|
self._data_row_count: int = 0
|
||||||
@@ -116,6 +123,11 @@ class DeviceController(QObject):
|
|||||||
"""Whether a hardware operation is currently running."""
|
"""Whether a hardware operation is currently running."""
|
||||||
return self._operation_in_progress
|
return self._operation_in_progress
|
||||||
|
|
||||||
|
@Property(str, notify=activityLogUpdated)
|
||||||
|
def activityLog(self) -> str:
|
||||||
|
"""Full activity log text; survives QML tab reloads."""
|
||||||
|
return "\n".join(self._activity_log)
|
||||||
|
|
||||||
@Property(str, notify=activityLogUpdated)
|
@Property(str, notify=activityLogUpdated)
|
||||||
def saveDataDir(self) -> str:
|
def saveDataDir(self) -> str:
|
||||||
"""Directory where parsed CSV data files are saved."""
|
"""Directory where parsed CSV data files are saved."""
|
||||||
@@ -129,6 +141,14 @@ class DeviceController(QObject):
|
|||||||
self._append_log(f"Save data dir set to: {path}")
|
self._append_log(f"Save data dir set to: {path}")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def exportDir(self) -> str:
|
||||||
|
"""Ensure and return the Export subfolder under saveDataDir."""
|
||||||
|
path = Path(self._save_data_dir) / "Export"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def openCsvFile(self, file_path: str) -> None:
|
def openCsvFile(self, file_path: str) -> None:
|
||||||
@@ -204,6 +224,17 @@ class DeviceController(QObject):
|
|||||||
info.get("cycleCount", 0),
|
info.get("cycleCount", 0),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@Property(bool, notify=waferDetectedChanged)
|
||||||
|
def waferDetected(self) -> bool:
|
||||||
|
"""Whether a wafer is currently detected and ready for read/erase."""
|
||||||
|
return self._wafer_detected
|
||||||
|
|
||||||
|
def _set_wafer_detected(self, detected: bool) -> None:
|
||||||
|
if self._wafer_detected != detected:
|
||||||
|
self._wafer_detected = detected
|
||||||
|
self.waferDetectedChanged.emit()
|
||||||
|
|
||||||
|
|
||||||
@Property(object, notify=detectResult)
|
@Property(object, notify=detectResult)
|
||||||
def dataModel(self) -> TemperatureTableModel:
|
def dataModel(self) -> TemperatureTableModel:
|
||||||
"""QAbstractTableModel for the parsed temperature data table."""
|
"""QAbstractTableModel for the parsed temperature data table."""
|
||||||
@@ -268,6 +299,7 @@ class DeviceController(QObject):
|
|||||||
self._set_connection_status("Disconnected")
|
self._set_connection_status("Disconnected")
|
||||||
self._selected_port = ""
|
self._selected_port = ""
|
||||||
self._last_wafer_info = {}
|
self._last_wafer_info = {}
|
||||||
|
self._set_wafer_detected(False)
|
||||||
self._data_row_count = 0
|
self._data_row_count = 0
|
||||||
self._data_col_count = 0
|
self._data_col_count = 0
|
||||||
self._raw_bytes = None
|
self._raw_bytes = None
|
||||||
@@ -324,6 +356,7 @@ class DeviceController(QObject):
|
|||||||
self._graph_view.resetChart()
|
self._graph_view.resetChart()
|
||||||
self._activity_log.clear()
|
self._activity_log.clear()
|
||||||
self.detectResult.emit(None)
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
self.parsedDataReady.emit({"success": False})
|
self.parsedDataReady.emit({"success": False})
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
@@ -349,20 +382,29 @@ class DeviceController(QObject):
|
|||||||
self, port: Optional[str], info: Optional[WaferInfo]
|
self, port: Optional[str], info: Optional[WaferInfo]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for detectWafer."""
|
"""Main-thread completion handler for detectWafer."""
|
||||||
if info is not None:
|
try:
|
||||||
self._selected_port = port or ""
|
if info is not None:
|
||||||
self._set_connection_status("Connected")
|
# Build the dict (can raise) before declaring success, so a
|
||||||
wafer_dict = self._wafer_info_to_dict(info)
|
# bad payload can't leave status="Connected" while
|
||||||
self._last_wafer_info = wafer_dict
|
# waferDetected stays false and Read/Erase stay greyed.
|
||||||
self.detectResult.emit(wafer_dict)
|
wafer_dict = self._wafer_info_to_dict(info)
|
||||||
self._save_status()
|
self._selected_port = port or ""
|
||||||
else:
|
self._last_wafer_info = wafer_dict
|
||||||
self._selected_port = ""
|
self._set_wafer_detected(True)
|
||||||
self._set_connection_status("Disconnected")
|
self._set_connection_status("Connected")
|
||||||
self._append_log("No wafer detected on any port")
|
self.detectResult.emit(wafer_dict)
|
||||||
self._last_wafer_info = {}
|
self.selectedPortChanged.emit()
|
||||||
self.detectResult.emit(None)
|
self._save_status()
|
||||||
self._set_operation_progress(False)
|
else:
|
||||||
|
self._selected_port = ""
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
self._append_log("No wafer detected on any port")
|
||||||
|
self._last_wafer_info = {}
|
||||||
|
self._set_wafer_detected(False)
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
|
finally:
|
||||||
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -377,6 +419,16 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Already busy — ignoring read request")
|
self._append_log("Already busy — ignoring read request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# TODO P2.1: license guard —
|
||||||
|
# if self._wafer_license_level() == "":
|
||||||
|
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||||
|
# self.readResult.emit({"error": "Wafer not licensed"})
|
||||||
|
# return
|
||||||
|
# THINKING: this slot is the trust boundary; the QML enabled
|
||||||
|
# binding (P2.2) is UX only. Reuses existing readResult error
|
||||||
|
# shape → zero new signals. Depends on P1.2. Same guard goes in
|
||||||
|
# eraseMemory (emit eraseResult there). See plan §2.1.
|
||||||
|
|
||||||
if not self._selected_port:
|
if not self._selected_port:
|
||||||
self._append_log("No wafer detected — run Detect Wafer first")
|
self._append_log("No wafer detected — run Detect Wafer first")
|
||||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||||
@@ -436,6 +488,9 @@ class DeviceController(QObject):
|
|||||||
if self._operation_in_progress:
|
if self._operation_in_progress:
|
||||||
self._append_log("Already busy — ignoring erase request")
|
self._append_log("Already busy — ignoring erase request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# TODO P2.1: same license guard as readMemoryAsync, but emit
|
||||||
|
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._set_connection_status("Erasing...")
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
@@ -502,10 +557,29 @@ class DeviceController(QObject):
|
|||||||
self._debugFinished.emit({"error": "F1 read failed"})
|
self._debugFinished.emit({"error": "F1 read failed"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
fc = (
|
||||||
|
self._last_wafer_info.get("familyCode", "")
|
||||||
|
if self._last_wafer_info
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
sensor_hex = parse_binary_data(sensor_data, fc)
|
||||||
|
debug_hex = parse_binary_data(debug_data, fc)
|
||||||
|
if not sensor_hex or not debug_hex:
|
||||||
|
self._debugFinished.emit({"error": "Debug binary parse failed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
temp_data = convert_to_temperatures(sensor_hex, fc)
|
||||||
|
debug_temp_data = convert_to_debug_temperatures(debug_hex)
|
||||||
|
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
|
||||||
|
if csv_path is None:
|
||||||
|
self._debugFinished.emit({"error": "Debug CSV save failed"})
|
||||||
|
return
|
||||||
|
|
||||||
self._debugFinished.emit({
|
self._debugFinished.emit({
|
||||||
"success": True,
|
"success": True,
|
||||||
"sensor_bytes": len(sensor_data),
|
"sensor_bytes": len(sensor_data),
|
||||||
"debug_bytes": len(debug_data),
|
"debug_bytes": len(debug_data),
|
||||||
|
"csv_path": csv_path,
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.exception("Debug worker crashed: %s", exc)
|
log.exception("Debug worker crashed: %s", exc)
|
||||||
@@ -519,10 +593,11 @@ class DeviceController(QObject):
|
|||||||
self._set_connection_status("Connected")
|
self._set_connection_status("Connected")
|
||||||
sensor_bytes = result.get("sensor_bytes", 0)
|
sensor_bytes = result.get("sensor_bytes", 0)
|
||||||
debug_bytes = result.get("debug_bytes", 0)
|
debug_bytes = result.get("debug_bytes", 0)
|
||||||
self._append_log(
|
csv_path = result.get("csv_path", "")
|
||||||
f"Debug read complete: {sensor_bytes} sensor bytes, "
|
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||||
f"{debug_bytes} debug bytes"
|
if csv_path:
|
||||||
)
|
msg += f", CSV written to {csv_path}"
|
||||||
|
self._append_log(msg)
|
||||||
else:
|
else:
|
||||||
self._set_connection_status("Disconnected")
|
self._set_connection_status("Disconnected")
|
||||||
err_msg = result.get("error", "Debug read failed")
|
err_msg = result.get("error", "Debug read failed")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
||||||
@@ -12,12 +11,17 @@ from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
|||||||
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
from pygui.backend.models.frame_player import (
|
||||||
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
FramePlayer,
|
||||||
|
all_sensor_series,
|
||||||
|
frames_from_wafer_data,
|
||||||
|
)
|
||||||
|
from pygui.backend.models.frame_stats import compute_edge_center
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
from pygui.backend.models.session_model import SessionModel
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
@@ -36,22 +40,22 @@ class SessionController(QObject):
|
|||||||
recordingChanged = Signal()
|
recordingChanged = Signal()
|
||||||
sensorsChanged = Signal()
|
sensorsChanged = Signal()
|
||||||
loadedFileChanged = Signal()
|
loadedFileChanged = Signal()
|
||||||
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
|
loadFileError = Signal(str)
|
||||||
clusterAveragingEnabledChanged = Signal()
|
clusterAveragingEnabledChanged = Signal()
|
||||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
|
||||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||||
# trend: per-frame avg for live graph
|
# trend: per-frame avg for live graph
|
||||||
trendData = Signal(str) # JSON array of floats
|
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
|
||||||
|
trendReset = Signal() # live trend buffer cleared (new stream started)
|
||||||
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||||
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
splitResult = Signal(dict) # dict with success, segments
|
splitResult = Signal(dict) # dict with success, segments
|
||||||
|
segmentExported = Signal(dict) # dict with success, path | error
|
||||||
# private: marshal a worker-thread frame onto the main thread
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
_liveFrame = Signal(object) # Frame
|
_liveFrame = Signal(object) # Frame
|
||||||
_liveError = Signal() # worker-thread error ping
|
_liveError = Signal() # worker-thread error ping
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None,
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
settings: dict | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._mode = MODE_REVIEW
|
self._mode = MODE_REVIEW
|
||||||
self._model = SessionModel()
|
self._model = SessionModel()
|
||||||
@@ -59,15 +63,9 @@ class SessionController(QObject):
|
|||||||
self._reader: Optional[StreamReader] = None
|
self._reader: Optional[StreamReader] = None
|
||||||
self._recorder = CsvRecorder()
|
self._recorder = CsvRecorder()
|
||||||
self._sensors: list[Sensor] = []
|
self._sensors: list[Sensor] = []
|
||||||
self._last = None # last SessionUpdate
|
self._last: Optional[SessionUpdate] = None
|
||||||
self._elapsed = 0.0
|
self._elapsed = 0.0
|
||||||
self._trend_buffer: list[float] = [] # rolling window
|
self._stream_start_time: float = 0.0
|
||||||
self._trend_timestamps: list[float] = [] # monotonic timestamps
|
|
||||||
|
|
||||||
# Trend refresh timer - update every 1s
|
|
||||||
self._trend_timer = QTimer(self)
|
|
||||||
self._trend_timer.setInterval(1000)
|
|
||||||
self._trend_timer.timeout.connect(self._flush_trend)
|
|
||||||
|
|
||||||
self._play_timer = QTimer(self)
|
self._play_timer = QTimer(self)
|
||||||
self._play_timer.timeout.connect(self._advance)
|
self._play_timer.timeout.connect(self._advance)
|
||||||
@@ -83,28 +81,18 @@ class SessionController(QObject):
|
|||||||
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
self._sensor_editor = SensorEditor()
|
self._sensor_editor = SensorEditor()
|
||||||
|
self._ec_pairs: tuple[tuple[int, int], ...] = ()
|
||||||
self._last_raw_frame: Frame | None = None
|
self._last_raw_frame: Frame | None = None
|
||||||
self._loaded_file: str = ""
|
self._loaded_file: str = ""
|
||||||
self._cluster_averaging_enabled = False
|
self._cluster_averaging_enabled = False
|
||||||
self._active_clusters: list[list[int]] = []
|
self._active_clusters: list[list[int]] = []
|
||||||
self._stats_tracker = ReplayStatsTracker()
|
|
||||||
self._received_count: int = 0
|
self._received_count: int = 0
|
||||||
self._error_count: int = 0
|
self._error_count: int = 0
|
||||||
|
|
||||||
# Restore persisted state if available
|
# Comparison cache for dynamic overlap mapping
|
||||||
self._load_settings(settings or {})
|
self._compare_recs_a: list = []
|
||||||
|
self._compare_recs_b: list = []
|
||||||
# ---- persisted settings ----
|
self._compare_alignment_map: dict[int, int] = {}
|
||||||
|
|
||||||
def _load_settings(self, settings: dict) -> None:
|
|
||||||
"""Restore session state from persisted settings dict."""
|
|
||||||
# Do NOT auto-restore the last session file — always start fresh.
|
|
||||||
|
|
||||||
def collect_settings(self) -> dict:
|
|
||||||
"""Return a dict of session state to persist."""
|
|
||||||
return {
|
|
||||||
"session_last_file": self._loaded_file,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- properties QML binds to ----
|
# ---- properties QML binds to ----
|
||||||
@Property(str, notify=modeChanged)
|
@Property(str, notify=modeChanged)
|
||||||
@@ -145,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 [
|
||||||
@@ -170,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:
|
||||||
@@ -199,15 +187,43 @@ class SessionController(QObject):
|
|||||||
if not self._last:
|
if not self._last:
|
||||||
return {}
|
return {}
|
||||||
s = self._last.stats
|
s = self._last.stats
|
||||||
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||||
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||||
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||||
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||||
|
ec = compute_edge_center(self._last.values, self._ec_pairs,
|
||||||
|
self._sensor_editor.replaced_indices())
|
||||||
|
if ec is not None:
|
||||||
|
out.update({
|
||||||
|
"ecMinEdgeIndex": ec.min_edge_index + 1,
|
||||||
|
"ecMinCenterIndex": ec.min_center_index + 1,
|
||||||
|
"ecMinEdgeValue": round(ec.min_edge, 2),
|
||||||
|
"ecMinCenterValue": round(ec.min_center, 2),
|
||||||
|
"ecMinDelta": round(ec.min_delta, 2),
|
||||||
|
"ecMaxEdgeIndex": ec.max_edge_index + 1,
|
||||||
|
"ecMaxCenterIndex": ec.max_center_index + 1,
|
||||||
|
"ecMaxEdgeValue": round(ec.max_edge, 2),
|
||||||
|
"ecMaxCenterValue": round(ec.max_center, 2),
|
||||||
|
"ecMaxDelta": round(ec.max_delta, 2),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
@Property(str, notify=loadedFileChanged)
|
@Property(str, notify=loadedFileChanged)
|
||||||
def loadedFile(self) -> str: return self._loaded_file
|
def loadedFile(self) -> str: return self._loaded_file
|
||||||
|
|
||||||
@Property("QVariantList", notify=frameUpdated)
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def graphSensorNames(self) -> str:
|
||||||
|
"""Comma-joined sensor names for the whole-run Graph tab."""
|
||||||
|
names, _ = all_sensor_series(list(self._player.frames))
|
||||||
|
return ",".join(names)
|
||||||
|
|
||||||
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def graphSeriesJson(self) -> str:
|
||||||
|
"""JSON per-sensor value series (one list per sensor) for the Graph tab."""
|
||||||
|
_, series = all_sensor_series(list(self._player.frames))
|
||||||
|
return json.dumps(series)
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||||
def overriddenSensors(self) -> list[int]:
|
def overriddenSensors(self) -> list[int]:
|
||||||
"""Indices of sensors that currently have a replacement or offset."""
|
"""Indices of sensors that currently have a replacement or offset."""
|
||||||
return self._sensor_editor.active_indices()
|
return self._sensor_editor.active_indices()
|
||||||
@@ -216,7 +232,7 @@ class SessionController(QObject):
|
|||||||
def clusterAveragingEnabled(self) -> bool:
|
def clusterAveragingEnabled(self) -> bool:
|
||||||
return self._cluster_averaging_enabled
|
return self._cluster_averaging_enabled
|
||||||
|
|
||||||
@clusterAveragingEnabled.setter
|
@clusterAveragingEnabled.setter # type: ignore[no-redef]
|
||||||
def clusterAveragingEnabled(self, value: bool) -> None:
|
def clusterAveragingEnabled(self, value: bool) -> None:
|
||||||
if self._cluster_averaging_enabled == value:
|
if self._cluster_averaging_enabled == value:
|
||||||
return
|
return
|
||||||
@@ -244,6 +260,12 @@ class SessionController(QObject):
|
|||||||
return
|
return
|
||||||
if mode != "live":
|
if mode != "live":
|
||||||
self.stopStream()
|
self.stopStream()
|
||||||
|
if self._mode == "live":
|
||||||
|
# Leaving a live session: blank the wafer map instead of
|
||||||
|
# freezing the last streamed frame on screen.
|
||||||
|
self._last = None
|
||||||
|
self._last_raw_frame = None
|
||||||
|
self.frameUpdated.emit()
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self.modeChanged.emit()
|
self.modeChanged.emit()
|
||||||
@@ -259,6 +281,8 @@ class SessionController(QObject):
|
|||||||
@Slot(str)
|
@Slot(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def loadFile(self, file_path: str) -> None:
|
def loadFile(self, file_path: str) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pygui.backend.data.data_records import (
|
from pygui.backend.data.data_records import (
|
||||||
is_official_csv,
|
is_official_csv,
|
||||||
read_data_records,
|
read_data_records,
|
||||||
@@ -268,37 +292,25 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
frames = []
|
frames = []
|
||||||
|
|
||||||
if is_official_csv(file_path):
|
try:
|
||||||
sensors, records = read_official_csv(file_path)
|
if is_official_csv(file_path):
|
||||||
frames = frames_from_wafer_data(None, records)
|
sensors, records = read_official_csv(file_path)
|
||||||
else:
|
frames = frames_from_wafer_data(None, records)
|
||||||
try:
|
else:
|
||||||
data, _ = ZWaferParser().parse(file_path)
|
data, _ = ZWaferParser().parse(file_path)
|
||||||
except (ValueError, KeyError) as exc:
|
if data is None or not data.sensors:
|
||||||
msg = f"Could not parse {Path(file_path).name}: {exc}"
|
self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
|
||||||
log.warning("%s", msg)
|
return
|
||||||
self.loadFileError.emit(msg)
|
records = read_data_records(file_path)
|
||||||
return
|
sensors = data.sensors
|
||||||
if data is None or not data.sensors:
|
frames = frames_from_wafer_data(data, records)
|
||||||
msg = f"Could not parse {Path(file_path).name}"
|
|
||||||
log.warning("%s", msg)
|
|
||||||
self.loadFileError.emit(msg)
|
|
||||||
return
|
|
||||||
records = read_data_records(file_path)
|
|
||||||
sensors = data.sensors
|
|
||||||
frames = frames_from_wafer_data(data, records)
|
|
||||||
|
|
||||||
if not sensors:
|
if not sensors or not frames:
|
||||||
msg = f"No sensor layout found in {Path(file_path).name}"
|
self.loadFileError.emit("No sensors or frames found in data file.")
|
||||||
log.warning("%s", msg)
|
return
|
||||||
self.loadFileError.emit(msg)
|
except Exception as exc:
|
||||||
return
|
log.warning("Could not parse file %s: %s", file_path, exc)
|
||||||
if not frames:
|
self.loadFileError.emit(f"Load error: {exc}")
|
||||||
# A well-formed header with zero data rows below it — most commonly
|
|
||||||
# a live recording that was stopped before any frames arrived.
|
|
||||||
msg = f"{Path(file_path).name} has no recorded frames"
|
|
||||||
log.warning("%s", msg)
|
|
||||||
self.loadFileError.emit(msg)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
wafer_id = ""
|
wafer_id = ""
|
||||||
@@ -309,26 +321,17 @@ class SessionController(QObject):
|
|||||||
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
|
||||||
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
|
||||||
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
self._player.load(frames)
|
self._player.load(frames)
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._stats_tracker.reset()
|
|
||||||
self._loaded_file = file_path
|
self._loaded_file = file_path
|
||||||
# A file was explicitly picked for review — show it regardless of
|
|
||||||
# whichever mode (e.g. Live) was previously active, otherwise a
|
|
||||||
# successful load can be invisible to the user.
|
|
||||||
self.setMode(MODE_REVIEW)
|
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
# Populate stats tracker from all loaded frames for trend chart (P3.1)
|
|
||||||
for frame in self._player._frames:
|
|
||||||
self._stats_tracker.track(frame.seq, frame.values)
|
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
|
||||||
self.settingsChanged.emit()
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -336,57 +339,29 @@ class SessionController(QObject):
|
|||||||
"""Clear the loaded file, resetting the player and frame states."""
|
"""Clear the loaded file, resetting the player and frame states."""
|
||||||
self._player.load([])
|
self._player.load([])
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._stats_tracker.reset()
|
self._ec_pairs = ()
|
||||||
self._loaded_file = ""
|
self._loaded_file = ""
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
self.settingsChanged.emit()
|
|
||||||
|
|
||||||
# ---- comparison: DTW between two CSV files ----
|
# ---- comparison: DTW between two CSV files ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def compareFiles(self, file_a: str, file_b: str) -> None:
|
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||||
"""Run DTW comparison between two CSV files and emit result."""
|
"""Run DTW comparison between two CSV files and emit result."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pygui.backend.comparison import compare_runs
|
from pygui.backend.comparison import compare_runs
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
|
||||||
|
if Path(file_a).resolve() == Path(file_b).resolve():
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "Cannot compare a file to itself. Choose two different runs."
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
|
||||||
# startRecording (see FileBrowser.isRecording), distinct from the
|
|
||||||
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
|
|
||||||
# Comparing across those categories doesn't make sense (a live
|
|
||||||
# capture vs. a full memory readout aren't the same kind of run),
|
|
||||||
# so gate on that before even looking at wafer type.
|
|
||||||
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
|
|
||||||
is_recording_a = stem_a.lower().startswith("live_")
|
|
||||||
is_recording_b = stem_b.lower().startswith("live_")
|
|
||||||
if is_recording_a != is_recording_b:
|
|
||||||
self.comparisonResult.emit({
|
|
||||||
"success": False,
|
|
||||||
"error": "Cannot compare a recording file with a read-memory file"
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
|
|
||||||
# (different sensor counts/layouts) would silently truncate the
|
|
||||||
# smaller sensor set and pair up unrelated physical sensors in the
|
|
||||||
# overlap view. Same convention as FileBrowser's waferType badge —
|
|
||||||
# first character of the filename stem, minus the "live_" prefix
|
|
||||||
# so the wafer letter is read from the serial, not the "L".
|
|
||||||
def _wafer_type(stem: str, is_recording: bool) -> str:
|
|
||||||
if is_recording:
|
|
||||||
stem = stem[len("live_"):]
|
|
||||||
return stem[0].upper() if stem else ""
|
|
||||||
|
|
||||||
type_a = _wafer_type(stem_a, is_recording_a)
|
|
||||||
type_b = _wafer_type(stem_b, is_recording_b)
|
|
||||||
if type_a and type_b and type_a != type_b:
|
|
||||||
self.comparisonResult.emit({
|
|
||||||
"success": False,
|
|
||||||
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_official_csv(file_a):
|
if is_official_csv(file_a):
|
||||||
_, recs_a = read_official_csv(file_a)
|
_, recs_a = read_official_csv(file_a)
|
||||||
else:
|
else:
|
||||||
@@ -403,30 +378,36 @@ class SessionController(QObject):
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Compare the full overlapping length of both runs. DTW's O(n*m) cost
|
self._compare_recs_a = recs_a
|
||||||
# matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
|
self._compare_recs_b = recs_b
|
||||||
# so only cap against pathologically long files, not typical runs.
|
|
||||||
max_frames = min(5000, len(recs_a), len(recs_b))
|
|
||||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
|
|
||||||
for i in range(max_frames)]
|
|
||||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
|
|
||||||
for i in range(max_frames)]
|
|
||||||
|
|
||||||
result = compare_runs(series_a, series_b)
|
len_a, len_b = len(recs_a), len(recs_b)
|
||||||
|
# DTW can only align frames both runs actually have; the longer
|
||||||
|
# run's extra tail is still returned for display, just unaligned.
|
||||||
|
overlap_frames = min(len_a, len_b)
|
||||||
|
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
|
||||||
|
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
|
||||||
|
time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
|
||||||
|
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
|
||||||
|
|
||||||
|
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
|
||||||
|
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
|
||||||
|
|
||||||
# Mean temporal shift along the DTW path: positive means run B
|
# Mean temporal shift along the DTW path: positive means run B
|
||||||
# reaches the same profile features later than run A.
|
# reaches the same profile features later than run A.
|
||||||
path = result["warping_path"]
|
path = result["warping_path"]
|
||||||
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
||||||
|
frame_offset_seconds = round(
|
||||||
|
sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2
|
||||||
|
) if path else 0.0
|
||||||
|
|
||||||
# Max sensor deviation: walk the DTW-aligned frame pairs and take
|
# Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
|
||||||
# the largest per-sensor abs difference across all sensors, not
|
# the overlap both runs share) and take the largest per-sensor abs
|
||||||
# just the sensor[0] series used for alignment.
|
# difference across all sensors, not just the sensor[0] series used
|
||||||
|
# for alignment.
|
||||||
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
|
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
|
||||||
max_sensor_deviation = 0.0
|
max_sensor_deviation = 0.0
|
||||||
for i, j in zip(result["index_a"], result["index_b"]):
|
for i, j in zip(result["index_a"], result["index_b"]):
|
||||||
if i >= max_frames or j >= max_frames:
|
|
||||||
continue
|
|
||||||
values_a = recs_a[i].values
|
values_a = recs_a[i].values
|
||||||
values_b = recs_b[j].values
|
values_b = recs_b[j].values
|
||||||
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
||||||
@@ -435,9 +416,8 @@ class SessionController(QObject):
|
|||||||
max_sensor_deviation = diff
|
max_sensor_deviation = diff
|
||||||
|
|
||||||
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||||
# same sensor-parsing branch as loadFile(); file_a and file_b are now
|
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
||||||
# guaranteed to share a wafer type by the gate above.
|
# of both files' wafer type).
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_official_csv
|
|
||||||
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
@@ -457,8 +437,8 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
if sensors:
|
if sensors:
|
||||||
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
last_a = recs_a[max_frames - 1].values
|
last_a = recs_a[overlap_frames - 1].values
|
||||||
last_b = recs_b[max_frames - 1].values
|
last_b = recs_b[overlap_frames - 1].values
|
||||||
n = min(len(sensors), len(last_a), len(last_b))
|
n = min(len(sensors), len(last_a), len(last_b))
|
||||||
sensor_layout = [
|
sensor_layout = [
|
||||||
{
|
{
|
||||||
@@ -481,9 +461,14 @@ class SessionController(QObject):
|
|||||||
# Lists, not tuples — tuples don't survive QVariant conversion
|
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||||
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||||
"frame_offset": frame_offset,
|
"frame_offset": frame_offset,
|
||||||
|
"frame_offset_seconds": frame_offset_seconds,
|
||||||
"max_sensor_deviation": max_sensor_deviation,
|
"max_sensor_deviation": max_sensor_deviation,
|
||||||
"series_a": series_a,
|
"series_a": series_a,
|
||||||
"series_b": series_b,
|
"series_b": series_b,
|
||||||
|
"time_a": time_a,
|
||||||
|
"time_b": time_b,
|
||||||
|
"frame_count_a": len_a,
|
||||||
|
"frame_count_b": len_b,
|
||||||
"sensor_layout": sensor_layout,
|
"sensor_layout": sensor_layout,
|
||||||
"sensor_diff": sensor_diff,
|
"sensor_diff": sensor_diff,
|
||||||
"wafer_shape": wafer_shape,
|
"wafer_shape": wafer_shape,
|
||||||
@@ -496,6 +481,25 @@ class SessionController(QObject):
|
|||||||
"error": str(exc)
|
"error": str(exc)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@Slot(int, result="QVariantList")
|
||||||
|
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
|
||||||
|
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
|
||||||
|
if not self._compare_recs_a or not self._compare_recs_b:
|
||||||
|
return []
|
||||||
|
|
||||||
|
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
|
||||||
|
if idx_a not in self._compare_alignment_map:
|
||||||
|
# DTW path only covers indices up to the shorter run's length —
|
||||||
|
# past that, Run B has no data at this frame, not a clampable one.
|
||||||
|
return []
|
||||||
|
idx_b = self._compare_alignment_map[idx_a]
|
||||||
|
|
||||||
|
values_a = self._compare_recs_a[idx_a].values
|
||||||
|
values_b = self._compare_recs_b[idx_b].values
|
||||||
|
|
||||||
|
n = min(len(values_a), len(values_b))
|
||||||
|
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
|
||||||
|
|
||||||
# ---- splitting: threshold-based segmentation ----
|
# ---- splitting: threshold-based segmentation ----
|
||||||
@Slot(str, float)
|
@Slot(str, float)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -520,6 +524,10 @@ class SessionController(QObject):
|
|||||||
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
||||||
segments = segment_profile(avg_temps, threshold)
|
segments = segment_profile(avg_temps, threshold)
|
||||||
|
|
||||||
|
# cache for exportSegment (per-segment Export in SplitDialog)
|
||||||
|
self._last_split_file = file_path
|
||||||
|
self._last_split_segments = segments
|
||||||
|
|
||||||
self.splitResult.emit({
|
self.splitResult.emit({
|
||||||
"success": True,
|
"success": True,
|
||||||
"segments": [{
|
"segments": [{
|
||||||
@@ -536,6 +544,48 @@ class SessionController(QObject):
|
|||||||
"error": str(exc)
|
"error": str(exc)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def exportSegment(self, index: int) -> None:
|
||||||
|
"""Write one segment from the last split as a standalone CSV.
|
||||||
|
|
||||||
|
Output lands next to the source file as
|
||||||
|
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
|
||||||
|
so read_official_csv still resolves the layout on re-import.
|
||||||
|
Emits segmentExported with success + path (or error).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
|
|
||||||
|
source = getattr(self, "_last_split_file", "")
|
||||||
|
segments = getattr(self, "_last_split_segments", [])
|
||||||
|
if not source or not (0 <= index < len(segments)):
|
||||||
|
self.segmentExported.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No split result to export"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
seg = segments[index]
|
||||||
|
src = Path(source)
|
||||||
|
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
|
||||||
|
try:
|
||||||
|
ok = CsvRecorder.write_segment(
|
||||||
|
str(dest), str(src), seg.start_frame, seg.end_frame)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Segment export failed: %s", exc)
|
||||||
|
self.segmentExported.emit({"success": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
self.segmentExported.emit({"success": True, "path": str(dest)})
|
||||||
|
else:
|
||||||
|
self.segmentExported.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No frames in range"
|
||||||
|
})
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def play(self) -> None:
|
def play(self) -> None:
|
||||||
@@ -639,6 +689,7 @@ class SessionController(QObject):
|
|||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Could not load layout for %s: %s", family_code, e)
|
log.warning("Could not load layout for %s: %s", family_code, e)
|
||||||
|
self._ec_pairs = ec_pairs_for_wafer_id(family_code)
|
||||||
|
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
@@ -669,10 +720,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# Clear out any old data from the prev sessions
|
# Clear out any old data from the prev sessions
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._trend_buffer.clear()
|
self._reset_live_trend()
|
||||||
self._trend_timestamps.clear()
|
|
||||||
self._trend_timer.start()
|
|
||||||
self._stats_tracker.reset()
|
|
||||||
self._last = None
|
self._last = None
|
||||||
self._last_raw_frame = None
|
self._last_raw_frame = None
|
||||||
self._received_count = 0
|
self._received_count = 0
|
||||||
@@ -714,7 +762,6 @@ class SessionController(QObject):
|
|||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
self._trend_timer.stop()
|
|
||||||
self._received_count = 0
|
self._received_count = 0
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
@@ -740,10 +787,8 @@ class SessionController(QObject):
|
|||||||
@Slot(object)
|
@Slot(object)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def _on_live_frame(self, frame: Frame) -> None:
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
import json
|
|
||||||
self._received_count += 1
|
self._received_count += 1
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
self._stats_tracker.track(frame.seq, frame.values)
|
|
||||||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
self._last_raw_frame = frame
|
self._last_raw_frame = frame
|
||||||
edited = Frame(seq=frame.seq, time=frame.time,
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
@@ -752,22 +797,11 @@ class SessionController(QObject):
|
|||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
self._recorder.write(frame) # always record raw values, never edited
|
self._recorder.write(frame) # always record raw values, never edited
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
|
||||||
|
|
||||||
if self._last and self._last.stats:
|
if self._last and self._last.stats:
|
||||||
avg = self._last.stats.avg
|
avg = self._last.stats.avg
|
||||||
now = time.monotonic()
|
elapsed = time.monotonic() - self._stream_start_time
|
||||||
self._trend_buffer.append(avg)
|
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
|
||||||
self._trend_timestamps.append(now)
|
|
||||||
|
|
||||||
# drop entries older than 60 secs
|
|
||||||
cutoff = now -60
|
|
||||||
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
|
|
||||||
self._trend_timestamps.pop(0)
|
|
||||||
self._trend_buffer.pop(0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_repaint(self) -> None:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
@@ -823,6 +857,8 @@ class SessionController(QObject):
|
|||||||
self._sensor_editor.clear()
|
self._sensor_editor.clear()
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
def _flush_trend(self) -> None:
|
def _reset_live_trend(self) -> None:
|
||||||
import json
|
"""Mark a fresh stream start for elapsed-time trend deltas."""
|
||||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
self._stream_start_time = time.monotonic()
|
||||||
|
self.trendReset.emit()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,15 +15,13 @@ class CsvRecorder:
|
|||||||
self._file_handle: Optional[TextIO] = None
|
self._file_handle: Optional[TextIO] = None
|
||||||
self._path: 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]
|
||||||
@@ -47,9 +46,15 @@ class CsvRecorder:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
|
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
|
||||||
"""Copy a range of frames from source CSV into a new file.
|
"""Copy a range of frames from source CSV into a new file.
|
||||||
|
|
||||||
|
Preserves the source's header structure so the segment re-imports
|
||||||
|
through FileBrowser unchanged: official CSVs keep their sensor-name
|
||||||
|
row, Z-wafer CSVs keep every metadata line through the "data"
|
||||||
|
sentinel. Frame indices count only valid data rows, matching how
|
||||||
|
the readers in data_records.py number frames.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Output CSV path.
|
file_path: Output CSV path.
|
||||||
source_path: Source CSV path with full wafer data
|
source_path: Source CSV path with full wafer data
|
||||||
@@ -57,11 +62,42 @@ class CsvRecorder:
|
|||||||
end_frame: Last frame index (inclusive)
|
end_frame: Last frame index (inclusive)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success.
|
True on success (at least one frame written).
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
from pygui.backend.data.data_records import is_data_row, is_official_csv
|
||||||
df = pd.read_csv(source_path)
|
|
||||||
segment = df.iloc[start_frame:end_frame + 1]
|
|
||||||
segment.to_csv(file_path, index=False)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
if start_frame < 0 or end_frame < start_frame:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(source_path, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
written = 0
|
||||||
|
frame_idx = -1
|
||||||
|
in_data = is_official_csv(source_path)
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.rstrip().rstrip(",").strip()
|
||||||
|
if not in_data:
|
||||||
|
out.append(line)
|
||||||
|
if stripped and stripped.split(",")[0].lower() == "data":
|
||||||
|
in_data = True
|
||||||
|
continue
|
||||||
|
if i == 0:
|
||||||
|
# official format: row 0 is the sensor-name header
|
||||||
|
out.append(line)
|
||||||
|
continue
|
||||||
|
if not stripped or not is_data_row(stripped):
|
||||||
|
continue
|
||||||
|
frame_idx += 1
|
||||||
|
if start_frame <= frame_idx <= end_frame:
|
||||||
|
out.append(line if line.endswith("\n") else line + "\n")
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
if written == 0:
|
||||||
|
return False
|
||||||
|
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
||||||
|
f.writelines(out)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
@@ -37,12 +37,22 @@ class FileBrowser(QObject):
|
|||||||
return str(self._current_directory)
|
return str(self._current_directory)
|
||||||
|
|
||||||
# ===== Directory Selection =====
|
# ===== Directory Selection =====
|
||||||
|
@Slot(str)
|
||||||
|
def setCurrentDirectory(self, path: str) -> None:
|
||||||
|
"""Point the browser at `path` without opening a native dialog.
|
||||||
|
|
||||||
|
Used to keep this directory in sync with DeviceController.saveDataDir
|
||||||
|
when the user picks a save folder elsewhere in the app.
|
||||||
|
"""
|
||||||
|
self._set_current_directory(Path(path))
|
||||||
|
self._refresh_files(show_empty_message=False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def chooseDirectory(self) -> None:
|
def chooseDirectory(self) -> None:
|
||||||
selected_dir = QFileDialog.getExistingDirectory(
|
selected_dir = QFileDialog.getExistingDirectory(
|
||||||
None,
|
None,
|
||||||
"Select CSV Folder",
|
"Select CSV Folder",
|
||||||
self.currentDirectory,
|
str(self._current_directory),
|
||||||
)
|
)
|
||||||
if not selected_dir:
|
if not selected_dir:
|
||||||
return
|
return
|
||||||
@@ -71,17 +81,16 @@ class FileBrowser(QObject):
|
|||||||
self._show_error(f"CSV file was not found:\n{file_path}")
|
self._show_error(f"CSV file was not found:\n{file_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ponytail: row is absent when saving metadata for a CSV just read from
|
||||||
|
# the wafer (not in the browser listing) — still write the sidecar.
|
||||||
row = self._find_row(file_path)
|
row = self._find_row(file_path)
|
||||||
if row is None:
|
if row is not None:
|
||||||
self._show_error("The selected CSV row is no longer available.")
|
row["wafer"] = wafer
|
||||||
return
|
row["date"] = date
|
||||||
|
row["chamber"] = chamber
|
||||||
row["wafer"] = wafer
|
row["notes"] = notes
|
||||||
row["date"] = date
|
row["selected"] = selected
|
||||||
row["chamber"] = chamber
|
row["masterType"] = master_type
|
||||||
row["notes"] = notes
|
|
||||||
row["selected"] = selected
|
|
||||||
row["masterType"] = master_type
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"wafer": wafer,
|
"wafer": wafer,
|
||||||
@@ -236,11 +245,7 @@ class FileBrowser(QObject):
|
|||||||
|
|
||||||
# ===== Internal Helpers =====
|
# ===== Internal Helpers =====
|
||||||
def _resolve_default_directory(self) -> Path:
|
def _resolve_default_directory(self) -> Path:
|
||||||
documents_dir = QStandardPaths.writableLocation(
|
return default_data_dir()
|
||||||
QStandardPaths.DocumentsLocation
|
|
||||||
)
|
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
|
||||||
return base_dir / DEFAULT_DATA_DIR_NAME
|
|
||||||
|
|
||||||
def _set_current_directory(self, directory: Path) -> None:
|
def _set_current_directory(self, directory: Path) -> None:
|
||||||
normalized = Path(directory)
|
normalized = Path(directory)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""C#-compatible wafer license file parsing.
|
||||||
|
|
||||||
|
License files are AES-128-CBC encrypted `.bin` blobs named
|
||||||
|
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
|
||||||
|
5-digit serial number, and ``00`` the 2-digit license level. The blob is
|
||||||
|
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
|
||||||
|
a fixed-offset record::
|
||||||
|
|
||||||
|
[0:6] serial e.g. "A00001"
|
||||||
|
[7:15] mfg date 8 hex chars
|
||||||
|
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
|
||||||
|
[19:27] license date 8 hex chars
|
||||||
|
|
||||||
|
The key is hardcoded to match the C# application exactly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.crypto.crypto_helper import CryptoHelper
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LICENSE_KEY = b"ThisIsA16ByteKey"
|
||||||
|
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
|
||||||
|
|
||||||
|
_MIN_TEXT_LEN = 27
|
||||||
|
|
||||||
|
|
||||||
|
# ===== License Record =====
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class License:
|
||||||
|
"""One parsed license file (all fields as stored in the file)."""
|
||||||
|
|
||||||
|
serial: str # "A00001"
|
||||||
|
level: str # "00".."03"
|
||||||
|
mfg_date_hex: str # 8 hex chars
|
||||||
|
lic_date_hex: str # 8 hex chars
|
||||||
|
|
||||||
|
@property
|
||||||
|
def level_int(self) -> int:
|
||||||
|
"""Level as int; -1 if unparseable."""
|
||||||
|
try:
|
||||||
|
return int(self.level, 10)
|
||||||
|
except ValueError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mfg_date_decimal(self) -> str:
|
||||||
|
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
|
||||||
|
return _hex_to_decimal(self.mfg_date_hex)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lic_date_decimal(self) -> str:
|
||||||
|
"""License date shown as decimal, same conversion as the C# grid."""
|
||||||
|
return _hex_to_decimal(self.lic_date_hex)
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_decimal(hex_str: str) -> str:
|
||||||
|
try:
|
||||||
|
return str(int(hex_str, 16))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Blob Parsing =====
|
||||||
|
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
|
||||||
|
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
|
||||||
|
if len(blob) < 32:
|
||||||
|
raise ValueError("License blob too short")
|
||||||
|
return blob[:-16], blob[-16:]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_license_blob(blob: bytes) -> License | None:
|
||||||
|
"""Decrypt and parse one license blob. None if malformed."""
|
||||||
|
try:
|
||||||
|
data, iv = split_data_iv(blob)
|
||||||
|
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
|
||||||
|
text = message.decode("utf-8")
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
log.warning("Undecryptable license blob: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(text) < _MIN_TEXT_LEN:
|
||||||
|
log.warning("License text too short (%d chars)", len(text))
|
||||||
|
return None
|
||||||
|
|
||||||
|
return License(
|
||||||
|
serial=text[0:6],
|
||||||
|
mfg_date_hex=text[7:15],
|
||||||
|
level=text[16:18],
|
||||||
|
lic_date_hex=text[19:27],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Directory Scan =====
|
||||||
|
def read_license_files(licenses_dir: str | Path) -> list[License]:
|
||||||
|
"""Parse every valid license .bin in the directory.
|
||||||
|
|
||||||
|
A file counts only if its name matches ``X00000-00.bin`` AND equals
|
||||||
|
``{serial}-{level}.bin`` from its own decrypted payload — same
|
||||||
|
tamper check as the C# app.
|
||||||
|
"""
|
||||||
|
directory = Path(licenses_dir)
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
licenses: list[License] = []
|
||||||
|
for path in sorted(directory.glob("*.bin")):
|
||||||
|
if not LICENSE_FILENAME_RE.match(path.name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blob = path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot read license file %s: %s", path, exc)
|
||||||
|
continue
|
||||||
|
lic = parse_license_blob(blob)
|
||||||
|
if lic is None:
|
||||||
|
continue
|
||||||
|
if path.name != f"{lic.serial}-{lic.level}.bin":
|
||||||
|
log.warning("License filename mismatch: %s", path.name)
|
||||||
|
continue
|
||||||
|
licenses.append(lic)
|
||||||
|
return licenses
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""QML-facing license model: grid rows, Load License, replay trial marker."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||||
|
|
||||||
|
from pygui.backend.license.license_manager import (
|
||||||
|
LICENSE_FILENAME_RE,
|
||||||
|
License,
|
||||||
|
parse_license_blob,
|
||||||
|
read_license_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TRIAL_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _to_local_path(path_or_url: str) -> Path:
|
||||||
|
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
|
||||||
|
if path_or_url.startswith("file:"):
|
||||||
|
return Path(unquote(urlparse(path_or_url).path))
|
||||||
|
return Path(path_or_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== License Model =====
|
||||||
|
class LicenseModel(QObject):
|
||||||
|
"""Context property backing the About dialog license grid.
|
||||||
|
|
||||||
|
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
|
||||||
|
``DataDir\\licenses``). The 30-day replay trial marker is
|
||||||
|
``<data_dir>/replay.lic``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
licensesChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._data_dir = Path(data_dir)
|
||||||
|
self._licenses_dir = self._data_dir / "licenses"
|
||||||
|
try:
|
||||||
|
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
|
||||||
|
self._licenses: list[License] = []
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# ── Grid rows ─────────────────────────────────────────────────────
|
||||||
|
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
|
||||||
|
def licenses(self) -> list:
|
||||||
|
"""Rows for the About grid: serial, mfg date, level, license date."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"serial": lic.serial,
|
||||||
|
"mfgDate": lic.mfg_date_decimal,
|
||||||
|
"level": lic.level,
|
||||||
|
"licDate": lic.lic_date_decimal,
|
||||||
|
}
|
||||||
|
for lic in self._licenses
|
||||||
|
]
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""Re-scan the licenses directory."""
|
||||||
|
self._licenses = read_license_files(self._licenses_dir)
|
||||||
|
self.licensesChanged.emit()
|
||||||
|
|
||||||
|
# ── Load License ──────────────────────────────────────────────────
|
||||||
|
@Slot(str, result=bool)
|
||||||
|
def loadLicenseFile(self, path_or_url: str) -> bool:
|
||||||
|
"""Validate a picked .bin and copy it into the licenses directory."""
|
||||||
|
src = _to_local_path(path_or_url)
|
||||||
|
if not src.is_file():
|
||||||
|
log.warning("License file does not exist: %s", src)
|
||||||
|
return False
|
||||||
|
if not LICENSE_FILENAME_RE.match(src.name):
|
||||||
|
log.warning("License filename invalid: %s", src.name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob = src.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot read license file %s: %s", src, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
lic = parse_license_blob(blob)
|
||||||
|
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
|
||||||
|
log.warning("License file failed validation: %s", src.name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(src, self._licenses_dir / src.name)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Wafer detect (informational) ──────────────────────────────────
|
||||||
|
@Slot(str, result=str)
|
||||||
|
def levelForWafer(self, serial: str) -> str:
|
||||||
|
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
|
||||||
|
for lic in self._licenses:
|
||||||
|
if lic.serial == serial:
|
||||||
|
return lic.level
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ── Replay trial (30-day marker, C# parity) ───────────────────────
|
||||||
|
@property
|
||||||
|
def _trial_marker(self) -> Path:
|
||||||
|
return self._data_dir / "replay.lic"
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
def replayLicenseState(self) -> str:
|
||||||
|
""""permanent" if any license level >= 1, "temporary" if trial
|
||||||
|
marker exists, else "none" (trial not started)."""
|
||||||
|
if any(lic.level_int >= 1 for lic in self._licenses):
|
||||||
|
return "permanent"
|
||||||
|
if self._trial_marker.exists():
|
||||||
|
return "temporary"
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
@Slot(result=bool)
|
||||||
|
def startReplayTrial(self) -> bool:
|
||||||
|
"""Create the trial marker (idempotent). False on I/O failure."""
|
||||||
|
try:
|
||||||
|
self._trial_marker.touch(exist_ok=True)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot create trial marker: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@Slot(result=bool)
|
||||||
|
def replayTrialExpired(self) -> bool:
|
||||||
|
"""True once the marker is older than 30 days (or missing)."""
|
||||||
|
marker = self._trial_marker
|
||||||
|
if not marker.exists():
|
||||||
|
return True
|
||||||
|
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
|
||||||
|
return age_days > TRIAL_DAYS
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# ===== Models Sub-package =====
|
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
|
||||||
from pygui.backend.models.frame import Frame
|
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
|
||||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
|
||||||
from pygui.backend.models.stability_detector import StabilityDetector
|
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Frame", "FramePlayer", "frames_from_wafer_data",
|
|
||||||
"Stats", "compute_stats",
|
|
||||||
"SessionModel", "SessionUpdate",
|
|
||||||
"TemperatureTableModel",
|
|
||||||
"SensorEditor",
|
|
||||||
"ThresholdConfig", "classify", "resolve_bounds",
|
|
||||||
"StabilityDetector",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
|||||||
# Column 0 = row index, columns 1..N = sensor values
|
# 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,10 +4,23 @@ from pygui.backend.models.frame import Frame
|
|||||||
from pygui.backend.wafer.zwafer_models import ZWaferData
|
from pygui.backend.wafer.zwafer_models import ZWaferData
|
||||||
|
|
||||||
|
|
||||||
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
|
def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
|
||||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
||||||
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
||||||
|
|
||||||
|
|
||||||
|
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
|
||||||
|
"""Per-sensor value series across every frame, for the whole-run Graph tab.
|
||||||
|
|
||||||
|
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
|
||||||
|
"""
|
||||||
|
if not frames:
|
||||||
|
return [], []
|
||||||
|
n_sensors = len(frames[0].values)
|
||||||
|
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
|
||||||
|
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
|
||||||
|
return names, series
|
||||||
|
|
||||||
class FramePlayer:
|
class FramePlayer:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._frames: list[Frame] =[]
|
self._frames: list[Frame] =[]
|
||||||
@@ -22,6 +35,10 @@ class FramePlayer:
|
|||||||
def total(self) -> int:
|
def total(self) -> int:
|
||||||
return len(self._frames)
|
return len(self._frames)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames(self) -> tuple[Frame, ...]:
|
||||||
|
return tuple(self._frames)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def index(self) -> int:
|
def index(self) -> int:
|
||||||
return self._i
|
return self._i
|
||||||
|
|||||||
@@ -14,6 +14,44 @@ class Stats:
|
|||||||
sigma: float; three_sigma: float
|
sigma: float; three_sigma: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EdgeCenterStats:
|
||||||
|
min_edge_index: int; min_center_index: int
|
||||||
|
min_edge: float; min_center: float; min_delta: float
|
||||||
|
max_edge_index: int; max_center_index: int
|
||||||
|
max_edge: float; max_center: float; max_delta: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_edge_center(
|
||||||
|
values: list[float],
|
||||||
|
pairs: tuple[tuple[int, int], ...],
|
||||||
|
excluded: set[int],
|
||||||
|
) -> EdgeCenterStats | None:
|
||||||
|
"""Min/max |edge - center| over the family's pair table.
|
||||||
|
|
||||||
|
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
|
||||||
|
NaN value are skipped. Returns None when no pair is computable.
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
best: list[tuple[float, int, int]] = []
|
||||||
|
for e, c in pairs:
|
||||||
|
if e in excluded or c in excluded or e >= n or c >= n:
|
||||||
|
continue
|
||||||
|
if math.isnan(values[e]) or math.isnan(values[c]):
|
||||||
|
continue
|
||||||
|
best.append((abs(values[e] - values[c]), e, c))
|
||||||
|
if not best:
|
||||||
|
return None
|
||||||
|
min_d, min_e, min_c = min(best)
|
||||||
|
max_d, max_e, max_c = max(best)
|
||||||
|
return EdgeCenterStats(
|
||||||
|
min_edge_index=min_e, min_center_index=min_c,
|
||||||
|
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
|
||||||
|
max_edge_index=max_e, max_center_index=max_c,
|
||||||
|
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def compute_stats(values: list[float]) -> Stats:
|
def compute_stats(values: list[float]) -> Stats:
|
||||||
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||||
if not clean:
|
if not clean:
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class SensorEditor:
|
|||||||
def has_overrides(self) -> bool:
|
def has_overrides(self) -> bool:
|
||||||
return bool(self._replacements or self._offsets)
|
return bool(self._replacements or self._offsets)
|
||||||
|
|
||||||
|
def replaced_indices(self) -> set[int]:
|
||||||
|
"""Sensors whose value is a replacement (offsets don't count — an
|
||||||
|
offset sensor still reads from its physical location)."""
|
||||||
|
return set(self._replacements)
|
||||||
|
|
||||||
def active_indices(self) -> list[int]:
|
def active_indices(self) -> list[int]:
|
||||||
"""Return sorted sensor indices that have any override."""
|
"""Return sorted sensor indices that have any override."""
|
||||||
return sorted(set(self._replacements) | set(self._offsets))
|
return sorted(set(self._replacements) | set(self._offsets))
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# ===== Visualization Sub-package =====
|
|
||||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
|
||||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
|
|
||||||
"interpolate_field",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Pure coordinate-mapping math shared by the QQuickPaintedItem chart items
|
||||||
|
(GraphQuickItem, TrendChartItem). No QPainter or Qt-widget dependency, so
|
||||||
|
it's testable without a GUI — unlike the paint() methods that call it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def value_to_pixel(
|
||||||
|
value: float,
|
||||||
|
value_min: float,
|
||||||
|
value_max: float,
|
||||||
|
pixel_start: float,
|
||||||
|
pixel_span: float,
|
||||||
|
) -> float:
|
||||||
|
"""Map a value onto a pixel range, inverted so larger values sit at
|
||||||
|
smaller pixel coordinates (screen y grows downward).
|
||||||
|
|
||||||
|
Also used for evenly-spaced tick indices: pass the tick index as
|
||||||
|
`value` and `(tick_count - 1)` as `value_max` with `value_min=0`.
|
||||||
|
"""
|
||||||
|
value_range = value_max - value_min
|
||||||
|
if value_range < 1e-9:
|
||||||
|
return pixel_start + pixel_span / 2
|
||||||
|
fraction = 1.0 - (value - value_min) / value_range
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
|
|
||||||
|
|
||||||
|
def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: float) -> float:
|
||||||
|
"""Map a 0-based sample index onto a pixel range, left to right."""
|
||||||
|
if count <= 1:
|
||||||
|
return pixel_start + pixel_span / 2
|
||||||
|
fraction = index / (count - 1)
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_to_pixel(
|
||||||
|
elapsed: float,
|
||||||
|
window_start: float,
|
||||||
|
window_end: float,
|
||||||
|
pixel_start: float,
|
||||||
|
pixel_span: float,
|
||||||
|
) -> float:
|
||||||
|
"""Map an elapsed-seconds value onto a pixel range, left to right.
|
||||||
|
|
||||||
|
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
|
||||||
|
at larger pixel x-coordinates, matching how time flows left-to-right.
|
||||||
|
"""
|
||||||
|
window_span = window_end - window_start
|
||||||
|
if window_span < 1e-9:
|
||||||
|
return pixel_start + pixel_span
|
||||||
|
fraction = (elapsed - window_start) / window_span
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
@@ -21,15 +21,60 @@ Usage from QML::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QRectF, Qt, Signal
|
import math
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def viewport_stats(
|
||||||
|
series: list[list[float]],
|
||||||
|
names: list[str],
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
) -> tuple[float, float, float, str, str]:
|
||||||
|
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
|
||||||
|
|
||||||
|
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
|
||||||
|
every visible point from every series, plus which sensor achieved each
|
||||||
|
extreme. Non-numeric values are skipped. No points in range -> zeros and
|
||||||
|
empty sensor names.
|
||||||
|
"""
|
||||||
|
vmin = math.inf
|
||||||
|
vmax = -math.inf
|
||||||
|
total = 0.0
|
||||||
|
count = 0
|
||||||
|
min_sensor = ""
|
||||||
|
max_sensor = ""
|
||||||
|
for i, s in enumerate(series):
|
||||||
|
name = names[i] if i < len(names) else ""
|
||||||
|
lo = max(0, start)
|
||||||
|
hi = min(len(s) - 1, end)
|
||||||
|
for j in range(lo, hi + 1):
|
||||||
|
try:
|
||||||
|
v = float(s[j])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if v < vmin:
|
||||||
|
vmin = v
|
||||||
|
min_sensor = name
|
||||||
|
if v > vmax:
|
||||||
|
vmax = v
|
||||||
|
max_sensor = name
|
||||||
|
total += v
|
||||||
|
count += 1
|
||||||
|
if count == 0:
|
||||||
|
return (0.0, 0.0, 0.0, "", "")
|
||||||
|
return (vmin, vmax, total / count, min_sensor, max_sensor)
|
||||||
|
|
||||||
|
|
||||||
@QmlElement
|
@QmlElement
|
||||||
class GraphQuickItem(QQuickPaintedItem):
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
"""Painted line chart; driven by series data passed via QML property bindings."""
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
@@ -40,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
xLabelChanged = Signal()
|
xLabelChanged = Signal()
|
||||||
yLabelChanged = Signal()
|
yLabelChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
|
viewportChanged = Signal()
|
||||||
|
viewStatsChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -72,16 +119,37 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._max_y: float = 150.0
|
self._max_y: float = 150.0
|
||||||
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
||||||
|
|
||||||
|
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
|
||||||
|
# so the Graph tab (which never sets these) is unaffected. See
|
||||||
|
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||||
|
self._view_start: int = 0
|
||||||
|
self._view_end: int = -1
|
||||||
|
self._show_min_max_markers: bool = False
|
||||||
|
self._view_min: float = 0.0
|
||||||
|
self._view_max: float = 0.0
|
||||||
|
self._view_avg: float = 0.0
|
||||||
|
self._view_min_sensor: str = ""
|
||||||
|
self._view_max_sensor: str = ""
|
||||||
|
|
||||||
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=seriesDataChanged)
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
def seriesData(self) -> list:
|
def seriesData(self) -> list:
|
||||||
return self._series_data
|
return self._series_data
|
||||||
|
|
||||||
@seriesData.setter
|
@seriesData.setter # type: ignore[no-redef]
|
||||||
def seriesData(self, val: list) -> None:
|
def seriesData(self, val: list) -> None:
|
||||||
self._series_data = [list(s) for s in (val or [])]
|
self._series_data = [list(s) for s in (val or [])]
|
||||||
|
# New data resets the viewport to the full range -- matches
|
||||||
|
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
|
||||||
|
# before RecalculateAxesScale(). Without this, a stale viewport left
|
||||||
|
# over from a previous (longer) series can point entirely past the
|
||||||
|
# new data -- every per-series slice becomes empty and the chart
|
||||||
|
# silently renders nothing instead of the new data.
|
||||||
|
self._view_start = 0
|
||||||
|
self._view_end = -1
|
||||||
self._auto_range()
|
self._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
self.seriesDataChanged.emit()
|
self.seriesDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -89,17 +157,138 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def sensorNames(self) -> list:
|
def sensorNames(self) -> list:
|
||||||
return self._sensor_names
|
return self._sensor_names
|
||||||
|
|
||||||
@sensorNames.setter
|
@sensorNames.setter # type: ignore[no-redef]
|
||||||
def sensorNames(self, val: list) -> None:
|
def sensorNames(self, val: list) -> None:
|
||||||
self._sensor_names = list(val or [])
|
self._sensor_names = list(val or [])
|
||||||
|
self._recompute_view_stats()
|
||||||
self.sensorNamesChanged.emit()
|
self.sensorNamesChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
|
||||||
|
|
||||||
|
@Property(int, notify=viewportChanged)
|
||||||
|
def viewStartIndex(self) -> int:
|
||||||
|
return self._view_start
|
||||||
|
|
||||||
|
@viewStartIndex.setter # type: ignore[no-redef]
|
||||||
|
def viewStartIndex(self, val: int) -> None:
|
||||||
|
v = max(0, int(val))
|
||||||
|
if v == self._view_start:
|
||||||
|
return
|
||||||
|
self._view_start = v
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(int, notify=viewportChanged)
|
||||||
|
def viewEndIndex(self) -> int:
|
||||||
|
return self._view_end
|
||||||
|
|
||||||
|
@viewEndIndex.setter # type: ignore[no-redef]
|
||||||
|
def viewEndIndex(self, val: int) -> None:
|
||||||
|
v = int(val)
|
||||||
|
if v == self._view_end:
|
||||||
|
return
|
||||||
|
self._view_end = v
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(bool, notify=viewportChanged)
|
||||||
|
def showMinMaxMarkers(self) -> bool:
|
||||||
|
return self._show_min_max_markers
|
||||||
|
|
||||||
|
@showMinMaxMarkers.setter # type: ignore[no-redef]
|
||||||
|
def showMinMaxMarkers(self, val: bool) -> None:
|
||||||
|
v = bool(val)
|
||||||
|
if v == self._show_min_max_markers:
|
||||||
|
return
|
||||||
|
self._show_min_max_markers = v
|
||||||
|
self.viewportChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewMin(self) -> float:
|
||||||
|
return self._view_min
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewMax(self) -> float:
|
||||||
|
return self._view_max
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewAvg(self) -> float:
|
||||||
|
return self._view_avg
|
||||||
|
|
||||||
|
@Property(str, notify=viewStatsChanged)
|
||||||
|
def viewMinSensor(self) -> str:
|
||||||
|
return self._view_min_sensor
|
||||||
|
|
||||||
|
@Property(str, notify=viewStatsChanged)
|
||||||
|
def viewMaxSensor(self) -> str:
|
||||||
|
return self._view_max_sensor
|
||||||
|
|
||||||
|
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
|
||||||
|
# with fractions derived from wheel/drag pixel deltas) ───────────────
|
||||||
|
|
||||||
|
@Slot(float, float)
|
||||||
|
def zoomAtFraction(self, frac: float, factor: float) -> None:
|
||||||
|
"""Zoom the viewport around a fractional X position within it.
|
||||||
|
|
||||||
|
`frac` in [0, 1] is where within the current viewport to zoom around;
|
||||||
|
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
|
||||||
|
(zoom out). Clamped to data bounds and a minimum 1-index window.
|
||||||
|
"""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if max_len < 2:
|
||||||
|
return
|
||||||
|
start = float(self._view_start)
|
||||||
|
end = float(self._resolved_view_end())
|
||||||
|
width = max(1.0, end - start)
|
||||||
|
f = max(0.0, min(1.0, frac))
|
||||||
|
center = start + f * width
|
||||||
|
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
|
||||||
|
new_start = center - f * new_width
|
||||||
|
new_end = new_start + new_width
|
||||||
|
self._set_viewport_clamped(new_start, new_end, max_len)
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
def panByFraction(self, delta_frac: float) -> None:
|
||||||
|
"""Shift the viewport by `delta_frac * window_width` indices."""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if max_len < 2:
|
||||||
|
return
|
||||||
|
start = float(self._view_start)
|
||||||
|
end = float(self._resolved_view_end())
|
||||||
|
width = max(1.0, end - start)
|
||||||
|
shift = delta_frac * width
|
||||||
|
self._set_viewport_clamped(start + shift, end + shift, max_len)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def resetZoom(self) -> None:
|
||||||
|
"""Restore the full-range viewport sentinel (0, -1)."""
|
||||||
|
self._set_viewport(0, -1)
|
||||||
|
|
||||||
|
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
|
||||||
|
if new_start < 0.0:
|
||||||
|
new_end -= new_start
|
||||||
|
new_start = 0.0
|
||||||
|
if new_end > max_len - 1:
|
||||||
|
new_start -= new_end - (max_len - 1)
|
||||||
|
new_end = float(max_len - 1)
|
||||||
|
new_start = max(0.0, new_start)
|
||||||
|
self._set_viewport(int(round(new_start)), int(round(new_end)))
|
||||||
|
|
||||||
|
def _set_viewport(self, start: int, end: int) -> None:
|
||||||
|
"""Set the viewport bounds directly (not through the QML-facing
|
||||||
|
property setters, so this stays a plain attribute write for mypy)."""
|
||||||
|
new_start = max(0, start)
|
||||||
|
if new_start == self._view_start and end == self._view_end:
|
||||||
|
return
|
||||||
|
self._view_start = new_start
|
||||||
|
self._view_end = end
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
@Property(str, notify=titleChanged)
|
@Property(str, notify=titleChanged)
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
return self._title
|
return self._title
|
||||||
|
|
||||||
@title.setter
|
@title.setter # type: ignore[no-redef]
|
||||||
def title(self, val: str) -> None:
|
def title(self, val: str) -> None:
|
||||||
self._title = str(val or "")
|
self._title = str(val or "")
|
||||||
self.titleChanged.emit()
|
self.titleChanged.emit()
|
||||||
@@ -109,7 +298,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def xLabel(self) -> str:
|
def xLabel(self) -> str:
|
||||||
return self._x_label
|
return self._x_label
|
||||||
|
|
||||||
@xLabel.setter
|
@xLabel.setter # type: ignore[no-redef]
|
||||||
def xLabel(self, val: str) -> None:
|
def xLabel(self, val: str) -> None:
|
||||||
self._x_label = str(val or "")
|
self._x_label = str(val or "")
|
||||||
self.xLabelChanged.emit()
|
self.xLabelChanged.emit()
|
||||||
@@ -119,7 +308,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def yLabel(self) -> str:
|
def yLabel(self) -> str:
|
||||||
return self._y_label
|
return self._y_label
|
||||||
|
|
||||||
@yLabel.setter
|
@yLabel.setter # type: ignore[no-redef]
|
||||||
def yLabel(self, val: str) -> None:
|
def yLabel(self, val: str) -> None:
|
||||||
self._y_label = str(val or "")
|
self._y_label = str(val or "")
|
||||||
self.yLabelChanged.emit()
|
self.yLabelChanged.emit()
|
||||||
@@ -129,7 +318,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def showLegend(self) -> bool:
|
def showLegend(self) -> bool:
|
||||||
return self._show_legend
|
return self._show_legend
|
||||||
|
|
||||||
@showLegend.setter
|
@showLegend.setter # type: ignore[no-redef]
|
||||||
def showLegend(self, val: bool) -> None:
|
def showLegend(self, val: bool) -> None:
|
||||||
self._show_legend = bool(val)
|
self._show_legend = bool(val)
|
||||||
self.colorsChanged.emit()
|
self.colorsChanged.emit()
|
||||||
@@ -141,7 +330,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def backgroundColor(self) -> QColor:
|
def backgroundColor(self) -> QColor:
|
||||||
return self._bg_color
|
return self._bg_color
|
||||||
|
|
||||||
@backgroundColor.setter
|
@backgroundColor.setter # type: ignore[no-redef]
|
||||||
def backgroundColor(self, c: QColor) -> None:
|
def backgroundColor(self, c: QColor) -> None:
|
||||||
self._bg_color = c
|
self._bg_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -150,7 +339,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def gridColor(self) -> QColor:
|
def gridColor(self) -> QColor:
|
||||||
return self._grid_color
|
return self._grid_color
|
||||||
|
|
||||||
@gridColor.setter
|
@gridColor.setter # type: ignore[no-redef]
|
||||||
def gridColor(self, c: QColor) -> None:
|
def gridColor(self, c: QColor) -> None:
|
||||||
self._grid_color = c
|
self._grid_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -159,7 +348,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def axisColor(self) -> QColor:
|
def axisColor(self) -> QColor:
|
||||||
return self._axis_color
|
return self._axis_color
|
||||||
|
|
||||||
@axisColor.setter
|
@axisColor.setter # type: ignore[no-redef]
|
||||||
def axisColor(self, c: QColor) -> None:
|
def axisColor(self, c: QColor) -> None:
|
||||||
self._axis_color = c
|
self._axis_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -168,7 +357,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def textColor(self) -> QColor:
|
def textColor(self) -> QColor:
|
||||||
return self._text_color
|
return self._text_color
|
||||||
|
|
||||||
@textColor.setter
|
@textColor.setter # type: ignore[no-redef]
|
||||||
def textColor(self, c: QColor) -> None:
|
def textColor(self, c: QColor) -> None:
|
||||||
self._text_color = c
|
self._text_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -178,7 +367,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
"""Return hex strings of the current series color palette."""
|
"""Return hex strings of the current series color palette."""
|
||||||
return [c.name() for c in self._series_colors]
|
return [c.name() for c in self._series_colors]
|
||||||
|
|
||||||
@seriesColors.setter
|
@seriesColors.setter # type: ignore[no-redef]
|
||||||
def seriesColors(self, hex_list: list) -> None:
|
def seriesColors(self, hex_list: list) -> None:
|
||||||
colors = []
|
colors = []
|
||||||
for h in (hex_list or []):
|
for h in (hex_list or []):
|
||||||
@@ -192,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
# ── internal ──────────────────────────────────────────────────────────────
|
# ── internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolved_view_end(self) -> int:
|
||||||
|
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if self._view_end < 0 or self._view_end > max_len - 1:
|
||||||
|
return max_len - 1
|
||||||
|
return self._view_end
|
||||||
|
|
||||||
|
def _on_viewport_changed(self) -> None:
|
||||||
|
self._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
|
self.viewportChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _recompute_view_stats(self) -> None:
|
||||||
|
end = self._resolved_view_end()
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
|
||||||
|
self._series_data, self._sensor_names, self._view_start, end
|
||||||
|
)
|
||||||
|
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
|
||||||
|
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
|
||||||
|
self.viewStatsChanged.emit()
|
||||||
|
|
||||||
def _auto_range(self) -> None:
|
def _auto_range(self) -> None:
|
||||||
"""Set Y range to cover all data with 10% padding."""
|
"""Set Y range to cover the current viewport's data with 10% padding."""
|
||||||
|
start = max(0, self._view_start)
|
||||||
|
end = self._resolved_view_end()
|
||||||
all_vals: list[float] = []
|
all_vals: list[float] = []
|
||||||
for series in self._series_data:
|
for series in self._series_data:
|
||||||
for v in series:
|
hi = min(len(series) - 1, end)
|
||||||
|
for i in range(start, hi + 1):
|
||||||
try:
|
try:
|
||||||
all_vals.append(float(v))
|
all_vals.append(float(series[i]))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if not all_vals:
|
if not all_vals:
|
||||||
@@ -275,8 +489,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
for i in range(n_y_ticks):
|
for i in range(n_y_ticks):
|
||||||
y_val = self._min_y + i * y_step
|
y_val = self._min_y + i * y_step
|
||||||
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
|
y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
|
||||||
y_px = plot_top + y_frac * plot_h
|
|
||||||
|
|
||||||
# Grid line
|
# Grid line
|
||||||
painter.setPen(grid_pen)
|
painter.setPen(grid_pen)
|
||||||
@@ -318,19 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._x_label,
|
self._x_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- X-axis ticks ---
|
# --- Viewport slice (replay chart zoom/pan; full range by default,
|
||||||
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
# so unset viewport reproduces the pre-viewport rendering) ---
|
||||||
if num_points > 1:
|
view_start = max(0, self._view_start)
|
||||||
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
view_end = self._resolved_view_end()
|
||||||
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
view_len = max(1, view_end - view_start + 1)
|
||||||
|
|
||||||
|
# --- X-axis ticks (labelled in original, non-sliced index space) ---
|
||||||
|
if view_len > 1:
|
||||||
|
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
|
||||||
|
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
|
||||||
for i in range(n_x_ticks):
|
for i in range(n_x_ticks):
|
||||||
idx = int(round(i * x_tick_step))
|
rel_idx = int(round(i * x_tick_step))
|
||||||
x_frac = idx / (num_points - 1)
|
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
|
||||||
x_px = plot_left + x_frac * plot_w
|
|
||||||
painter.setPen(axis_pen)
|
painter.setPen(axis_pen)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
int(x_px - 12), int(plot_top + plot_h + 14),
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
str(idx + 1),
|
str(view_start + rel_idx + 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Axes boundary ---
|
# --- Axes boundary ---
|
||||||
@@ -339,30 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
||||||
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||||
|
|
||||||
# --- Figure out max series length ---
|
# --- Draw each series (sliced to the current viewport) ---
|
||||||
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
min_pt: tuple[float, float] | None = None
|
||||||
if max_len < 1:
|
max_pt: tuple[float, float] | None = None
|
||||||
return
|
min_seen = math.inf
|
||||||
|
max_seen = -math.inf
|
||||||
# --- Draw each series ---
|
|
||||||
for si, series in enumerate(self._series_data):
|
for si, series in enumerate(self._series_data):
|
||||||
if len(series) < 1:
|
hi = min(len(series) - 1, view_end)
|
||||||
|
sliced = series[view_start:hi + 1] if view_start <= hi else []
|
||||||
|
if not sliced:
|
||||||
continue
|
continue
|
||||||
color = self._series_colors[si % len(self._series_colors)]
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
pen = QPen(color, 2)
|
pen = QPen(color, 2)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
pts: list[tuple[float, float]] = []
|
pts: list[tuple[float, float]] = []
|
||||||
for i, val in enumerate(series):
|
for i, val in enumerate(sliced):
|
||||||
try:
|
try:
|
||||||
v = float(val)
|
v = float(val)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
|
x_px = index_to_pixel(i, view_len, plot_left, plot_w)
|
||||||
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
|
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||||
x_px = plot_left + x_frac * plot_w
|
|
||||||
y_px = plot_top + y_frac * plot_h
|
|
||||||
pts.append((x_px, y_px))
|
pts.append((x_px, y_px))
|
||||||
|
if self._show_min_max_markers:
|
||||||
|
if v < min_seen:
|
||||||
|
min_seen = v
|
||||||
|
min_pt = (x_px, y_px)
|
||||||
|
if v > max_seen:
|
||||||
|
max_seen = v
|
||||||
|
max_pt = (x_px, y_px)
|
||||||
|
|
||||||
if len(pts) < 2:
|
if len(pts) < 2:
|
||||||
if len(pts) == 1:
|
if len(pts) == 1:
|
||||||
@@ -375,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
|
||||||
|
if self._show_min_max_markers:
|
||||||
|
marker_font = QFont()
|
||||||
|
marker_font.setPixelSize(14)
|
||||||
|
marker_font.setBold(True)
|
||||||
|
painter.setFont(marker_font)
|
||||||
|
if max_pt is not None:
|
||||||
|
painter.setPen(QPen(QColor("#FF5757")))
|
||||||
|
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "▼")
|
||||||
|
if min_pt is not None:
|
||||||
|
painter.setPen(QPen(QColor("#42A5F5")))
|
||||||
|
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "▲")
|
||||||
|
|
||||||
# --- Legend ---
|
# --- Legend ---
|
||||||
if self._show_legend and self._sensor_names:
|
if self._show_legend and self._sensor_names:
|
||||||
legend_font = QFont()
|
legend_font = QFont()
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class GraphView(QObject):
|
|||||||
each sensor as a separate line series.
|
each sensor as a separate line series.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
|
||||||
|
_trend_line: Any
|
||||||
|
|
||||||
# ---- signals ----
|
# ---- signals ----
|
||||||
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
||||||
|
|
||||||
@@ -227,7 +230,7 @@ class GraphView(QObject):
|
|||||||
index_a, index_b = path[i]
|
index_a, index_b = path[i]
|
||||||
if index_a < len(a) and index_b < len(b):
|
if index_a < len(a) and index_b < len(b):
|
||||||
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||||
style=Qt.DashLine))
|
style=Qt.PenStyle.DashLine))
|
||||||
self._plot_widget.addItem(line)
|
self._plot_widget.addItem(line)
|
||||||
|
|
||||||
self._plot_widget.setTitle("DTW Comparison")
|
self._plot_widget.setTitle("DTW Comparison")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
||||||
|
|
||||||
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
Renders a single polyline series (per-frame average temperatures) over a fixed
|
||||||
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
|
||||||
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
rolling window. Mirrors the @QmlElement registration pattern used by
|
||||||
|
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
|
||||||
|
and used as `TrendChartItem { }`.
|
||||||
|
|
||||||
The series is driven by the existing `streamController.trendData` signal, which
|
The series is driven by `streamController.trendDelta` (one new
|
||||||
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
`[elapsed_s, value]` point per live frame) and cleared on
|
||||||
streaming.
|
`streamController.trendReset` (new stream started). See
|
||||||
|
docs/adr/0001-trend-chart-axes-standardization.md.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,17 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 60.0
|
||||||
|
Y_MIN = 0.0
|
||||||
|
Y_MAX = 200.0
|
||||||
|
Y_TICKS = (0, 40, 80, 120, 160, 200)
|
||||||
|
|
||||||
|
|
||||||
@QmlElement
|
@QmlElement
|
||||||
class TrendChartItem(QQuickPaintedItem):
|
class TrendChartItem(QQuickPaintedItem):
|
||||||
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
"""Painted trend chart; driven by incremental (elapsed_s, value) points."""
|
||||||
|
|
||||||
dataChanged = Signal()
|
|
||||||
hasDataChanged = Signal()
|
hasDataChanged = Signal()
|
||||||
lineColorChanged = Signal()
|
lineColorChanged = Signal()
|
||||||
gridColorChanged = Signal()
|
gridColorChanged = Signal()
|
||||||
@@ -42,41 +51,35 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self.setAntialiasing(True)
|
self.setAntialiasing(True)
|
||||||
self.setFillColor(QColor("transparent"))
|
self.setFillColor(QColor("transparent"))
|
||||||
|
|
||||||
self._data: list[float] = []
|
self._points: list[tuple[float, float]] = []
|
||||||
self._padding: int = 8
|
self._padding: int = 8
|
||||||
|
# Separate from `_padding` (breathing room around the plot area): these
|
||||||
|
# reserve space for the axis labels themselves, which are wider than
|
||||||
|
# `_padding` alone — that mismatch is what clipped y-axis text before.
|
||||||
|
self._margin_left: int = 48
|
||||||
|
self._margin_bottom: int = 30
|
||||||
|
# Top tick label is drawn centered on the plot's top edge (y_px - 7),
|
||||||
|
# so the plot needs headroom or the label kisses the card border.
|
||||||
|
self._margin_top: int = 10
|
||||||
self._line_color = QColor("#5B9DF5")
|
self._line_color = QColor("#5B9DF5")
|
||||||
self._grid_color = QColor("#2A3441")
|
self._grid_color = QColor("#2A3441")
|
||||||
self._text_color = QColor("#CBD5E1")
|
self._text_color = QColor("#CBD5E1")
|
||||||
|
|
||||||
# ── Qt properties ─────────────────────────────────────────────────────
|
# ── Qt properties ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=dataChanged)
|
|
||||||
def data(self) -> list[float]:
|
|
||||||
return self._data
|
|
||||||
|
|
||||||
@data.setter
|
|
||||||
def data(self, val) -> None:
|
|
||||||
coerced = self._coerce_floats(val)
|
|
||||||
if coerced == self._data:
|
|
||||||
return
|
|
||||||
self._data = coerced
|
|
||||||
self.dataChanged.emit()
|
|
||||||
self.hasDataChanged.emit()
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
@Property(bool, notify=hasDataChanged)
|
@Property(bool, notify=hasDataChanged)
|
||||||
def hasData(self) -> bool:
|
def hasData(self) -> bool:
|
||||||
"""True when the data list has at least one point.
|
"""True when at least one point is buffered.
|
||||||
|
|
||||||
QML can bind to this safely (QVariantList `.length` is not bindable).
|
QML can bind to this safely (a plain list length is not bindable).
|
||||||
"""
|
"""
|
||||||
return len(self._data) > 0
|
return len(self._points) > 0
|
||||||
|
|
||||||
@Property(QColor, notify=lineColorChanged)
|
@Property(QColor, notify=lineColorChanged)
|
||||||
def lineColor(self) -> QColor:
|
def lineColor(self) -> QColor:
|
||||||
return self._line_color
|
return self._line_color
|
||||||
|
|
||||||
@lineColor.setter
|
@lineColor.setter # type: ignore[no-redef]
|
||||||
def lineColor(self, c: QColor) -> None:
|
def lineColor(self, c: QColor) -> None:
|
||||||
if c == self._line_color:
|
if c == self._line_color:
|
||||||
return
|
return
|
||||||
@@ -88,7 +91,7 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
def gridColor(self) -> QColor:
|
def gridColor(self) -> QColor:
|
||||||
return self._grid_color
|
return self._grid_color
|
||||||
|
|
||||||
@gridColor.setter
|
@gridColor.setter # type: ignore[no-redef]
|
||||||
def gridColor(self, c: QColor) -> None:
|
def gridColor(self, c: QColor) -> None:
|
||||||
if c == self._grid_color:
|
if c == self._grid_color:
|
||||||
return
|
return
|
||||||
@@ -100,7 +103,7 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
def textColor(self) -> QColor:
|
def textColor(self) -> QColor:
|
||||||
return self._text_color
|
return self._text_color
|
||||||
|
|
||||||
@textColor.setter
|
@textColor.setter # type: ignore[no-redef]
|
||||||
def textColor(self, c: QColor) -> None:
|
def textColor(self, c: QColor) -> None:
|
||||||
if c == self._text_color:
|
if c == self._text_color:
|
||||||
return
|
return
|
||||||
@@ -112,7 +115,7 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
def padding(self) -> int:
|
def padding(self) -> int:
|
||||||
return self._padding
|
return self._padding
|
||||||
|
|
||||||
@padding.setter
|
@padding.setter # type: ignore[no-redef]
|
||||||
def padding(self, val: int) -> None:
|
def padding(self, val: int) -> None:
|
||||||
v = max(0, int(val))
|
v = max(0, int(val))
|
||||||
if v == self._padding:
|
if v == self._padding:
|
||||||
@@ -121,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self.paddingChanged.emit()
|
self.paddingChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
# ── slots for QML: streamController.trendDelta / trendReset ────────────
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def setDataFromJson(self, avgs_json: str) -> None:
|
def appendDelta(self, delta_json: str) -> None:
|
||||||
"""Slot for QML Connections handler: parse JSON array and update data.
|
"""Append new (elapsed_s, value) points and prune the 60s window.
|
||||||
|
|
||||||
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
Mirrors `streamController.trendDelta`, which emits one new pair per
|
||||||
|
live frame as `[[elapsed_s, value], ...]`.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(avgs_json) if avgs_json else []
|
parsed = json.loads(delta_json) if delta_json else []
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
|
||||||
return
|
return
|
||||||
coerced = self._coerce_floats(parsed)
|
|
||||||
if coerced == self._data:
|
had_data = len(self._points) > 0
|
||||||
|
added = False
|
||||||
|
for pair in parsed:
|
||||||
|
try:
|
||||||
|
elapsed, value = float(pair[0]), float(pair[1])
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
self._points.append((elapsed, value))
|
||||||
|
added = True
|
||||||
|
|
||||||
|
if not added:
|
||||||
return
|
return
|
||||||
self._data = coerced
|
|
||||||
self.dataChanged.emit()
|
latest_elapsed = self._points[-1][0]
|
||||||
|
cutoff = latest_elapsed - WINDOW_SECONDS
|
||||||
|
self._points = [p for p in self._points if p[0] >= cutoff]
|
||||||
|
|
||||||
|
if not had_data:
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def clearTrend(self) -> None:
|
||||||
|
"""Clear all buffered points (new stream started)."""
|
||||||
|
if not self._points:
|
||||||
|
return
|
||||||
|
self._points = []
|
||||||
self.hasDataChanged.emit()
|
self.hasDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -153,57 +180,39 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
if w <= 0 or h <= 0:
|
if w <= 0 or h <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
plot_rect = QRectF(self._padding, self._padding,
|
left = self._padding + self._margin_left
|
||||||
max(1, w - 2 * self._padding),
|
top = self._padding + self._margin_top
|
||||||
max(1, h - 2 * self._padding))
|
bottom_inset = self._padding + self._margin_bottom
|
||||||
|
plot_rect = QRectF(left, top,
|
||||||
|
max(1, w - left - self._padding),
|
||||||
|
max(1, h - top - bottom_inset))
|
||||||
|
|
||||||
self._draw_grid(painter, plot_rect)
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
if len(self._data) < 2:
|
if len(self._points) < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
y_min, y_max = self._y_range()
|
window_end = self._points[-1][0]
|
||||||
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
window_start = max(0.0, window_end - WINDOW_SECONDS)
|
||||||
self._draw_line(painter, plot_rect, y_min, y_max)
|
self._draw_axes_labels(painter, plot_rect, window_start, window_end)
|
||||||
|
self._draw_line(painter, plot_rect, window_start, window_end)
|
||||||
|
|
||||||
# ── internals ─────────────────────────────────────────────────────────
|
# ── internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _coerce_floats(self, val) -> list[float]:
|
def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
|
||||||
if not val:
|
return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
|
||||||
return []
|
|
||||||
out: list[float] = []
|
|
||||||
for v in val:
|
|
||||||
try:
|
|
||||||
out.append(float(v))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _y_range(self) -> tuple[float, float]:
|
def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
|
||||||
lo = min(self._data)
|
px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
|
||||||
hi = max(self._data)
|
# Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
|
||||||
if hi - lo < 1e-9:
|
return max(plot_rect.top(), min(plot_rect.bottom(), px))
|
||||||
return lo - 5.0, hi + 5.0
|
|
||||||
buf = (hi - lo) * 0.1
|
|
||||||
return lo - buf, hi + buf
|
|
||||||
|
|
||||||
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
|
||||||
if n <= 1:
|
|
||||||
return plot_rect.left()
|
|
||||||
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
|
||||||
|
|
||||||
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
|
||||||
if y_max - y_min < 1e-9:
|
|
||||||
return plot_rect.center().y()
|
|
||||||
t = (v - y_min) / (y_max - y_min)
|
|
||||||
return plot_rect.bottom() - t * plot_rect.height()
|
|
||||||
|
|
||||||
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||||
pen = QPen(self._grid_color)
|
pen = QPen(self._grid_color)
|
||||||
pen.setWidthF(1.0)
|
pen.setWidthF(1.0)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
rows = 4
|
rows = len(Y_TICKS) - 1
|
||||||
for i in range(rows + 1):
|
for i in range(rows + 1):
|
||||||
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||||
painter.drawLine(int(plot_rect.left()), int(y),
|
painter.drawLine(int(plot_rect.left()), int(y),
|
||||||
@@ -213,31 +222,69 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
plot_rect: QRectF,
|
plot_rect: QRectF,
|
||||||
y_min: float,
|
window_start: float,
|
||||||
y_max: float,
|
window_end: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
painter.setPen(self._text_color)
|
painter.setPen(self._text_color)
|
||||||
font = QFont(painter.font())
|
font = QFont(painter.font())
|
||||||
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
rows = 4
|
|
||||||
for i in range(rows + 1):
|
title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
|
||||||
t = i / rows
|
label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
|
||||||
y_val = y_max - t * (y_max - y_min)
|
for tick in reversed(Y_TICKS):
|
||||||
y_px = plot_rect.top() + t * plot_rect.height()
|
y_px = self._y_to_px(tick, plot_rect)
|
||||||
label = f"{y_val:.1f}"
|
painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
|
||||||
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
|
||||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
label)
|
str(tick))
|
||||||
|
|
||||||
|
# X-axis: elapsed seconds within the current rolling window.
|
||||||
|
cols = 4
|
||||||
|
y_px = plot_rect.bottom() + 4
|
||||||
|
for i in range(cols + 1):
|
||||||
|
t_val = window_start + (i / cols) * (window_end - window_start)
|
||||||
|
x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
|
||||||
|
label = f"{t_val:.0f}"
|
||||||
|
if i == 0:
|
||||||
|
rect = QRectF(x_px, y_px, 40, 14)
|
||||||
|
align = Qt.AlignmentFlag.AlignLeft
|
||||||
|
elif i == cols:
|
||||||
|
# Right-anchor inside the plot so the label never clips off-widget
|
||||||
|
rect = QRectF(x_px - 40, y_px, 40, 14)
|
||||||
|
align = Qt.AlignmentFlag.AlignRight
|
||||||
|
else:
|
||||||
|
rect = QRectF(x_px - 20, y_px, 40, 14)
|
||||||
|
align = Qt.AlignmentFlag.AlignHCenter
|
||||||
|
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
|
||||||
|
|
||||||
|
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
|
||||||
|
|
||||||
|
def _draw_axis_titles(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
title_w: float,
|
||||||
|
x_label_bottom: float,
|
||||||
|
) -> None:
|
||||||
|
# Y axis: "Temp", rotated upright, centered along the plot's height.
|
||||||
|
painter.save()
|
||||||
|
painter.translate(title_w / 2, plot_rect.center().y())
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# X axis: "Time", centered under the elapsed-seconds tick row.
|
||||||
|
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
|
||||||
|
|
||||||
def _draw_line(
|
def _draw_line(
|
||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
plot_rect: QRectF,
|
plot_rect: QRectF,
|
||||||
y_min: float,
|
window_start: float,
|
||||||
y_max: float,
|
window_end: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
n = len(self._data)
|
|
||||||
pen = QPen(self._line_color)
|
pen = QPen(self._line_color)
|
||||||
pen.setWidthF(2.0)
|
pen.setWidthF(2.0)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
@@ -246,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
poly = QPolygon()
|
poly = QPolygon()
|
||||||
for i, v in enumerate(self._data):
|
for elapsed, value in self._points:
|
||||||
px = self._x_to_px(i, n, plot_rect)
|
px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
|
||||||
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
py = self._y_to_px(value, plot_rect)
|
||||||
poly.append(QPoint(int(px), int(py)))
|
poly.append(QPoint(int(px), int(py)))
|
||||||
painter.drawPolyline(poly)
|
painter.drawPolyline(poly)
|
||||||
|
|
||||||
# Trailing dot at the last data point
|
# Trailing dot at the last data point
|
||||||
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
last_elapsed, last_value = self._points[-1]
|
||||||
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
|
||||||
|
last_y = int(self._y_to_px(last_value, plot_rect))
|
||||||
painter.setBrush(self._line_color)
|
painter.setBrush(self._line_color)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
radius = 3
|
radius = 3
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QBrush,
|
QBrush,
|
||||||
QColor,
|
QColor,
|
||||||
@@ -27,6 +27,7 @@ from PySide6.QtGui import (
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.models.frame_stats import compute_stats
|
||||||
from pygui.backend.visualization.rbf_heatmap import (
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
ellipse_alpha,
|
ellipse_alpha,
|
||||||
interpolate_field,
|
interpolate_field,
|
||||||
@@ -36,6 +37,59 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
|
||||||
|
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
|
||||||
|
|
||||||
|
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
|
||||||
|
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
|
||||||
|
Returns (points, error) — error is "" on success.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
points: list[list[float]] = []
|
||||||
|
slot: str | None = None
|
||||||
|
try:
|
||||||
|
with open(file_path, newline="") as fh:
|
||||||
|
reader = csv.reader(fh)
|
||||||
|
header = next(reader, None)
|
||||||
|
if header is None:
|
||||||
|
return [], "Unable to read header row from CSV file."
|
||||||
|
if header and header[-1] == "": # trailing comma
|
||||||
|
header = header[:-1]
|
||||||
|
nfields = len(header)
|
||||||
|
if nfields != 8:
|
||||||
|
return [], "Incorrect field count in CSV header row."
|
||||||
|
for lno, row in enumerate(reader, start=2):
|
||||||
|
if row and row[-1] == "":
|
||||||
|
row = row[:-1]
|
||||||
|
if len(row) != nfields:
|
||||||
|
return [], f"Incorrect field count in CSV row {lno}"
|
||||||
|
if slot is None:
|
||||||
|
slot = row[3]
|
||||||
|
elif row[3] != slot:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
t2, x, y = float(row[4]), float(row[6]), float(row[7])
|
||||||
|
except ValueError:
|
||||||
|
return [], f"Malformed value in CSV row {lno}"
|
||||||
|
points.append([x, y, t2])
|
||||||
|
except OSError as exc:
|
||||||
|
return [], f"Unable to read CSV file: {exc}"
|
||||||
|
if not points:
|
||||||
|
return [], "No data found in CSV file."
|
||||||
|
return points, ""
|
||||||
|
|
||||||
|
|
||||||
|
def readout_text(values: list[float]) -> str:
|
||||||
|
"""Full READOUT stats line for the export footer; '' when no data."""
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
s = compute_stats(values)
|
||||||
|
return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) "
|
||||||
|
f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} "
|
||||||
|
f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}")
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
@@ -51,6 +105,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
marginChanged = Signal()
|
marginChanged = Signal()
|
||||||
blendChanged = Signal()
|
blendChanged = Signal()
|
||||||
showLabelsChanged = Signal()
|
showLabelsChanged = Signal()
|
||||||
|
showExtremesChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
shapeChanged = Signal()
|
shapeChanged = Signal()
|
||||||
sizeChanged = Signal()
|
sizeChanged = Signal()
|
||||||
@@ -69,9 +124,10 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._margin: float = 1.0
|
self._margin: float = 1.0
|
||||||
self._blend: float = 0.0
|
self._blend: float = 0.0
|
||||||
self._show_labels: bool = True
|
self._show_labels: bool = True
|
||||||
|
self._show_extremes: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
self._size: float = 300.0
|
||||||
self._thickness_data: list[float] = []
|
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
|
||||||
self._show_thickness: bool = False
|
self._show_thickness: bool = False
|
||||||
self._thickness_heatmap:QImage | None = None
|
self._thickness_heatmap:QImage | None = None
|
||||||
# Hover highlight: index of the marker under the pointer (-1 = none)
|
# Hover highlight: index of the marker under the pointer (-1 = none)
|
||||||
@@ -107,7 +163,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def sensors(self) -> list:
|
def sensors(self) -> list:
|
||||||
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||||||
|
|
||||||
@sensors.setter
|
@sensors.setter # type: ignore[no-redef]
|
||||||
def sensors(self, val: list) -> None:
|
def sensors(self, val: list) -> None:
|
||||||
self._sensors = [
|
self._sensors = [
|
||||||
Sensor(
|
Sensor(
|
||||||
@@ -127,7 +183,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def values(self) -> list:
|
def values(self) -> list:
|
||||||
return self._values
|
return self._values
|
||||||
|
|
||||||
@values.setter
|
@values.setter # type: ignore[no-redef]
|
||||||
def values(self, val: list) -> None:
|
def values(self, val: list) -> None:
|
||||||
self._values = list(val or [])
|
self._values = list(val or [])
|
||||||
self._rebuild_heatmap()
|
self._rebuild_heatmap()
|
||||||
@@ -138,7 +194,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def bands(self) -> list:
|
def bands(self) -> list:
|
||||||
return self._bands
|
return self._bands
|
||||||
|
|
||||||
@bands.setter
|
@bands.setter # type: ignore[no-redef]
|
||||||
def bands(self, val: list) -> None:
|
def bands(self, val: list) -> None:
|
||||||
self._bands = list(val or [])
|
self._bands = list(val or [])
|
||||||
self.bandsChanged.emit()
|
self.bandsChanged.emit()
|
||||||
@@ -148,7 +204,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def target(self) -> float:
|
def target(self) -> float:
|
||||||
return self._target
|
return self._target
|
||||||
|
|
||||||
@target.setter
|
@target.setter # type: ignore[no-redef]
|
||||||
def target(self, val: float) -> None:
|
def target(self, val: float) -> None:
|
||||||
self._target = float(val)
|
self._target = float(val)
|
||||||
self._rebuild_heatmap()
|
self._rebuild_heatmap()
|
||||||
@@ -159,7 +215,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def margin(self) -> float:
|
def margin(self) -> float:
|
||||||
return self._margin
|
return self._margin
|
||||||
|
|
||||||
@margin.setter
|
@margin.setter # type: ignore[no-redef]
|
||||||
def margin(self, val: float) -> None:
|
def margin(self, val: float) -> None:
|
||||||
self._margin = float(val)
|
self._margin = float(val)
|
||||||
self._rebuild_heatmap()
|
self._rebuild_heatmap()
|
||||||
@@ -170,7 +226,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def blend(self) -> float:
|
def blend(self) -> float:
|
||||||
return self._blend
|
return self._blend
|
||||||
|
|
||||||
@blend.setter
|
@blend.setter # type: ignore[no-redef]
|
||||||
def blend(self, val: float) -> None:
|
def blend(self, val: float) -> None:
|
||||||
self._blend = max(0.0, min(1.0, float(val)))
|
self._blend = max(0.0, min(1.0, float(val)))
|
||||||
if self._blend > 0 and self._heatmap is None:
|
if self._blend > 0 and self._heatmap is None:
|
||||||
@@ -182,17 +238,27 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def showLabels(self) -> bool:
|
def showLabels(self) -> bool:
|
||||||
return self._show_labels
|
return self._show_labels
|
||||||
|
|
||||||
@showLabels.setter
|
@showLabels.setter # type: ignore[no-redef]
|
||||||
def showLabels(self, val: bool) -> None:
|
def showLabels(self, val: bool) -> None:
|
||||||
self._show_labels = bool(val)
|
self._show_labels = bool(val)
|
||||||
self.showLabelsChanged.emit()
|
self.showLabelsChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=showExtremesChanged)
|
||||||
|
def showExtremes(self) -> bool:
|
||||||
|
return self._show_extremes
|
||||||
|
|
||||||
|
@showExtremes.setter # type: ignore[no-redef]
|
||||||
|
def showExtremes(self, val: bool) -> None:
|
||||||
|
self._show_extremes = bool(val)
|
||||||
|
self.showExtremesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
@Property(str, notify=shapeChanged)
|
@Property(str, notify=shapeChanged)
|
||||||
def shape(self) -> str:
|
def shape(self) -> str:
|
||||||
return self._shape
|
return self._shape
|
||||||
|
|
||||||
@shape.setter
|
@shape.setter # type: ignore[no-redef]
|
||||||
def shape(self, val: str) -> None:
|
def shape(self, val: str) -> None:
|
||||||
self._shape = str(val).lower()
|
self._shape = str(val).lower()
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
@@ -202,7 +268,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def size(self) -> float:
|
def size(self) -> float:
|
||||||
return self._size
|
return self._size
|
||||||
|
|
||||||
@size.setter
|
@size.setter # type: ignore[no-redef]
|
||||||
def size(self, val: float) -> None:
|
def size(self, val: float) -> None:
|
||||||
self._size = float(val)
|
self._size = float(val)
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
@@ -210,20 +276,38 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
@Property("QVariantList", notify=thicknessChanged)
|
@Property("QVariantList", notify=thicknessChanged)
|
||||||
def thicknessData(self) -> list:
|
def thicknessData(self) -> list:
|
||||||
|
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
|
||||||
return self._thickness_data
|
return self._thickness_data
|
||||||
|
|
||||||
@thicknessData.setter
|
@thicknessData.setter # type: ignore[no-redef]
|
||||||
def thicknessData(self, val:list) -> None:
|
def thicknessData(self, val:list) -> None:
|
||||||
self._thickness_data = list(val or [])
|
self._thickness_data = [list(p) for p in (val or [])]
|
||||||
self._rebuild_thickness()
|
self._rebuild_thickness()
|
||||||
self.thicknessChanged.emit()
|
self.thicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=thicknessChanged)
|
||||||
|
def hasThickness(self) -> bool:
|
||||||
|
return bool(self._thickness_data)
|
||||||
|
|
||||||
|
@Slot(str, result=str)
|
||||||
|
def loadThickness(self, file_path: str) -> str:
|
||||||
|
"""Load a customer thickness CSV; returns error message, '' on success."""
|
||||||
|
points, err = parse_thickness_csv(file_path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
self._thickness_data = points
|
||||||
|
self._rebuild_thickness()
|
||||||
|
self.thicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
return ""
|
||||||
self.update
|
self.update
|
||||||
|
|
||||||
@Property(bool, notify=showThicknessChanged)
|
@Property(bool, notify=showThicknessChanged)
|
||||||
def showThickness(self) -> bool:
|
def showThickness(self) -> bool:
|
||||||
return self._show_thickness
|
return self._show_thickness
|
||||||
|
|
||||||
@showThickness.setter
|
@showThickness.setter # type: ignore[no-redef]
|
||||||
def showThickness(self, val: bool) -> None:
|
def showThickness(self, val: bool) -> None:
|
||||||
self._show_thickness = bool(val)
|
self._show_thickness = bool(val)
|
||||||
self.showThicknessChanged.emit()
|
self.showThicknessChanged.emit()
|
||||||
@@ -233,7 +317,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 +330,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 +339,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 ─────────────────────────────────────────────────────────────
|
||||||
@@ -301,41 +385,73 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
|
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
|
||||||
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
||||||
@Slot(str, result=bool)
|
@Slot(str, result=bool)
|
||||||
def export_image(self, file_path: str) -> bool:
|
@Slot(str, str, result=bool)
|
||||||
|
def export_image(self, file_path: str, extra: str = "") -> bool:
|
||||||
"""Export the current wafer map rendering to a PNG file
|
"""Export the current wafer map rendering to a PNG file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_pat: Absolute path for the output PNG.
|
file_path: Absolute path for the output PNG.
|
||||||
|
extra: Optional second footer line (live stream metrics).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
if not file_path:
|
if not file_path or not self._values:
|
||||||
|
# No file loaded / no stream played yet — only the empty template
|
||||||
|
# would render, which isn't a meaningful export.
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
result = self.grabToImage()
|
# Render synchronously via paint() into our own image —
|
||||||
img = result.image()
|
# grabToImage() resolves on a later render frame, so its result
|
||||||
img.save(file_path, "PNG")
|
# is null when read immediately (the old silent failure).
|
||||||
return True
|
w, h = int(self.width()), int(self.height())
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return False
|
||||||
|
lines = [ln for ln in (readout_text(self._values), extra) if ln]
|
||||||
|
footer_h = 28 * len(lines)
|
||||||
|
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
|
||||||
|
img.fill(0) # transparent background
|
||||||
|
painter = QPainter(img)
|
||||||
|
self.paint(painter)
|
||||||
|
if lines:
|
||||||
|
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
||||||
|
painter.setPen(QColor("#CBD5E1"))
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
painter.drawText(QRectF(0, h + 28 * i, w, 28),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, line)
|
||||||
|
painter.end()
|
||||||
|
return bool(img.save(file_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Export failed: %s", e)
|
log.error("Export failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _paint_extremes(self, painter: QPainter) -> None:
|
||||||
|
"""Ring the hottest (high color) and coldest (low color) markers."""
|
||||||
|
if not self._show_extremes or not self._values or not self._markers:
|
||||||
|
return
|
||||||
|
s = compute_stats(self._values)
|
||||||
|
ring_r = self._marker_r + 5
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
for idx, color in ((s.max_index, self._high_color),
|
||||||
|
(s.min_index, self._low_color)):
|
||||||
|
if idx not in self._markers:
|
||||||
|
continue
|
||||||
|
px, py = self._markers[idx]
|
||||||
|
painter.setPen(QPen(color, 2))
|
||||||
|
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
|
||||||
|
|
||||||
def _rebuild_thickness(self) -> None:
|
def _rebuild_thickness(self) -> None:
|
||||||
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
"""Interpolate thickness points into a gray/orange heatmap QImage."""
|
||||||
if not self._sensors or not self._thickness_data:
|
if not self._thickness_data:
|
||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
r_mm = self._wafer_radius_mm()
|
r_mm = self._wafer_radius_mm()
|
||||||
xs = np.array([s.x for s in self._sensors])
|
pts = np.array(self._thickness_data, dtype=float)
|
||||||
ys = np.array([s.y for s in self._sensors])
|
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
|
||||||
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
|
||||||
if len(vs) < len(self._sensors):
|
|
||||||
self._thickness_heatmap = None
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
@@ -347,6 +463,10 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
field = refine_field(field)
|
field = refine_field(field)
|
||||||
|
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||||||
|
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||||||
|
# Clamp to the real data range so color only reflects measured values.
|
||||||
|
field = np.clip(field, vs.min(), vs.max())
|
||||||
if self._shape == "round":
|
if self._shape == "round":
|
||||||
alpha = ellipse_alpha(*field.shape)
|
alpha = ellipse_alpha(*field.shape)
|
||||||
else:
|
else:
|
||||||
@@ -467,6 +587,10 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._heatmap = None
|
self._heatmap = None
|
||||||
return
|
return
|
||||||
field = refine_field(field)
|
field = refine_field(field)
|
||||||
|
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||||||
|
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||||||
|
# Clamp to the real data range so color only reflects measured values.
|
||||||
|
field = np.clip(field, vs.min(), vs.max())
|
||||||
if self._shape == "round":
|
if self._shape == "round":
|
||||||
alpha = ellipse_alpha(*field.shape)
|
alpha = ellipse_alpha(*field.shape)
|
||||||
else:
|
else:
|
||||||
@@ -546,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setOpacity(1.0)
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
self._paint_markers(painter)
|
self._paint_markers(painter)
|
||||||
|
self._paint_extremes(painter)
|
||||||
|
|
||||||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
@@ -609,11 +734,11 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
font_scale = 0.85
|
font_scale = 0.85
|
||||||
|
|
||||||
id_font = QFont()
|
id_font = QFont()
|
||||||
id_font.setPointSize(max(4, int(r * font_scale)))
|
id_font.setPointSize(max(9, int(r * font_scale)))
|
||||||
id_font.setBold(True)
|
id_font.setBold(True)
|
||||||
|
|
||||||
temp_font = QFont()
|
temp_font = QFont()
|
||||||
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
|
temp_font.setPointSize(max(9, int(r * font_scale)))
|
||||||
|
|
||||||
band_color = {
|
band_color = {
|
||||||
"in_range": self._in_range_color,
|
"in_range": self._in_range_color,
|
||||||
@@ -694,11 +819,11 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setPen(QPen(self._text_color))
|
painter.setPen(QPen(self._text_color))
|
||||||
y1 = ly + id_ascent
|
y1 = ly + id_ascent
|
||||||
if side in ("top", "bottom"):
|
if side in ("top", "bottom"):
|
||||||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
|
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
|
||||||
elif side == "left":
|
elif side == "left":
|
||||||
painter.drawText(lx + (text_w - id_w), y1, id_text)
|
painter.drawText(lx + (text_w - id_w), y1, id_text) # type: ignore[arg-type]
|
||||||
else:
|
else:
|
||||||
painter.drawText(lx, y1, id_text)
|
painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
|
||||||
|
|
||||||
# Draw Temperature (second line)
|
# Draw Temperature (second line)
|
||||||
if has_temp:
|
if has_temp:
|
||||||
@@ -706,8 +831,8 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setPen(QPen(color))
|
painter.setPen(QPen(color))
|
||||||
y2 = ly + id_line_h + temp_ascent
|
y2 = ly + id_line_h + temp_ascent
|
||||||
if side in ("top", "bottom"):
|
if side in ("top", "bottom"):
|
||||||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
|
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
|
||||||
elif side == "left":
|
elif side == "left":
|
||||||
painter.drawText(lx + (text_w - temp_w), y2, temp_text)
|
painter.drawText(lx + (text_w - temp_w), y2, temp_text) # type: ignore[arg-type]
|
||||||
else:
|
else:
|
||||||
painter.drawText(lx, y2, temp_text)
|
painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# ===== Wafer Sub-package =====
|
|
||||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
|
||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ZWaferData", "Sensor", "DataRecord",
|
|
||||||
"ZWaferParser",
|
|
||||||
"load_layout", "available_families",
|
|
||||||
"sensor_count_for",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -66,15 +66,39 @@ def _family_name(raw_name: str) -> str:
|
|||||||
|
|
||||||
def _load_yaml(path: Path) -> dict:
|
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)
|
# Edge→center sensor pair tables, ported verbatim from the C# original
|
||||||
# THINKING: this already returns everything the UI needs (family names) —
|
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
|
||||||
# the missing piece is a context-property exposure (SessionController or a
|
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
|
||||||
# thin new property) and a setLayout(family) slot that calls load_layout()
|
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
|
||||||
# below. No parsing work needed; this is a wiring-only task.
|
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
|
||||||
# See docs/pending/alpha-release-polish-plan.md §6.3.
|
"BCD": tuple((e, 28) for e in range(12)),
|
||||||
|
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
|
||||||
|
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
|
||||||
|
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
|
||||||
|
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
|
||||||
|
"Z": tuple((e, 0) for e in range(49, 65)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
|
||||||
|
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
|
||||||
|
|
||||||
|
Unknown families deliberately get no pairs (C# fell through to Z).
|
||||||
|
"""
|
||||||
|
prefix = wafer_id[0].upper() if wafer_id else ""
|
||||||
|
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
|
||||||
|
("X", "X"), ("Z", "Z")):
|
||||||
|
if prefix and prefix in families:
|
||||||
|
return _EC_MAPS[key]
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
|
||||||
|
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
|
||||||
def available_families() -> list[str]:
|
def available_families() -> list[str]:
|
||||||
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -106,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_debug_temp(hex_str: str) -> float:
|
||||||
|
"""Convert a 4-char hex string to a debug/cold-junction temperature.
|
||||||
|
|
||||||
|
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
|
||||||
|
2**(i-8), sign bit at bit 0. No family-code branching — this formula
|
||||||
|
applies regardless of wafer family, unlike _convert_hex_to_temp.
|
||||||
|
"""
|
||||||
|
bits = _hex_to_binary(hex_str)
|
||||||
|
value = 0.0
|
||||||
|
for i in range(4, 15):
|
||||||
|
if bits[i]:
|
||||||
|
value += 2.0 ** (i - 8)
|
||||||
|
if bits[0]:
|
||||||
|
value = -value
|
||||||
|
return round(value, 2)
|
||||||
|
|
||||||
|
|
||||||
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
||||||
"""Convert a singi hale 4-char hex string to a float temperature."""
|
"""Convert a singi hale 4-char hex string to a float temperature."""
|
||||||
bits = _hex_to_binary(hex_str)
|
bits = _hex_to_binary(hex_str)
|
||||||
@@ -224,6 +242,15 @@ def convert_to_temperatures(
|
|||||||
return temp_value
|
return temp_value
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
|
||||||
|
"""Convert debug/cold-junction hex values to temperature strings.
|
||||||
|
|
||||||
|
Same shape contract as convert_to_temperatures, but uses the
|
||||||
|
family-independent debug formula (_convert_debug_temp).
|
||||||
|
"""
|
||||||
|
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
|
||||||
|
|
||||||
|
|
||||||
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
||||||
"""Remove rows of all-zero values from the end of data (in-place).
|
"""Remove rows of all-zero values from the end of data (in-place).
|
||||||
|
|
||||||
@@ -266,7 +293,6 @@ def save_to_csv(
|
|||||||
try:
|
try:
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
# Build filename: P00001-20260505_133045.csv
|
# Build filename: P00001-20260505_133045.csv
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"{serial_number}-{timestamp}.csv"
|
filename = f"{serial_number}-{timestamp}.csv"
|
||||||
@@ -294,3 +320,46 @@ def save_to_csv(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CSV save failed: %s", exc)
|
log.error("CSV save failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_debug_csv(
|
||||||
|
temp_data: list[list[str]],
|
||||||
|
debug_data: list[list[str]],
|
||||||
|
output_dir: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
|
||||||
|
|
||||||
|
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
|
||||||
|
truncated to the shorter of the two row/column counts. Fixed filename
|
||||||
|
(not timestamped), unlike save_to_csv.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
temp_data: 2D array of sensor temperature strings (D1, decoded via
|
||||||
|
convert_to_temperatures).
|
||||||
|
debug_data: 2D array of debug temperature strings (F1, decoded via
|
||||||
|
convert_to_debug_temperatures).
|
||||||
|
output_dir: Directory to save the CSV file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full file path on success, None on empty input or write failure.
|
||||||
|
"""
|
||||||
|
if not temp_data or not debug_data:
|
||||||
|
log.error("Debug CSV save failed: temp or debug data is empty")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
num_cols = min(len(temp_data[0]), len(debug_data[0]))
|
||||||
|
num_rows = min(len(temp_data), len(debug_data))
|
||||||
|
filepath = os.path.join(output_dir, "debug_info.csv")
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
|
||||||
|
f.write(",".join(headers) + "\n")
|
||||||
|
for i in range(num_rows):
|
||||||
|
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
|
||||||
|
f.write(",".join(row) + "\n")
|
||||||
|
|
||||||
|
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
|
||||||
|
return filepath
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Debug CSV save failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class SerialPort:
|
|||||||
)
|
)
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
kwargs["timeout"] = timeout
|
kwargs["timeout"] = timeout
|
||||||
|
# Write timeout must never be None (= block forever): on macOS,
|
||||||
|
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
|
||||||
|
# hang the write, which froze detect in "Detecting..." permanently.
|
||||||
|
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
Wafer ID=A12
|
||||||
|
Acquisition Date=03/26/2025
|
||||||
|
Label,1,2,3
|
||||||
|
X (mm),0,10,-10
|
||||||
|
Y (mm),10,-10,-10
|
||||||
|
data
|
||||||
|
0.0,150.0,149.5,151.6
|
||||||
|
0.5,150.2,149.4,151.7
|
||||||
|
1.0,150.1,149.6,151.5
|
||||||
|
1.5,150.3,149.7,151.4
|
||||||
|
2.0,150.4,149.8,151.3
|
||||||
|
@@ -0,0 +1,52 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from pygui.backend.visualization.chart_geometry import (
|
||||||
|
elapsed_to_pixel,
|
||||||
|
index_to_pixel,
|
||||||
|
value_to_pixel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_min_maps_to_bottom():
|
||||||
|
assert value_to_pixel(0.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(210.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_max_maps_to_top():
|
||||||
|
assert value_to_pixel(100.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_midpoint():
|
||||||
|
assert value_to_pixel(50.0, 0.0, 100.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_degenerate_range_centers():
|
||||||
|
assert value_to_pixel(5.0, 5.0, 5.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_to_pixel_first_and_last():
|
||||||
|
assert index_to_pixel(0, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(0.0)
|
||||||
|
assert index_to_pixel(4, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_to_pixel_single_point_centers():
|
||||||
|
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_window_start_maps_to_left():
|
||||||
|
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_window_end_maps_to_right():
|
||||||
|
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_midpoint():
|
||||||
|
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_degenerate_window_pins_right():
|
||||||
|
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
|
||||||
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
|
|||||||
assert records[0].values == [149.0, 148.0]
|
assert records[0].values == [149.0, 148.0]
|
||||||
assert records[1].time == 0.5
|
assert records[1].time == 0.5
|
||||||
assert records[1].values == [149.5, 148.5]
|
assert records[1].values == [149.5, 148.5]
|
||||||
|
|
||||||
|
# ===== write_segment (P3.3) =====
|
||||||
|
def _make_zwafer_csv(tmp_path, rows=5):
|
||||||
|
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
|
||||||
|
path = tmp_path / "src.csv"
|
||||||
|
rec = CsvRecorder()
|
||||||
|
rec.start(str(path), sensors, serial="A12")
|
||||||
|
for i in range(rows):
|
||||||
|
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
|
||||||
|
rec.stop()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_zwafer_slice(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=5)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
|
||||||
|
|
||||||
|
records = read_data_records(str(dest))
|
||||||
|
assert len(records) == 3
|
||||||
|
assert records[0].values == [101.0, 201.0]
|
||||||
|
assert records[-1].values == [103.0, 203.0]
|
||||||
|
|
||||||
|
# header survives → still parses as a wafer CSV
|
||||||
|
data, _ = ZWaferParser().parse(str(dest))
|
||||||
|
assert data is not None
|
||||||
|
assert [s.label for s in data.sensors] == ["1", "2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_full_range(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=4)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
|
||||||
|
assert len(read_data_records(str(dest))) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_out_of_range(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=3)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
|
||||||
|
assert not dest.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_invalid_range(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=3)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_official_csv(tmp_path):
|
||||||
|
# official format: row 0 = sensor names, rows 1+ = values only
|
||||||
|
src = tmp_path / "A00001-x.csv"
|
||||||
|
src.write_text(
|
||||||
|
"Sensor1,Sensor2\n"
|
||||||
|
"10.0,20.0\n"
|
||||||
|
"11.0,21.0\n"
|
||||||
|
"12.0,22.0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
dest = tmp_path / "A00001-x_seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
|
||||||
|
lines = dest.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert lines[0] == "Sensor1,Sensor2"
|
||||||
|
assert lines[1] == "11.0,21.0"
|
||||||
|
assert lines[2] == "12.0,22.0"
|
||||||
|
assert len(lines) == 3
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
|
_convert_debug_temp,
|
||||||
_convert_hex_to_temp,
|
_convert_hex_to_temp,
|
||||||
|
convert_to_debug_temperatures,
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
csv_column_count,
|
csv_column_count,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
|
save_debug_csv,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,3 +324,97 @@ class TestSaveToCsv:
|
|||||||
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
|
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
|
||||||
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
|
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
|
||||||
assert data_row[243] == "243.0", "last column value must be preserved"
|
assert data_row[243] == "243.0", "last column value must be preserved"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug temperature conversion ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertDebugTemp:
|
||||||
|
"""Spot-check known hex → debug temperature values.
|
||||||
|
|
||||||
|
Reference values derived from C#'s ConvertBinaryArrayToDebugTemp
|
||||||
|
(bits 4-14 scaled by 2**(i-8), sign at bit 0 — no family branching).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_zero_hex_gives_zero(self):
|
||||||
|
assert _convert_debug_temp("0000") == 0.0
|
||||||
|
|
||||||
|
def test_bit8_gives_one(self):
|
||||||
|
# bit index 8 set (value 0x0080) → contributes 2**(8-8) = 1.0
|
||||||
|
assert _convert_debug_temp("0080") == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_sign_bit_negates(self):
|
||||||
|
# sign bit (bit 0) + bit 8 → -1.0
|
||||||
|
assert _convert_debug_temp("8080") == pytest.approx(-1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_no_family_branching(self):
|
||||||
|
# Debug conversion takes no family code — same result regardless
|
||||||
|
assert _convert_debug_temp("0080") == _convert_debug_temp("0080")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertToDebugTemperatures:
|
||||||
|
def test_returns_same_shape(self):
|
||||||
|
hex_data = [["0080", "0000"], ["8080", "0000"]]
|
||||||
|
result = convert_to_debug_temperatures(hex_data)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert len(result[0]) == 2
|
||||||
|
|
||||||
|
def test_values_are_strings(self):
|
||||||
|
result = convert_to_debug_temperatures([["0080"]])
|
||||||
|
assert isinstance(result[0][0], str)
|
||||||
|
|
||||||
|
def test_converts_known_value(self):
|
||||||
|
result = convert_to_debug_temperatures([["0080"]])
|
||||||
|
assert float(result[0][0]) == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug CSV export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveDebugCsv:
|
||||||
|
def test_creates_file_with_fixed_name(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0"]], [["1.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
assert result.endswith("debug_info.csv")
|
||||||
|
|
||||||
|
def test_header_alternates_sensor_and_debug(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
header = open(result).readline().strip()
|
||||||
|
assert header == "Sensor1,Debug1,Sensor2,Debug2"
|
||||||
|
|
||||||
|
def test_data_row_alternates_values(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
lines = open(result).readlines()
|
||||||
|
assert lines[1].strip() == "25.0,1.0,24.5,2.0"
|
||||||
|
|
||||||
|
def test_truncates_to_shorter_row_count(self, tmp_path):
|
||||||
|
temp_data = [["25.0"], ["24.0"], ["23.0"]]
|
||||||
|
debug_data = [["1.0"], ["2.0"]]
|
||||||
|
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
lines = open(result).readlines()
|
||||||
|
assert len(lines) == 3 # 1 header + 2 data rows (shorter of the two)
|
||||||
|
|
||||||
|
def test_truncates_to_shorter_column_count(self, tmp_path):
|
||||||
|
temp_data = [["25.0", "24.0", "23.0"]]
|
||||||
|
debug_data = [["1.0", "2.0"]]
|
||||||
|
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
header = open(result).readline().strip()
|
||||||
|
assert header == "Sensor1,Debug1,Sensor2,Debug2"
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_temp_data(self, tmp_path):
|
||||||
|
assert save_debug_csv([], [["1.0"]], str(tmp_path)) is None
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_debug_data(self, tmp_path):
|
||||||
|
assert save_debug_csv([["25.0"]], [], str(tmp_path)) is None
|
||||||
|
|
||||||
|
def test_creates_output_dir_if_missing(self, tmp_path):
|
||||||
|
nested = str(tmp_path / "a" / "b")
|
||||||
|
result = save_debug_csv([["25.0"]], [["1.0"]], nested)
|
||||||
|
assert result is not None
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
assert Path(result).exists()
|
||||||
|
|||||||
@@ -133,6 +133,64 @@ def test_read_debug_handler(controller):
|
|||||||
assert controller.connectionStatus == "Connected"
|
assert controller.connectionStatus == "Connected"
|
||||||
assert not controller.operationInProgress
|
assert not controller.operationInProgress
|
||||||
|
|
||||||
|
def _make_p_family_bytes(value: int = 0x0100) -> bytes:
|
||||||
|
"""Synthetic single-block P-family binary: 244 valid words + 12 overhead."""
|
||||||
|
data = bytearray()
|
||||||
|
for i in range(256):
|
||||||
|
word = value if i < 244 else 0
|
||||||
|
data += word.to_bytes(2, byteorder="little")
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_worker_decodes_and_saves_csv(controller, tmp_path):
|
||||||
|
controller._last_wafer_info = {"familyCode": "P"}
|
||||||
|
controller._save_data_dir = str(tmp_path)
|
||||||
|
controller._service.read_wafer_data = MagicMock(
|
||||||
|
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
controller._debugFinished.connect(captured.update)
|
||||||
|
|
||||||
|
controller._debug_worker("COM1")
|
||||||
|
|
||||||
|
assert captured.get("success") is True
|
||||||
|
assert captured.get("sensor_bytes") == 512
|
||||||
|
assert captured.get("debug_bytes") == 512
|
||||||
|
assert "csv_path" in captured
|
||||||
|
from pathlib import Path
|
||||||
|
assert Path(captured["csv_path"]).exists()
|
||||||
|
assert Path(captured["csv_path"]).name == "debug_info.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_worker_reports_error_when_csv_save_fails(controller, tmp_path):
|
||||||
|
controller._last_wafer_info = {"familyCode": "P"}
|
||||||
|
# Point save dir somewhere save_debug_csv cannot write to, so it returns None.
|
||||||
|
controller._save_data_dir = str(tmp_path / "debug_info.csv") # a file, not a dir
|
||||||
|
(tmp_path / "debug_info.csv").write_text("occupied")
|
||||||
|
controller._service.read_wafer_data = MagicMock(
|
||||||
|
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
controller._debugFinished.connect(captured.update)
|
||||||
|
|
||||||
|
controller._debug_worker("COM1")
|
||||||
|
|
||||||
|
assert captured.get("success") is not True
|
||||||
|
assert "error" in captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_handler_logs_csv_path(controller):
|
||||||
|
controller._handle_debug_finished({
|
||||||
|
"success": True,
|
||||||
|
"sensor_bytes": 512,
|
||||||
|
"debug_bytes": 512,
|
||||||
|
"csv_path": "/tmp/debug_info.csv",
|
||||||
|
})
|
||||||
|
assert "/tmp/debug_info.csv" in controller.activityLog
|
||||||
|
|
||||||
|
|
||||||
def test_parse_and_save_data_and_get_chart_data(controller):
|
def test_parse_and_save_data_and_get_chart_data(controller):
|
||||||
res = controller.getChartData()
|
res = controller.getChartData()
|
||||||
assert res == {"success": False}
|
assert res == {"success": False}
|
||||||
@@ -233,3 +291,12 @@ def test_device_service_port_caching(controller):
|
|||||||
service.enumerate_ports()
|
service.enumerate_ports()
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dir_created_under_save_data_dir(controller):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
result = controller.exportDir()
|
||||||
|
|
||||||
|
assert result == str(Path(controller.saveDataDir) / "Export")
|
||||||
|
assert Path(result).is_dir()
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
|
||||||
|
# docs/pending/license-gating-plan.md). Two cases:
|
||||||
|
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
|
||||||
|
# emits readResult {"error": ...} and spawns no thread.
|
||||||
|
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
|
||||||
|
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
|
||||||
|
# reorders the busy/license checks or renames the "serialNumber" key.
|
||||||
|
# Depends on P1.2 + P2.1 being implemented.
|
||||||
@@ -28,3 +28,16 @@ def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
|||||||
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
||||||
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
||||||
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
|
||||||
|
"""setCurrentDirectory (no native dialog) is how the Status tab's save-dir
|
||||||
|
picker keeps the Source panel pointed at the same folder."""
|
||||||
|
_write_csv(tmp_path / "P00003-20260706_143500.csv")
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser.setCurrentDirectory(str(tmp_path))
|
||||||
|
|
||||||
|
assert browser.currentDirectory == str(tmp_path)
|
||||||
|
assert len(browser.files) == 1
|
||||||
|
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer
|
from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
|
||||||
|
|
||||||
|
|
||||||
def frames(n):
|
def frames(n):
|
||||||
@@ -29,3 +29,24 @@ def test_at_end():
|
|||||||
assert not p.at_end
|
assert not p.at_end
|
||||||
p.seek(1)
|
p.seek(1)
|
||||||
assert p.at_end
|
assert p.at_end
|
||||||
|
|
||||||
|
def test_frames_property_exposes_loaded_frames():
|
||||||
|
p = FramePlayer(); p.load(frames(3))
|
||||||
|
assert p.frames == tuple(frames(3))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- all_sensor_series (Graph tab whole-run assembly) ----
|
||||||
|
|
||||||
|
def test_all_sensor_series_empty_frames():
|
||||||
|
assert all_sensor_series([]) == ([], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sensor_series_names_and_columns():
|
||||||
|
fs = [
|
||||||
|
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
|
||||||
|
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
|
||||||
|
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
|
||||||
|
]
|
||||||
|
names, series = all_sensor_series(fs)
|
||||||
|
assert names == ["Sensor 1", "Sensor 2"]
|
||||||
|
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import math
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
from pygui.backend.models.frame_stats import (
|
||||||
|
EdgeCenterStats,
|
||||||
|
Stats,
|
||||||
|
compute_edge_center,
|
||||||
|
compute_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_basic_stat():
|
def test_basic_stat():
|
||||||
@@ -28,3 +33,43 @@ def test_ignores_nan():
|
|||||||
assert s.avg == pytest.approx(150.0)
|
assert s.avg == pytest.approx(150.0)
|
||||||
assert s.sigma == pytest.approx(1.0)
|
assert s.sigma == pytest.approx(1.0)
|
||||||
assert s.three_sigma == pytest.approx(3.0)
|
assert s.three_sigma == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center delta (ADR-0002) ----
|
||||||
|
|
||||||
|
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_min_max_pairs():
|
||||||
|
# e0 e1 e2 e3 c4 c5
|
||||||
|
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||||
|
ec = compute_edge_center(values, PAIRS, excluded=set())
|
||||||
|
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
|
||||||
|
assert ec == EdgeCenterStats(
|
||||||
|
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
|
||||||
|
min_delta=pytest.approx(0.1),
|
||||||
|
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
|
||||||
|
max_delta=pytest.approx(1.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_skips_excluded_sensors():
|
||||||
|
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||||
|
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
|
||||||
|
assert ec is not None
|
||||||
|
assert ec.max_delta == pytest.approx(1.0)
|
||||||
|
# excluding a center sensor kills all its pairs
|
||||||
|
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_skips_nan_and_out_of_range():
|
||||||
|
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
|
||||||
|
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
|
||||||
|
assert ec is not None
|
||||||
|
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
|
||||||
|
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_none_when_no_pairs():
|
||||||
|
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
|
||||||
|
assert compute_edge_center([], PAIRS, excluded=set()) is None
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_initially():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
assert item.seriesData == []
|
||||||
|
assert item.sensorNames == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_setter_stores_series():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_auto_ranges_y_with_padding():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
|
||||||
|
# min=100, max=200, range=100, 10% pad => [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_empty_keeps_default_range():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = []
|
||||||
|
|
||||||
|
assert item._min_y == 0.0
|
||||||
|
assert item._max_y == 150.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_names_setter_stores_names():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_numeric_values_are_skipped_not_crashing():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, "bad", 151.0]]
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(149.9)
|
||||||
|
assert item._max_y == pytest.approx(151.1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- viewport_stats (replay chart min/max/avg readouts) ----
|
||||||
|
# Independently hand-computed against PopupChartForm.cs's recalc_stats
|
||||||
|
# semantics: aggregate across every sensor's points in [start, end], plus
|
||||||
|
# which sensor achieved each extreme.
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_full_range_aggregate():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
names = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(147.0)
|
||||||
|
assert vmax == pytest.approx(152.0)
|
||||||
|
assert avg == pytest.approx(149.25)
|
||||||
|
assert min_sensor == "Sensor 2"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_restricted_to_subrange():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
names = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(147.5)
|
||||||
|
assert vmax == pytest.approx(151.0)
|
||||||
|
assert avg == pytest.approx(149.125)
|
||||||
|
assert min_sensor == "Sensor 2"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_empty_series_returns_zeros():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_skips_non_numeric():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, "bad", 151.0]]
|
||||||
|
names = ["Sensor 1"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(150.0)
|
||||||
|
assert vmax == pytest.approx(151.0)
|
||||||
|
assert avg == pytest.approx(150.5)
|
||||||
|
assert min_sensor == "Sensor 1"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GraphQuickItem viewport properties (replay chart) ----
|
||||||
|
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
|
||||||
|
# tab (which never sets these) is unaffected — see docs/adr/0004.
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_defaults_to_whole_range_and_markers_off():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
assert item.showMinMaxMarkers is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_stats_reflect_default_full_range_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|
||||||
|
assert item.viewMin == pytest.approx(147.0)
|
||||||
|
assert item.viewMax == pytest.approx(152.0)
|
||||||
|
assert item.viewAvg == pytest.approx(149.25)
|
||||||
|
assert item.viewMinSensor == "Sensor 2"
|
||||||
|
assert item.viewMaxSensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_stats_restrict_to_narrowed_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|
||||||
|
item.viewStartIndex = 0
|
||||||
|
item.viewEndIndex = 1
|
||||||
|
|
||||||
|
assert item.viewMin == pytest.approx(147.5)
|
||||||
|
assert item.viewMax == pytest.approx(151.0)
|
||||||
|
assert item.viewAvg == pytest.approx(149.125)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_range_y_restricted_to_narrowed_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
|
||||||
|
item.viewStartIndex = 0
|
||||||
|
item.viewEndIndex = 0
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(145.0)
|
||||||
|
assert item._max_y == pytest.approx(205.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
|
||||||
|
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
|
||||||
|
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
|
||||||
|
|
||||||
|
|
||||||
|
def _eleven_point_item():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[float(i) for i in range(11)]]
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_in_centers_on_fraction():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
|
||||||
|
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
|
||||||
|
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
|
||||||
|
item.zoomAtFraction(0.5, 0.4)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 3
|
||||||
|
assert item.viewEndIndex == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_out_clamps_to_data_bounds():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 3
|
||||||
|
item.viewEndIndex = 7
|
||||||
|
|
||||||
|
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
|
||||||
|
# new_width=12 exceeds the data's max width of 10, so it clamps to the
|
||||||
|
# full range (0, 10).
|
||||||
|
item.zoomAtFraction(0.5, 3.0)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_shifts_viewport_by_fraction_of_width():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 4
|
||||||
|
item.viewEndIndex = 8
|
||||||
|
|
||||||
|
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
|
||||||
|
item.panByFraction(0.5)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 6
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_clamps_at_right_edge_preserving_width():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 4
|
||||||
|
item.viewEndIndex = 8
|
||||||
|
|
||||||
|
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
|
||||||
|
# out of bounds (max index 10), so the whole window shifts back by 2 to
|
||||||
|
# stay in bounds while keeping width=4: (6,10).
|
||||||
|
item.panByFraction(1.0)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 6
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_zoom_restores_full_range_sentinel():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 3
|
||||||
|
item.viewEndIndex = 7
|
||||||
|
|
||||||
|
item.resetZoom()
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_noop_when_fewer_than_two_points():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[42.0]]
|
||||||
|
|
||||||
|
item.zoomAtFraction(0.5, 0.5)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
|
||||||
|
"""Regression: zooming near the tail of a long run, then loading a new
|
||||||
|
(shorter) file, left the stale viewport pointing past the new data --
|
||||||
|
every per-series slice became empty and the chart went blank with a
|
||||||
|
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
|
||||||
|
the viewport to the full range (PopupChartForm.cs's SetData() parity).
|
||||||
|
"""
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[float(i) for i in range(20)]]
|
||||||
|
item.viewStartIndex = 15
|
||||||
|
item.viewEndIndex = 19
|
||||||
|
|
||||||
|
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
assert item.viewMin == pytest.approx(100.0)
|
||||||
|
assert item.viewMax == pytest.approx(104.0)
|
||||||
|
assert item.viewAvg == pytest.approx(102.0)
|
||||||
@@ -66,6 +66,51 @@ class TestUpdateTrend:
|
|||||||
gv.updateTrend("[1, 2, 3]")
|
gv.updateTrend("[1, 2, 3]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateChart:
|
||||||
|
def test_plots_one_series_per_sensor(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.5, 148.4, 148.6]]
|
||||||
|
gv.updateChart("Sensor 1,Sensor 2", json.dumps(series))
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert gv._plot_widget.plot.call_count == 2
|
||||||
|
assert gv._sensor_names == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
def test_rejects_invalid_json(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateChart("Sensor 1", "not json")
|
||||||
|
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_empty_series_clears_without_plotting(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
gv._plot_widget = _make_plot_widget()
|
||||||
|
|
||||||
|
gv.updateChart("", "[]")
|
||||||
|
|
||||||
|
assert gv._plot_widget.clear.called
|
||||||
|
assert not gv._plot_widget.plot.called
|
||||||
|
|
||||||
|
def test_no_plot_widget(self):
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|
||||||
|
gv = GraphView()
|
||||||
|
assert gv._plot_widget is None
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
gv.updateChart("Sensor 1", json.dumps([[1.0, 2.0]]))
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateComparison:
|
class TestUpdateComparison:
|
||||||
def test_parses_valid_json(self):
|
def test_parses_valid_json(self):
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for src/pygui/backend/license/ (manager + model)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
from pygui.backend.license.license_manager import (
|
||||||
|
LICENSE_KEY,
|
||||||
|
parse_license_blob,
|
||||||
|
read_license_files,
|
||||||
|
split_data_iv,
|
||||||
|
)
|
||||||
|
from pygui.backend.license.license_model import LicenseModel
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Fixture helpers =====
|
||||||
|
def _encrypt_aes(plaintext: bytes, key: bytes, iv: bytes) -> bytes:
|
||||||
|
"""AES-128-CBC + PKCS7, mirroring what the C# license generator does."""
|
||||||
|
pad_len = 16 - (len(plaintext) % 16)
|
||||||
|
padded = plaintext + bytes([pad_len]) * pad_len
|
||||||
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||||||
|
enc = cipher.encryptor()
|
||||||
|
return enc.update(padded) + enc.finalize()
|
||||||
|
|
||||||
|
|
||||||
|
def make_license_blob(
|
||||||
|
serial: str = "A00001",
|
||||||
|
mfg_hex: str = "0001E240",
|
||||||
|
level: str = "01",
|
||||||
|
lic_hex: str = "0001E24F",
|
||||||
|
) -> bytes:
|
||||||
|
"""Build a C#-format license blob: ciphertext || iv, fixed-offset text."""
|
||||||
|
text = f"{serial} {mfg_hex} {level} {lic_hex}"
|
||||||
|
iv = bytes(range(16))
|
||||||
|
return _encrypt_aes(text.encode("utf-8"), LICENSE_KEY, iv) + iv
|
||||||
|
|
||||||
|
|
||||||
|
def write_license(directory, serial="A00001", level="01", **kw) -> str:
|
||||||
|
path = directory / f"{serial}-{level}.bin"
|
||||||
|
path.write_bytes(make_license_blob(serial=serial, level=level, **kw))
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Manager: blob parsing =====
|
||||||
|
def test_parse_valid_blob():
|
||||||
|
lic = parse_license_blob(make_license_blob())
|
||||||
|
assert lic is not None
|
||||||
|
assert lic.serial == "A00001"
|
||||||
|
assert lic.level == "01"
|
||||||
|
assert lic.level_int == 1
|
||||||
|
assert lic.mfg_date_hex == "0001E240"
|
||||||
|
assert lic.mfg_date_decimal == "123456"
|
||||||
|
assert lic.lic_date_decimal == str(int("0001E24F", 16))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_blob_too_short():
|
||||||
|
assert parse_license_blob(b"short") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_blob_garbage():
|
||||||
|
assert parse_license_blob(os.urandom(64)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_blob_short_text():
|
||||||
|
iv = bytes(16)
|
||||||
|
blob = _encrypt_aes(b"A00001 tiny", LICENSE_KEY, iv) + iv
|
||||||
|
assert parse_license_blob(blob) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_data_iv():
|
||||||
|
data, iv = split_data_iv(bytes(48))
|
||||||
|
assert len(data) == 32
|
||||||
|
assert len(iv) == 16
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Manager: directory scan =====
|
||||||
|
def test_read_license_files_valid(tmp_path):
|
||||||
|
write_license(tmp_path)
|
||||||
|
licenses = read_license_files(tmp_path)
|
||||||
|
assert len(licenses) == 1
|
||||||
|
assert licenses[0].serial == "A00001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_license_files_missing_dir(tmp_path):
|
||||||
|
assert read_license_files(tmp_path / "nope") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_license_files_bad_filename_skipped(tmp_path):
|
||||||
|
(tmp_path / "notalicense.bin").write_bytes(make_license_blob())
|
||||||
|
assert read_license_files(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_license_files_name_payload_mismatch(tmp_path):
|
||||||
|
# payload says A00001-01 but file claims B00002-03 → tamper, reject
|
||||||
|
(tmp_path / "B00002-03.bin").write_bytes(make_license_blob())
|
||||||
|
assert read_license_files(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_license_files_corrupt_skipped(tmp_path):
|
||||||
|
(tmp_path / "A00001-01.bin").write_bytes(os.urandom(64))
|
||||||
|
assert read_license_files(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Model =====
|
||||||
|
def test_model_lists_licenses(tmp_path, qapp):
|
||||||
|
(tmp_path / "licenses").mkdir()
|
||||||
|
write_license(tmp_path / "licenses")
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
rows = model.licenses
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["serial"] == "A00001"
|
||||||
|
assert rows[0]["level"] == "01"
|
||||||
|
assert rows[0]["mfgDate"] == "123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_load_license_file(tmp_path, qapp):
|
||||||
|
src_dir = tmp_path / "downloads"
|
||||||
|
src_dir.mkdir()
|
||||||
|
src = write_license(src_dir, serial="B00002", level="00")
|
||||||
|
model = LicenseModel(str(tmp_path / "appdata"))
|
||||||
|
assert model.loadLicenseFile(src) is True
|
||||||
|
assert len(model.licenses) == 1
|
||||||
|
assert model.licenses[0]["serial"] == "B00002"
|
||||||
|
# copy landed in <data_dir>/licenses
|
||||||
|
assert (tmp_path / "appdata" / "licenses" / "B00002-00.bin").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_load_license_file_url(tmp_path, qapp):
|
||||||
|
src_dir = tmp_path / "dl"
|
||||||
|
src_dir.mkdir()
|
||||||
|
src = write_license(src_dir)
|
||||||
|
model = LicenseModel(str(tmp_path / "appdata"))
|
||||||
|
assert model.loadLicenseFile("file://" + src) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_load_rejects_invalid(tmp_path, qapp):
|
||||||
|
src_dir = tmp_path / "dl"
|
||||||
|
src_dir.mkdir()
|
||||||
|
bad = src_dir / "A00001-01.bin"
|
||||||
|
bad.write_bytes(os.urandom(64))
|
||||||
|
model = LicenseModel(str(tmp_path / "appdata"))
|
||||||
|
assert model.loadLicenseFile(str(bad)) is False
|
||||||
|
assert model.licenses == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_load_rejects_missing(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.loadLicenseFile(str(tmp_path / "ghost.bin")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_level_for_wafer(tmp_path, qapp):
|
||||||
|
(tmp_path / "licenses").mkdir()
|
||||||
|
write_license(tmp_path / "licenses", serial="A00001", level="02")
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.levelForWafer("A00001") == "02"
|
||||||
|
assert model.levelForWafer("Z99999") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Replay trial =====
|
||||||
|
def test_replay_state_none_then_temporary(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.replayLicenseState() == "none"
|
||||||
|
assert model.startReplayTrial() is True
|
||||||
|
assert model.replayLicenseState() == "temporary"
|
||||||
|
assert model.replayTrialExpired() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_state_permanent_with_level1(tmp_path, qapp):
|
||||||
|
(tmp_path / "licenses").mkdir()
|
||||||
|
write_license(tmp_path / "licenses", level="01")
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.replayLicenseState() == "permanent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_level0_not_permanent(tmp_path, qapp):
|
||||||
|
(tmp_path / "licenses").mkdir()
|
||||||
|
write_license(tmp_path / "licenses", level="00")
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.replayLicenseState() == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_trial_expiry(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
model.startReplayTrial()
|
||||||
|
marker = tmp_path / "replay.lic"
|
||||||
|
old = time.time() - 31 * 86400
|
||||||
|
os.utime(marker, (old, old))
|
||||||
|
assert model.replayTrialExpired() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_trial_expired_without_marker(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.replayTrialExpired() is True
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_settings():
|
||||||
|
return LocalSettings()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model(qapp, tmp_path, shared_settings):
|
||||||
|
return LocalSettingsModel(shared_settings, tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_mutates_the_shared_instance_in_place(model, shared_settings):
|
||||||
|
# This is the bug candidate 1 fixes: DeviceController holds the same
|
||||||
|
# LocalSettings object, so a save here must be visible to it without
|
||||||
|
# a reload — no throwaway copy, no stale read until restart.
|
||||||
|
model.chamberId = "7"
|
||||||
|
model.waferReadTimeout = 45000
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
assert shared_settings.chamber_id == "7"
|
||||||
|
assert shared_settings.wafer_read_timeout == 45000
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_persists_to_disk_and_reload_round_trips(tmp_path, qapp, shared_settings):
|
||||||
|
model = LocalSettingsModel(shared_settings, tmp_path)
|
||||||
|
model.chamberId = "9"
|
||||||
|
model.waferRetries = 3
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
reloaded_settings = LocalSettings.read_settings(str(tmp_path))
|
||||||
|
other_model = LocalSettingsModel(reloaded_settings, tmp_path)
|
||||||
|
other_model.loadSettings()
|
||||||
|
|
||||||
|
assert other_model.chamberId == "9"
|
||||||
|
assert other_model.waferRetries == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_restores_last_saved_values(model):
|
||||||
|
model.chamberId = "2"
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
model.chamberId = "unsaved-edit"
|
||||||
|
assert model.isDirty is True
|
||||||
|
|
||||||
|
model.revertChanges()
|
||||||
|
assert model.chamberId == "2"
|
||||||
|
assert model.isDirty is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_chamber_id_blocks_save(model, shared_settings):
|
||||||
|
shared_settings.chamber_id = "before"
|
||||||
|
model.chamberId = ""
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
assert model.saveStatus.startswith("error:")
|
||||||
|
assert shared_settings.chamber_id == "before" # unsaved, shared instance untouched
|
||||||
+174
-119
@@ -1,8 +1,11 @@
|
|||||||
|
import json
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pygui.backend.controllers.session_controller as session_controller_module
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -50,44 +53,56 @@ def test_pause_stop_timer(controller):
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
assert controller.frameIndex == 0
|
assert controller.frameIndex == 0
|
||||||
|
|
||||||
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
|
def test_graph_data_empty_before_load(controller):
|
||||||
"""A recording with a valid header but zero data rows (e.g. Record was
|
assert controller.graphSensorNames == ""
|
||||||
started/stopped without any live frames arriving) must surface a clear
|
assert controller.graphSeriesJson == "[]"
|
||||||
loadFileError instead of silently doing nothing."""
|
|
||||||
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
|
|
||||||
empty_recording.write_text(
|
|
||||||
"Wafer ID=A00001\n"
|
|
||||||
"Acquisition Date=07/06/2026\n"
|
|
||||||
"Label,1,2\n"
|
|
||||||
"X (mm),0.0,10.0\n"
|
|
||||||
"Y (mm),10.0,-10.0\n"
|
|
||||||
"data\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_slot = MagicMock()
|
def test_graph_data_after_load(controller):
|
||||||
controller.loadFileError.connect(mock_slot)
|
|
||||||
controller.loadFile(str(empty_recording))
|
|
||||||
|
|
||||||
assert mock_slot.call_count == 1
|
|
||||||
assert "no recorded frames" in mock_slot.call_args[0][0]
|
|
||||||
assert controller.loadedFile == ""
|
|
||||||
|
|
||||||
def test_load_file_switches_to_review_mode(controller):
|
|
||||||
"""Loading a file for playback must always land in review mode, even if
|
|
||||||
the controller was left in live mode from a previous session — otherwise
|
|
||||||
a successful load can be invisible behind a stale Live toggle."""
|
|
||||||
controller._mode = "live"
|
|
||||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
assert controller.mode == "review"
|
assert controller.graphSensorNames == "Sensor 1,Sensor 2,Sensor 3"
|
||||||
|
series = json.loads(controller.graphSeriesJson)
|
||||||
|
assert series == [
|
||||||
|
[149.0, 149.2, 149.1],
|
||||||
|
[148.5, 148.4, 148.6],
|
||||||
|
[150.6, 150.7, 150.5],
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_graph_data_cleared_after_unload(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.unloadFile()
|
||||||
|
assert controller.graphSensorNames == ""
|
||||||
|
assert controller.graphSeriesJson == "[]"
|
||||||
|
|
||||||
|
def test_leaving_live_mode_blanks_wafer_map(controller):
|
||||||
|
"""Stopping a live session (tab switch → setMode) must not leave stale
|
||||||
|
sensor values on the wafer map — it should read as blank."""
|
||||||
|
frame_signal = MagicMock()
|
||||||
|
controller.frameUpdated.connect(frame_signal)
|
||||||
|
|
||||||
|
controller.setMode("live")
|
||||||
|
controller._on_live_frame(Frame(seq=1, time=0.0, values=[100.0] * 22))
|
||||||
|
assert controller.sensorValues, "sanity: live frame should populate the map"
|
||||||
|
|
||||||
|
controller.setMode("review")
|
||||||
|
assert controller.sensorValues == []
|
||||||
|
assert controller.stats == {}
|
||||||
|
assert frame_signal.called, "QML needs frameUpdated to repaint blank"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaving_live_mode_stops_stream(controller):
|
||||||
|
controller.setMode("live")
|
||||||
|
controller.setMode("review")
|
||||||
|
assert controller._reader is None
|
||||||
|
assert not controller._repaint_timer.isActive()
|
||||||
|
|
||||||
|
|
||||||
def test_compare_files_integration(controller):
|
def test_compare_files_integration(controller):
|
||||||
# Mock the comparisonResult signal
|
# Mock the comparisonResult signal
|
||||||
mock_slot = MagicMock()
|
mock_slot = MagicMock()
|
||||||
controller.comparisonResult.connect(mock_slot)
|
controller.comparisonResult.connect(mock_slot)
|
||||||
|
|
||||||
# Use sample_stream.csv for both arguments to test identical files compare
|
# Two runs with the same first 3 frames but run B has 2 extra trailing frames.
|
||||||
csv_path = "tests/fixtures/sample_stream.csv"
|
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
|
||||||
controller.compareFiles(csv_path, csv_path)
|
|
||||||
|
|
||||||
# Verify that the comparison signal is emitted with a success dict
|
# Verify that the comparison signal is emitted with a success dict
|
||||||
assert mock_slot.call_count == 1
|
assert mock_slot.call_count == 1
|
||||||
@@ -96,97 +111,24 @@ def test_compare_files_integration(controller):
|
|||||||
assert "distance" in args
|
assert "distance" in args
|
||||||
assert "series_a" in args
|
assert "series_a" in args
|
||||||
assert "series_b" in args
|
assert "series_b" in args
|
||||||
assert args["frame_offset"] == 0
|
# time_a/time_b expose per-frame timestamps in seconds for the mismatched run lengths.
|
||||||
|
assert args["time_a"] == [0.0, 0.5, 1.0]
|
||||||
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
|
assert args["time_b"] == [0.0, 0.5, 1.0, 1.5, 2.0]
|
||||||
"""compareFiles must score the whole run, not just an initial 100-frame window.
|
assert args["frame_count_a"] == 3
|
||||||
|
assert args["frame_count_b"] == 5
|
||||||
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
|
|
||||||
run's soak/cool phases were silently excluded from the DTW comparison.
|
|
||||||
"""
|
|
||||||
num_frames = 150
|
|
||||||
csv_path = tmp_path / "long_run.csv"
|
|
||||||
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
|
||||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
|
|
||||||
for i in range(num_frames):
|
|
||||||
t = i * 0.5
|
|
||||||
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
|
|
||||||
csv_path.write_text("\n".join(lines) + "\n")
|
|
||||||
|
|
||||||
|
def test_compare_files_same_file_is_gated(controller):
|
||||||
|
"""Comparing a run against itself should be rejected, not silently DTW'd."""
|
||||||
mock_slot = MagicMock()
|
mock_slot = MagicMock()
|
||||||
controller.comparisonResult.connect(mock_slot)
|
controller.comparisonResult.connect(mock_slot)
|
||||||
controller.compareFiles(str(csv_path), str(csv_path))
|
|
||||||
|
csv_path = "tests/fixtures/sample_stream.csv"
|
||||||
|
controller.compareFiles(csv_path, csv_path)
|
||||||
|
|
||||||
assert mock_slot.call_count == 1
|
assert mock_slot.call_count == 1
|
||||||
result = mock_slot.call_args[0][0]
|
args = mock_slot.call_args[0][0]
|
||||||
assert result["success"] is True
|
assert args["success"] is False
|
||||||
assert len(result["series_a"]) == num_frames
|
assert "error" in args
|
||||||
assert len(result["series_b"]) == num_frames
|
|
||||||
|
|
||||||
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
|
|
||||||
"""compareFiles must refuse to compare two different wafer families.
|
|
||||||
|
|
||||||
Otherwise the overlap view silently truncates to min(sensor_count) and
|
|
||||||
pairs up unrelated physical sensors between the two wafer types.
|
|
||||||
"""
|
|
||||||
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
|
||||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
|
||||||
"0.0,149.0,148.5,150.6"]
|
|
||||||
file_a = tmp_path / "A00055-20250326.csv"
|
|
||||||
file_b = tmp_path / "X00108-20250326.csv"
|
|
||||||
file_a.write_text("\n".join(header) + "\n")
|
|
||||||
file_b.write_text("\n".join(header) + "\n")
|
|
||||||
|
|
||||||
mock_slot = MagicMock()
|
|
||||||
controller.comparisonResult.connect(mock_slot)
|
|
||||||
controller.compareFiles(str(file_a), str(file_b))
|
|
||||||
|
|
||||||
assert mock_slot.call_count == 1
|
|
||||||
result = mock_slot.call_args[0][0]
|
|
||||||
assert result["success"] is False
|
|
||||||
assert "A" in result["error"] and "X" in result["error"]
|
|
||||||
|
|
||||||
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
|
|
||||||
"""A live_-prefixed recording must not be compared against a read-memory
|
|
||||||
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
|
|
||||||
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
|
|
||||||
recording-vs-read-mem error.
|
|
||||||
"""
|
|
||||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
|
||||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
|
||||||
"0.0,149.0,148.5,150.6"]
|
|
||||||
recording = tmp_path / "live_P0001_20260706_143022.csv"
|
|
||||||
read_mem = tmp_path / "P0001-20260706_143500.csv"
|
|
||||||
recording.write_text("\n".join(header) + "\n")
|
|
||||||
read_mem.write_text("\n".join(header) + "\n")
|
|
||||||
|
|
||||||
mock_slot = MagicMock()
|
|
||||||
controller.comparisonResult.connect(mock_slot)
|
|
||||||
controller.compareFiles(str(recording), str(read_mem))
|
|
||||||
|
|
||||||
assert mock_slot.call_count == 1
|
|
||||||
result = mock_slot.call_args[0][0]
|
|
||||||
assert result["success"] is False
|
|
||||||
assert result["error"] == "Cannot compare a recording file with a read-memory file"
|
|
||||||
|
|
||||||
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
|
|
||||||
"""Two live recordings of the same wafer family should compare fine —
|
|
||||||
the "live_" prefix must not be mistaken for the wafer type letter."""
|
|
||||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
|
||||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
|
||||||
"0.0,149.0,148.5,150.6"]
|
|
||||||
file_a = tmp_path / "live_P0001_20260706_143022.csv"
|
|
||||||
file_b = tmp_path / "live_P0002_20260706_150000.csv"
|
|
||||||
file_a.write_text("\n".join(header) + "\n")
|
|
||||||
file_b.write_text("\n".join(header) + "\n")
|
|
||||||
|
|
||||||
mock_slot = MagicMock()
|
|
||||||
controller.comparisonResult.connect(mock_slot)
|
|
||||||
controller.compareFiles(str(file_a), str(file_b))
|
|
||||||
|
|
||||||
assert mock_slot.call_count == 1
|
|
||||||
result = mock_slot.call_args[0][0]
|
|
||||||
assert result["success"] is True
|
|
||||||
|
|
||||||
def test_comparison_result_reaches_qml(qapp, controller):
|
def test_comparison_result_reaches_qml(qapp, controller):
|
||||||
"""The result dict must arrive in QML as a real JS object with fields.
|
"""The result dict must arrive in QML as a real JS object with fields.
|
||||||
@@ -216,13 +158,13 @@ Item {
|
|||||||
assert engine.rootObjects(), "probe QML failed to load"
|
assert engine.rootObjects(), "probe QML failed to load"
|
||||||
root = engine.rootObjects()[0]
|
root = engine.rootObjects()[0]
|
||||||
|
|
||||||
csv_path = "tests/fixtures/sample_stream.csv"
|
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
|
||||||
controller.compareFiles(csv_path, csv_path)
|
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
|
|
||||||
assert root.property("gotSuccess") is True
|
assert root.property("gotSuccess") is True
|
||||||
assert root.property("gotDistance") >= 0
|
assert root.property("gotDistance") >= 0
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -235,7 +177,6 @@ def test_recording_toggle(controller, tmp_path):
|
|||||||
content = open(csv_path).read()
|
content = open(csv_path).read()
|
||||||
assert "Wafer ID=SN123" in content
|
assert "Wafer ID=SN123" in content
|
||||||
|
|
||||||
|
|
||||||
def test_resync_count_default(controller):
|
def test_resync_count_default(controller):
|
||||||
# No active reader -> zero
|
# No active reader -> zero
|
||||||
assert controller.resyncCount == 0
|
assert controller.resyncCount == 0
|
||||||
@@ -257,3 +198,117 @@ def test_split_data_integration(controller):
|
|||||||
args = mock_slot.call_args[0][0]
|
args = mock_slot.call_args[0][0]
|
||||||
assert args["success"] is True
|
assert args["success"] is True
|
||||||
assert "segments" in args
|
assert "segments" in args
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_segment_after_split(qapp, controller, tmp_path):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
src = tmp_path / "sample.csv"
|
||||||
|
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
|
||||||
|
|
||||||
|
controller.splitData(str(src), 149.0)
|
||||||
|
|
||||||
|
exported = MagicMock()
|
||||||
|
controller.segmentExported.connect(exported)
|
||||||
|
controller.exportSegment(0)
|
||||||
|
|
||||||
|
assert exported.call_count == 1
|
||||||
|
result = exported.call_args[0][0]
|
||||||
|
assert result["success"] is True
|
||||||
|
out = result["path"]
|
||||||
|
assert out.startswith(str(tmp_path))
|
||||||
|
from pygui.backend.data.data_records import read_data_records
|
||||||
|
assert len(read_data_records(out)) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_segment_without_split(qapp, controller):
|
||||||
|
exported = MagicMock()
|
||||||
|
controller.segmentExported.connect(exported)
|
||||||
|
controller.exportSegment(0)
|
||||||
|
result = exported.call_args[0][0]
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_segment_bad_index(qapp, controller, tmp_path):
|
||||||
|
import shutil
|
||||||
|
src = tmp_path / "sample.csv"
|
||||||
|
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
|
||||||
|
controller.splitData(str(src), 149.0)
|
||||||
|
|
||||||
|
exported = MagicMock()
|
||||||
|
controller.segmentExported.connect(exported)
|
||||||
|
controller.exportSegment(99)
|
||||||
|
assert exported.call_args[0][0]["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_live_trend_sets_start_time_and_emits_reset(controller, monkeypatch):
|
||||||
|
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 100.0)
|
||||||
|
|
||||||
|
reset_seen = MagicMock()
|
||||||
|
controller.trendReset.connect(reset_seen)
|
||||||
|
controller._reset_live_trend()
|
||||||
|
|
||||||
|
assert controller._stream_start_time == 100.0
|
||||||
|
assert reset_seen.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkeypatch):
|
||||||
|
controller._stream_start_time = 100.0
|
||||||
|
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 102.5)
|
||||||
|
|
||||||
|
delta_seen = MagicMock()
|
||||||
|
controller.trendDelta.connect(delta_seen)
|
||||||
|
|
||||||
|
frame = Frame(seq=1, time=102.5, values=[10.0, 20.0, 30.0])
|
||||||
|
controller._on_live_frame(frame)
|
||||||
|
|
||||||
|
payload = json.loads(delta_seen.call_args[0][0])
|
||||||
|
assert len(payload) == 1
|
||||||
|
elapsed, avg = payload[0]
|
||||||
|
assert elapsed == pytest.approx(2.5)
|
||||||
|
assert avg == pytest.approx(20.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center stats exposure (ADR-0002) ----
|
||||||
|
|
||||||
|
def _write_f_family_csv(path):
|
||||||
|
"""22-sensor F-family stream: pair (0,18) has delta 1.0, all others 0."""
|
||||||
|
n = 22
|
||||||
|
values = [149.0] * n
|
||||||
|
values[0] = 150.0
|
||||||
|
lines = [
|
||||||
|
"Wafer ID=F12",
|
||||||
|
"Acquisition Date=03/26/2025",
|
||||||
|
"Label," + ",".join(str(i + 1) for i in range(n)),
|
||||||
|
"X (mm)," + ",".join(str(i) for i in range(n)),
|
||||||
|
"Y (mm)," + ",".join(str(i) for i in range(n)),
|
||||||
|
"data",
|
||||||
|
"0.0," + ",".join(f"{v:.1f}" for v in values),
|
||||||
|
]
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_include_edge_center_for_known_family(controller, tmp_path):
|
||||||
|
csv = tmp_path / "f_run.csv"
|
||||||
|
_write_f_family_csv(csv)
|
||||||
|
controller.loadFile(str(csv))
|
||||||
|
s = controller.stats
|
||||||
|
assert s["ecMaxDelta"] == pytest.approx(1.0)
|
||||||
|
assert s["ecMaxEdgeIndex"] == 1 and s["ecMaxCenterIndex"] == 19 # 1-origin
|
||||||
|
assert s["ecMaxEdgeValue"] == pytest.approx(150.0)
|
||||||
|
assert s["ecMaxCenterValue"] == pytest.approx(149.0)
|
||||||
|
assert s["ecMinDelta"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_edge_center_skips_replaced_sensor(controller, tmp_path):
|
||||||
|
csv = tmp_path / "f_run.csv"
|
||||||
|
_write_f_family_csv(csv)
|
||||||
|
controller.loadFile(str(csv))
|
||||||
|
controller.replaceSensor(0, 149.0) # replaced: pair (0,18) must be skipped
|
||||||
|
assert controller.stats["ecMaxDelta"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_edge_center_absent_for_unknown_or_short_family(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv") # A12, 3 sensors
|
||||||
|
assert "ecMaxDelta" not in controller.stats
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/trend_chart_item.py."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
|
||||||
|
# descriptors work properly.
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_initially():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
assert item.hasData is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_adds_point_and_sets_has_data():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[1.5, 42.0]]))
|
||||||
|
|
||||||
|
assert item.hasData is True
|
||||||
|
assert item._points == [(1.5, 42.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_accumulates_across_calls():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[0.0, 10.0]]))
|
||||||
|
item.appendDelta(json.dumps([[1.0, 20.0]]))
|
||||||
|
|
||||||
|
assert item._points == [(0.0, 10.0), (1.0, 20.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_prunes_points_older_than_60s_window():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[0.0, 10.0]]))
|
||||||
|
item.appendDelta(json.dumps([[70.0, 20.0]]))
|
||||||
|
|
||||||
|
assert item._points == [(70.0, 20.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_ignores_malformed_json():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta("not json")
|
||||||
|
|
||||||
|
assert item._points == []
|
||||||
|
assert item.hasData is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_delta_skips_repaint_when_no_points_added():
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[1.0, 10.0]]))
|
||||||
|
item.update = MagicMock()
|
||||||
|
|
||||||
|
item.appendDelta("not json")
|
||||||
|
|
||||||
|
assert item.update.called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_trend_resets_points_and_has_data():
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
|
|
||||||
|
item = TrendChartItem()
|
||||||
|
item.appendDelta(json.dumps([[1.0, 10.0]]))
|
||||||
|
assert item.hasData is True
|
||||||
|
|
||||||
|
item.clearTrend()
|
||||||
|
|
||||||
|
assert item._points == []
|
||||||
|
assert item.hasData is False
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
from pygui.backend.wafer.wafer_layouts import (
|
||||||
|
available_families,
|
||||||
|
ec_pairs_for_wafer_id,
|
||||||
|
load_layout,
|
||||||
|
)
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
@@ -20,3 +24,27 @@ def test_loads_sensors_in_mm():
|
|||||||
def test_unknown_family_raises():
|
def test_unknown_family_raises():
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
load_layout("nope")
|
load_layout("nope")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center pair tables (ADR-0002) ----
|
||||||
|
|
||||||
|
def test_ec_pairs_aep_match_csharp_table():
|
||||||
|
pairs = ec_pairs_for_wafer_id("A00123")
|
||||||
|
assert pairs[0] == (0, 40)
|
||||||
|
assert pairs[-1] == (15, 47)
|
||||||
|
assert len(pairs) == 16
|
||||||
|
assert ec_pairs_for_wafer_id("E1") == pairs
|
||||||
|
assert ec_pairs_for_wafer_id("P1") == pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_ec_pairs_per_family_classes():
|
||||||
|
assert len(ec_pairs_for_wafer_id("B00108")) == 12
|
||||||
|
assert ec_pairs_for_wafer_id("C1") == ec_pairs_for_wafer_id("D1")
|
||||||
|
assert ec_pairs_for_wafer_id("F1")[0] == (0, 18)
|
||||||
|
assert ec_pairs_for_wafer_id("X1")[0] == (5, 76)
|
||||||
|
assert ec_pairs_for_wafer_id("Z1")[0] == (49, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ec_pairs_unknown_family_empty():
|
||||||
|
assert ec_pairs_for_wafer_id("Q999") == ()
|
||||||
|
assert ec_pairs_for_wafer_id("") == ()
|
||||||
|
|||||||
+238
-31
@@ -8,20 +8,6 @@ import pytest
|
|||||||
pytestmark = pytest.mark.usefixtures("qapp")
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_valid_path():
|
|
||||||
"""export_image saves a PNG when grabToImage succeeds."""
|
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
|
||||||
|
|
||||||
item = WaferMapItem()
|
|
||||||
|
|
||||||
mock_image = MagicMock()
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.image.return_value = mock_image
|
|
||||||
item.grabToImage = MagicMock(return_value=mock_result)
|
|
||||||
|
|
||||||
assert item.export_image("/tmp/test_wafer.png") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_empty_path():
|
def test_export_image_empty_path():
|
||||||
"""export_image returns False for empty/None path."""
|
"""export_image returns False for empty/None path."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
@@ -31,29 +17,23 @@ def test_export_image_empty_path():
|
|||||||
assert item.export_image(None) is False
|
assert item.export_image(None) is False
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_grab_fails():
|
def test_export_image_zero_size_item_fails():
|
||||||
"""export_image returns False when grabToImage raises."""
|
"""export_image returns False when the item has no geometry to render."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
|
|
||||||
|
|
||||||
assert item.export_image("/tmp/test_wafer.png") is False
|
assert item.export_image("/tmp/test_wafer.png") is False
|
||||||
|
|
||||||
|
|
||||||
def test_export_image_save_fails():
|
def test_export_image_unwritable_path_fails(tmp_path):
|
||||||
"""export_image returns False when image.save raises."""
|
"""export_image returns False when the target directory doesn't exist."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
mock_image = MagicMock()
|
|
||||||
mock_image.save.side_effect = IOError("disk full")
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.image.return_value = mock_image
|
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item.grabToImage = MagicMock(return_value=mock_result)
|
item.setWidth(100)
|
||||||
|
item.setHeight(100)
|
||||||
assert item.export_image("/tmp/test_wafer.png") is False
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
|
||||||
|
|
||||||
|
|
||||||
def test_thickness_properties():
|
def test_thickness_properties():
|
||||||
@@ -67,16 +47,89 @@ def test_thickness_properties():
|
|||||||
|
|
||||||
|
|
||||||
def test_thickness_setter_updates_data():
|
def test_thickness_setter_updates_data():
|
||||||
"""Setting thicknessData updates _thickness_data."""
|
"""Setting thicknessData stores [x, y, t2] triples."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
item = WaferMapItem()
|
item = WaferMapItem()
|
||||||
item._rebuild_thickness = MagicMock()
|
item._rebuild_thickness = MagicMock()
|
||||||
|
|
||||||
item.thicknessData = [1.0, 2.0, 3.0]
|
item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||||||
|
|
||||||
assert item._thickness_data == [1.0, 2.0, 3.0]
|
assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||||||
assert item._rebuild_thickness.called
|
assert item._rebuild_thickness.called
|
||||||
|
assert item.hasThickness is True
|
||||||
|
|
||||||
|
|
||||||
|
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
|
||||||
|
p = tmp_path / "thickness.csv"
|
||||||
|
p.write_text(header + body)
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_valid(tmp_path):
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path,
|
||||||
|
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||||||
|
"t,1,2,7,2.5,0,-30.0,40.0\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert err == ""
|
||||||
|
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_first_slot_only(tmp_path):
|
||||||
|
"""Rows after the Slot# changes belong to another wafer and are skipped."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path,
|
||||||
|
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||||||
|
"t,1,1,8,9.9,0,0.0,0.0\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert err == ""
|
||||||
|
assert points == [[10.0, -20.0, 1.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_bad_header(tmp_path):
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert points == []
|
||||||
|
assert "header" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_malformed_value(tmp_path):
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
|
||||||
|
points, err = parse_thickness_csv(path)
|
||||||
|
assert points == []
|
||||||
|
assert "Malformed" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_thickness_csv_missing_file():
|
||||||
|
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||||
|
|
||||||
|
points, err = parse_thickness_csv("/no/such/file.csv")
|
||||||
|
assert points == []
|
||||||
|
assert "Unable to read" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_thickness_slot(tmp_path):
|
||||||
|
"""loadThickness feeds parsed points into thicknessData."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
|
||||||
|
assert item.loadThickness(path) == ""
|
||||||
|
assert item.hasThickness is True
|
||||||
|
|
||||||
|
assert item.loadThickness("/no/such/file.csv") != ""
|
||||||
|
# Failed load keeps the previously loaded data.
|
||||||
|
assert item.hasThickness is True
|
||||||
|
|
||||||
|
|
||||||
def test_show_thickness_setter():
|
def test_show_thickness_setter():
|
||||||
@@ -88,3 +141,157 @@ def test_show_thickness_setter():
|
|||||||
|
|
||||||
item.showThickness = True
|
item.showThickness = True
|
||||||
assert item._show_thickness is True
|
assert item._show_thickness is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_writes_real_png_headless(tmp_path):
|
||||||
|
"""export_image must produce an actual decodable image file without a
|
||||||
|
QQuickWindow — the button was silently failing because grabToImage()
|
||||||
|
resolves asynchronously and never completes headless/immediately."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
assert out.exists()
|
||||||
|
loaded = QImage(str(out))
|
||||||
|
assert not loaded.isNull()
|
||||||
|
assert loaded.width() == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_includes_metrics_footer(tmp_path):
|
||||||
|
"""With sensor values loaded, the export gains a footer strip below the
|
||||||
|
map carrying the metric readouts (sensors / min / max / avg)."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
loaded = QImage(str(out))
|
||||||
|
assert loaded.height() > 200 # footer strip appended
|
||||||
|
# footer is painted opaque (dark bg), not transparent
|
||||||
|
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_extra_line_grows_footer(tmp_path):
|
||||||
|
"""Live export passes an extra metrics line; the footer gains a second
|
||||||
|
row for it. Review export (no extra) keeps the single-row footer."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
|
||||||
|
review = tmp_path / "review.png"
|
||||||
|
live = tmp_path / "live.png"
|
||||||
|
assert item.export_image(str(review)) is True
|
||||||
|
assert item.export_image(str(live), "Frames: 42 Errors: 0") is True
|
||||||
|
|
||||||
|
h_review = QImage(str(review)).height()
|
||||||
|
h_live = QImage(str(live)).height()
|
||||||
|
assert h_review > 200 # readout footer present
|
||||||
|
assert h_live > h_review # extra live row appended
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_highlights_hottest_and_coldest(tmp_path):
|
||||||
|
"""The exported map rings the hottest sensor in the high color and the
|
||||||
|
coldest in the low color. With no bands set every marker fill is the
|
||||||
|
in-range green, so any high/low-colored pixels must come from the rings."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.sensors = [
|
||||||
|
{"label": "1", "x": -50.0, "y": 0.0},
|
||||||
|
{"label": "2", "x": 0.0, "y": 0.0},
|
||||||
|
{"label": "3", "x": 50.0, "y": 0.0},
|
||||||
|
]
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
|
||||||
|
img = QImage(str(out))
|
||||||
|
found = {img.pixelColor(x, y).name()
|
||||||
|
for y in range(img.height()) for x in range(img.width())}
|
||||||
|
assert "#ef4444" in found # hottest ring (sensorHigh)
|
||||||
|
assert "#5b9df5" in found # coldest ring (sensorLow)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_extremes_toggle_gates_peak_rings(tmp_path):
|
||||||
|
"""showExtremes (Display setting) hides the hot/cold rings when off.
|
||||||
|
Same pixel probe as the highlight test: with rings off, no high/low
|
||||||
|
colored pixels should appear anywhere."""
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
assert item.showExtremes is True # rings on by default
|
||||||
|
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
item.sensors = [
|
||||||
|
{"label": "1", "x": -50.0, "y": 0.0},
|
||||||
|
{"label": "2", "x": 0.0, "y": 0.0},
|
||||||
|
{"label": "3", "x": 50.0, "y": 0.0},
|
||||||
|
]
|
||||||
|
item.values = [10.0, 20.0, 30.0]
|
||||||
|
item.showExtremes = False
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
assert item.export_image(str(out)) is True
|
||||||
|
|
||||||
|
img = QImage(str(out))
|
||||||
|
found = {img.pixelColor(x, y).name()
|
||||||
|
for y in range(img.height()) for x in range(img.width())}
|
||||||
|
assert "#ef4444" not in found
|
||||||
|
assert "#5b9df5" not in found
|
||||||
|
|
||||||
|
|
||||||
|
def test_readout_text_full_stats():
|
||||||
|
"""Export footer readout carries every READOUT stat with sensor indices.
|
||||||
|
|
||||||
|
Worked example: [10, 20, 30] → min 10 (#1), max 30 (#3), diff 20,
|
||||||
|
avg 20, σ = sqrt(200/3) ≈ 8.16, 3σ ≈ 24.49.
|
||||||
|
"""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import readout_text
|
||||||
|
|
||||||
|
assert readout_text([10.0, 20.0, 30.0]) == (
|
||||||
|
"Sensors: 3 Min: 10.00°C (#1) Max: 30.00°C (#3) "
|
||||||
|
"Diff: 20.00 Avg: 20.00 σ: 8.16 3σ: 24.49"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_readout_text_empty():
|
||||||
|
from pygui.backend.visualization.wafer_map_item import readout_text
|
||||||
|
|
||||||
|
assert readout_text([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_no_data_fails(tmp_path):
|
||||||
|
"""No file loaded / no stream played -> nothing meaningful to export."""
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(200)
|
||||||
|
item.setHeight(200)
|
||||||
|
out = tmp_path / "wafer.png"
|
||||||
|
|
||||||
|
assert item.export_image(str(out)) is False
|
||||||
|
assert not out.exists()
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
{
|
|
||||||
"_mock_return_value":
|
|
||||||
@@ -58,6 +58,104 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -72,7 +170,7 @@ name = "contourpy"
|
|||||||
version = "1.3.3"
|
version = "1.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -149,6 +247,62 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "49.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cycler"
|
name = "cycler"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -163,8 +317,8 @@ name = "dtw-python"
|
|||||||
version = "1.7.5"
|
version = "1.7.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "scipy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/ac/e0967e71019959197e2f8a4e99937f043f35fa1efc2ac30294ecc8b3df77/dtw_python-1.7.5.tar.gz", hash = "sha256:650dec798812dcda3aab059a5dcf5954d34d33903479df00b90d33956b7b04b3", size = 274203, upload-time = "2026-06-12T12:01:02.056Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/ac/e0967e71019959197e2f8a4e99937f043f35fa1efc2ac30294ecc8b3df77/dtw_python-1.7.5.tar.gz", hash = "sha256:650dec798812dcda3aab059a5dcf5954d34d33903479df00b90d33956b7b04b3", size = 274203, upload-time = "2026-06-12T12:01:02.056Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -437,15 +591,15 @@ name = "matplotlib"
|
|||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "contourpy" },
|
||||||
{ name = "cycler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "cycler" },
|
||||||
{ name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "fonttools" },
|
||||||
{ name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "kiwisolver" },
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "packaging" },
|
||||||
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pillow" },
|
||||||
{ name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyparsing" },
|
||||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "python-dateutil" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -501,11 +655,11 @@ name = "mypy"
|
|||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ast-serialize", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "ast-serialize" },
|
||||||
{ name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" },
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
{ name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "mypy-extensions" },
|
||||||
{ name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pathspec" },
|
||||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -649,8 +803,8 @@ name = "pandas"
|
|||||||
version = "3.0.3"
|
version = "3.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
|
||||||
@@ -809,6 +963,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
@@ -823,31 +986,33 @@ name = "pygui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dtw-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "cryptography" },
|
||||||
{ name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "dtw-python" },
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "matplotlib" },
|
||||||
{ name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
{ name = "pyqtgraph", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pandas" },
|
||||||
{ name = "pyserial", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyqtgraph" },
|
||||||
{ name = "pyside6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyserial" },
|
||||||
{ name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyside6" },
|
||||||
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "scipy" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "mypy" },
|
||||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "cryptography", specifier = ">=41.0" },
|
||||||
{ name = "dtw-python" },
|
{ name = "dtw-python" },
|
||||||
{ name = "matplotlib", specifier = ">=3.7.0" },
|
{ name = "matplotlib", specifier = ">=3.7.0" },
|
||||||
{ name = "numpy", specifier = ">=1.24.0" },
|
{ name = "numpy", specifier = ">=1.24.0" },
|
||||||
@@ -882,8 +1047,8 @@ name = "pyqtgraph"
|
|||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "colorama" },
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
|
||||||
@@ -903,9 +1068,9 @@ name = "pyside6"
|
|||||||
version = "6.11.1"
|
version = "6.11.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pyside6-addons", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyside6-addons" },
|
||||||
{ name = "pyside6-essentials", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyside6-essentials" },
|
||||||
{ name = "shiboken6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "shiboken6" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
|
||||||
@@ -920,8 +1085,8 @@ name = "pyside6-addons"
|
|||||||
version = "6.11.1"
|
version = "6.11.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pyside6-essentials", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pyside6-essentials" },
|
||||||
{ name = "shiboken6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "shiboken6" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
|
||||||
@@ -936,7 +1101,7 @@ name = "pyside6-essentials"
|
|||||||
version = "6.11.1"
|
version = "6.11.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "shiboken6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "shiboken6" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
|
||||||
@@ -952,10 +1117,10 @@ version = "9.0.3"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "iniconfig" },
|
||||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "packaging" },
|
||||||
{ name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -967,7 +1132,7 @@ name = "python-dateutil"
|
|||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "six" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1059,7 +1224,7 @@ name = "scipy"
|
|||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
Reference in New Issue
Block a user