Compare commits

..

23 Commits

Author SHA1 Message Date
jack 9e3bec9031 feat(debug): add debug read with CSV export
- Parse F1 debug data via family-independent temp formula
  (bits 4-14 scaled by 2**(i-8), sign at bit 0)
- Save sensor+debug temps side-by-side to debug_info.csv
- Wire READ DEBUG button in StatusActionsPanel
- Rename ReplayChart → RunChart; move into GraphTab
- Extract PanelBox/SectionTitle/PanelSlider in ReadoutPanel
  to cut ~300 lines of duplicated styling
- Add rightRailWidth token to Theme for consistent rail sizing
- Wrap ReadoutPanel in ScrollView so Thresholds card stays
  reachable when E-C delta rows appear
2026-07-11 18:39:29 -07:00
jack fed4d9b590 feat: replay chart with zoomable viewpor 2026-07-10 20:54:24 -07:00
jack 4bb855a940 feat(graph): add graph tab for whole-run data
Provides a static line chart plotting multi-sensor temperature values
over time when a session file is loaded.
2026-07-10 20:06:48 -07:00
jack 94f917b116 feat(map): add edge-center delta stats, thickness overlay, and layout fix
- Add edge-center sensor pair tables and delta calculations per wafer family.
- Parse and interpolate wafer thickness CSV data for offscreen visualization.
- Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping.
- Compact E-C Delta text format to fit standard sidebar width.
- Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
2026-07-10 17:32:12 -07:00
jack 25fa7507ce feat(map): add min/max highlighting toggle and live safeguards
- Add "Highlight Min/Max" option to ReadoutPanel showing peak rings.
- Reset to review mode and blank wafer map when switching away from map tab.
- Disable hover highlights and sensor dialog triggers during live stream.
- Lower trendPane height and add top margin headroom for tick labels.
- Add test coverage for blanking states, toggle overrides, and interactions.
2026-07-10 16:28:55 -07:00
jack 034f13b717 feat(map): add peak highlights and live metrics to PNG export
- Draw rings around hottest and coldest sensors on export.
- Extend export_image to support second line for live metrics.
- Format detailed sensor stats in export footer.
- Add test coverage for footer rows, overlays, and stats.
2026-07-10 15:39:30 -07:00
jack 69753e35f9 feat(map): add batch export and adjust layout alignment
- Implement synchronous offscreen wafer map PNG rendering and CSV summary.
- Add "Batch Export" button to file browser sidebar.
- Adjust trend pane bottom spacer to align with About button when visible.
- Fix type check errors and add pytest coverage for batch export.
2026-07-10 15:22:36 -07:00
jack 278a48a2e4 feat(transport): editable frame field + uniform spacing 2026-07-10 14:38:12 -07:00
jack e2ce8778e0 feat(files): sync Source panel browser with chosen save directory 2026-07-10 14:37:29 -07:00
jack bdc667b2ed refactor: extract StreamControlPanel and MetricRow from WaferMapTab 2026-07-09 15:16:11 -07:00
jack b6249c2b8e fix: added property var settingsPopup: null / property var aboutDialog: null to UtilityFooter.qml and updated onClicked to use local properties instead of utilFooter.parent.*. 2026-07-09 14:30:19 -07:00
jack 2fe8aef805 feat(ui): refine AboutDialog layout and add nav pill tooltips 2026-07-09 13:56:48 -07:00
jack 4ee139ad8f fix: The QML cross-component id resolution for root.waferDetected was failing
silently. Exposing a proper waferDetected property on the Python
DeviceController ensures reliable gating, and resetting it on refresh
blocks the actions until the next successful detection.
2026-07-09 13:07:31 -07:00
jack 7b20ffa376 refactor(qml): finalize tooltip standardization and settings module imports 2026-07-09 12:50:24 -07:00
jack ac4448ed44 refactor(backend): clean up package re-exports, settings model properties, and unused signals 2026-07-09 12:49:33 -07:00
jack ca158a7946 refactor(qml): extract sidebar panels and data tab comparison cards to modular components 2026-07-09 12:49:10 -07:00
jack 637b51b999 fix(qml): resolve runtime reference errors, missing imports, and port change notifications 2026-07-09 12:47:56 -07:00
jack bb9b5d709a feat: add global ToolTip styling to HomePage 2026-07-08 00:31:46 -07:00
jack b6903af625 feat: redesign StatusTab UI with bento-grid layout and add CSV metadata editing capabilities 2026-07-07 16:41:40 -07:00
jack 92f130b3bd feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab 2026-07-07 15:05:22 -07:00
jack 1e03227788 style: increase ReadoutPanel font sizes and add CSV test fixture for data logging 2026-07-07 12:10:47 -07:00
jack 7df7fd4c6f feat: implement C#-compatible AES-128-CBC license file parsing and integrate license management UI into AboutDialog 2026-07-07 11:15:06 -07:00
jack e2d05d2c33 refactor: relocate live stream controls to ReadoutPanel and update chart axis rendering 2026-07-07 10:38:42 -07:00
91 changed files with 6422 additions and 2998 deletions
+18
View File
@@ -40,3 +40,21 @@ dist/
*.spec *.spec
*.icns *.icns
*.ico *.ico
# Runtime scratch data
tmp/
# Superpowers brainstorming visual companion
.superpowers/
.llm-wiki/
.agents/
.pi/
# Planning / personal docs (root level)
CLAUDE.md
LEARNINGS.md
MIGRATION.md
AGENTS.md
CONVENTIONS.md
specs/
graphify-out/
+15 -1
View File
@@ -14,11 +14,13 @@ The `ISenseCloud` application provides a real-time monitor and analysis dashboar
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries. - **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
### App Showcase ### App Showcase
![ISenseCloud UI displaying successful connection on COM6](image-1.png) ![ISenseCloud UI displaying successful connection on COM6](image-1.png)
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.* *Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
> [!NOTE] > [!NOTE]
> **Take Screenshots for Visual Placeholders:** > **Take Screenshots for Visual Placeholders:**
>
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here. > - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here. > - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
@@ -42,12 +44,15 @@ During the refactoring from the flat-layout prototype (`SettingTab` branch) to t
From the project root: From the project root:
### Option A: Using `uv` (Recommended) ### Option A: Using `uv` (Recommended)
If you have [uv](https://docs.astral.sh/uv/) installed: If you have [uv](https://docs.astral.sh/uv/) installed:
```bash ```bash
uv sync uv sync
``` ```
### Option B: Using `pip` ### Option B: Using `pip`
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows source .venv/bin/activate # or .venv\Scripts\activate on Windows
@@ -59,9 +64,11 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
## Run ## Run
### Launch PySide6 QML App ### Launch PySide6 QML App
```bash ```bash
make run make run
``` ```
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`. This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
## Development ## Development
@@ -83,6 +90,7 @@ A `Makefile` is provided to simplify common development and verification tasks:
### Ruff Configuration ### Ruff Configuration
Ruff is configured in `pyproject.toml` to enforce: Ruff is configured in `pyproject.toml` to enforce:
- **E/W**: Pycodestyle errors and warnings - **E/W**: Pycodestyle errors and warnings
- **F**: Pyflakes linter rules - **F**: Pyflakes linter rules
- **I**: Import sorting (isort parity) - **I**: Import sorting (isort parity)
@@ -174,11 +182,13 @@ Window {
- If the app does not start, verify the virtual environment is active and dependencies are installed: - If the app does not start, verify the virtual environment is active and dependencies are installed:
*Using `uv`:* *Using `uv`:*
```bash ```bash
uv sync uv sync
``` ```
*Using `pip`:* *Using `pip`:*
```bash ```bash
source .venv/bin/activate # or .venv\Scripts\activate on Windows source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt pip install -r requirements.txt
@@ -187,17 +197,20 @@ Window {
- If no window appears, ensure you are running in a desktop session with GUI access. - If no window appears, ensure you are running in a desktop session with GUI access.
- **Windows COM Port Connection Issues:** - **Windows COM Port Connection Issues:**
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud. - Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
### Step-by-Step Windows Simulator Connection Guide ### Step-by-Step Windows Simulator Connection Guide
#### Step 1: Create the Virtual Serial Port Bridge #### Step 1: Create the Virtual Serial Port Bridge
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**. Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png) ![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png)
#### Step 2: Configure and Start the Wafer Simulator #### Step 2: Configure and Start the Wafer Simulator
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`): Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
1. Set the **Serial Port** to **`COM5`**. 1. Set the **Serial Port** to **`COM5`**.
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox. 2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`). 3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
@@ -206,6 +219,7 @@ Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png) ![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png)
#### Step 3: Run the UI and Connect #### Step 3: Run the UI and Connect
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green). Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
![ISenseCloud UI displaying successful connection on COM6](image-1.png) ![ISenseCloud UI displaying successful connection on COM6](image-1.png)
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"scipy>=1.17.1", "scipy>=1.17.1",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"dtw-python", "dtw-python",
"cryptography>=41.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+95 -37
View File
@@ -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
RowLayout {
Layout.fillWidth: true
Label { Label {
text: "LICENSE" text: "LICENSE"
color: Theme.panelTitleText color: Theme.panelTitleText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 0.5 font.letterSpacing: 0.5
Layout.fillWidth: true
}
Label {
id: loadFailedLabel
visible: false
text: "Invalid license file"
color: Theme.statusWarningColor
font.pixelSize: Theme.fontSm
}
} }
// Grid header // Grid header
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 4 spacing: 8
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 } Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 } Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 } Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
} }
Rectangle { Rectangle {
@@ -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,12 +148,39 @@ Popup {
} }
} }
// ── Close ── // ── Buttons ──
RowLayout {
Layout.fillWidth: true
spacing: 12
Button {
text: "Load License"
Layout.preferredWidth: 140
Layout.preferredHeight: 36
onClicked: licenseFileDialog.open()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: parent.text
color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Item { Layout.fillWidth: true }
Button { Button {
text: "Close" text: "Close"
Layout.alignment: Qt.AlignRight
Layout.preferredWidth: 100 Layout.preferredWidth: 100
Layout.preferredHeight: 32 Layout.preferredHeight: 36
onClicked: root.close() onClicked: root.close()
background: Rectangle { background: Rectangle {
@@ -137,9 +192,12 @@ Popup {
contentItem: Label { contentItem: Label {
text: parent.text text: parent.text
color: Theme.buttonNeutralText color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
} }
} }
} }
}
+304 -261
View File
@@ -23,20 +23,38 @@ Rectangle {
if (selectedTabIndex === 2) { if (selectedTabIndex === 2) {
file_browser.refreshFiles(); file_browser.refreshFiles();
streamController.unloadFile() streamController.unloadFile()
} else if (streamController.mode === "live") {
// Leaving the map tab mid-stream: stop and blank the live map.
streamController.setMode("review")
} }
} }
property bool memoryRead: false property bool memoryRead: false
property bool waferDetected: false property bool waferDetected: false
// TODO P2.2: add `property bool waferLicensed: false`; set it in
// onDetectResult below via deviceController.waferLicensed() (slot from
// P1.2 — the detectResult payload is opaque in QML, can't read serial
// from it). Then READ/ERASE buttons get
// `enabled: root.waferDetected && root.waferLicensed`.
// See docs/pending/license-gating-plan.md §2.2.
Connections { Connections {
target: deviceController target: deviceController
function onDetectResult(result){ function onDetectResult(result){
root.waferDetected = (result != null && result != undefined) // detectResult payload is Signal(object): a dict may cross into QML
// as an opaque wrapper whose fields read as undefined, so only
// test truthiness (None on failure, dict on success).
root.waferDetected = !!result
} }
function onParsedDataReady(result) { function onParsedDataReady(result) {
if (result.success && result.csv_path) { if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path) streamController.loadFile(result.csv_path)
// TODO P3.1: only auto-jump to Map when unlocked:
// `&& licenseModel.licenses.length > 0` — otherwise a
// successful read walks straight into the locked tab.
// See plan §3.1.
if (root.selectedTabIndex === 0) { if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2 root.selectedTabIndex = 2
} }
@@ -63,6 +81,7 @@ Rectangle {
function _doDetect() { function _doDetect() {
root.memoryRead = false root.memoryRead = false
root.waferDetected = false
streamController.setMode("review") streamController.setMode("review")
streamController.stopStream() streamController.stopStream()
deviceController.detectWafer() deviceController.detectWafer()
@@ -72,7 +91,9 @@ Rectangle {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save Data" title: "Choose a folder to save Data"
onAccepted: { onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)) var dir = root.cleanFolderUrl(selectedFolder)
deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir)
root._doDetect() root._doDetect()
} }
} }
@@ -181,8 +202,6 @@ Rectangle {
Layout.preferredWidth: Theme.sideRailWidth Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.sideRailBackground color: Theme.sideRailBackground
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
@@ -201,9 +220,30 @@ Rectangle {
ListModel { ListModel {
id: pillModel id: pillModel
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 } ListElement {
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 } label: "STATUS"
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 } icon: "Tabs/icons/status.svg"
expandW: 86
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
}
ListElement {
label: "DATA"
icon: "Tabs/icons/data.svg"
expandW: 66
desc: "Manage, review, compare (DTW), and split historical sensor data records."
}
ListElement {
label: "MAP"
icon: "Tabs/icons/map.svg"
expandW: 58
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
}
ListElement {
label: "GRAPH"
icon: "Tabs/icons/bar-chart.svg"
expandW: 70
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
}
} }
RowLayout { RowLayout {
@@ -284,307 +324,306 @@ Rectangle {
} }
} }
AppToolTip {
visible: pillBtn.hovered
text: "<b>" + model.label + "</b>: " + model.desc
}
onClicked: root.selectedTabIndex = index onClicked: root.selectedTabIndex = index
} }
} }
} }
} }
// ── BOX 2: Context-Sensitive Rail Panel ──────────────────── // ── BOX 1.5: Run Comparison (Data tab only) ────────────────
Rectangle { Rectangle {
id: runComparisonBox
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.preferredHeight: runCompCol.implicitHeight + 28
Layout.minimumHeight: 120 visible: root.selectedTabIndex === 1
radius: Theme.sidePanelRadius radius: Theme.sidePanelRadius
border.color: Theme.sideBorder border.color: Theme.sideBorder
border.width: 1 border.width: 1
color: Theme.sidePanelBackground color: Theme.sidePanelBackground
ColumnLayout { readonly property var dataTab: dataTabLoader.item
component RunSlot: Rectangle {
id: slotBox
property string title
property string filePath: ""
property color accent: Theme.primaryAccent
property bool active: false
property bool locked: false
property string placeholderText: "Click to select"
signal cleared()
signal armed()
Layout.fillWidth: true
implicitHeight: 58
radius: Theme.radiusSm
color: Theme.fieldBackground
opacity: slotBox.locked ? 0.65 : 1.0
border.width: slotBox.active ? 2 : 1
border.color: slotBox.active ? accent : Theme.sideBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent anchors.fill: parent
anchors.margins: 14 enabled: !slotBox.locked
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: slotBox.armed()
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8 spacing: 8
StackLayout { Rectangle {
Layout.fillWidth: true width: 8; height: 8; radius: 4
Layout.fillHeight: true color: slotBox.accent
currentIndex: root.selectedTabIndex === 0 ? 0 : 1 Layout.alignment: Qt.AlignVCenter
}
// ── Status tab: Hardware Actions ────────────────
ColumnLayout { ColumnLayout {
spacing: 6 Layout.fillWidth: true
spacing: 2
Label {
text: slotBox.title
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.0
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
}
Label {
text: slotBox.filePath !== ""
? slotBox.filePath.split("/").pop()
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
font.pixelSize: Theme.fontSm
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
Button {
visible: slotBox.filePath !== "" && !slotBox.locked
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: slotBox.cleared()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
anchors.centerIn: parent
}
}
}
}
ColumnLayout {
id: runCompCol
anchors.fill: parent
anchors.margins: 14
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 8
Label { Label {
text: "HARDWARE ACTIONS" text: "RUN COMPARISON (DTW)"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
}
RailActionButton {
label: "DETECT WAFER"
iconSource: "../icons/detect.svg"
Layout.fillWidth: true Layout.fillWidth: true
enabled: !deviceController.operationInProgress elide: Text.ElideRight
onClicked: root._doDetect()
} }
RailActionButton { Button {
label: "READ MEMORY" id: resetRunBtn
iconSource: "../icons/read.svg" flat: true
implicitWidth: 32
implicitHeight: 32
visible: true
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
background: Rectangle {
radius: Theme.radiusSm
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/rotate-ccw.svg"
width: 18; height: 18
sourceSize.width: 18
sourceSize.height: 18
color: Theme.bodyColor
anchors.centerIn: parent
}
AppToolTip {
visible: resetRunBtn.hovered
text: "Reset comparison"
}
}
}
PanelCheckBox {
id: useMasterCheck
Layout.fillWidth: true Layout.fillWidth: true
enabled: root.waferDetected text: "Compare vs Master File"
onClicked: deviceController.readMemoryAsync() checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
onToggled: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.useMasterFile = checked
if (checked) runComparisonBox.dataTab.activeBox = 2
}
} }
RailActionButton { RunSlot {
label: "ERASE MEMORY" readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
iconSource: "../icons/erase.svg" title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
accent: Theme.primaryAccent
locked: useMaster
placeholderText: useMaster
? (runComparisonBox.dataTab.runBWaferType !== ""
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
: "Select Run B to resolve master")
: "Click to select"
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
filePath: runComparisonBox.dataTab
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
: ""
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileA = ""
runComparisonBox.dataTab.activeBox = 1
runComparisonBox.dataTab.clearResults()
}
}
RunSlot {
title: "SESSION (RUN B)"
accent: Theme.themeSkill
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileB = ""
runComparisonBox.dataTab.activeBox = 2
runComparisonBox.dataTab.clearResults()
}
}
Button {
id: compareRunBtn
Layout.fillWidth: true Layout.fillWidth: true
destructive: true Layout.preferredHeight: 32
enabled: root.waferDetected readonly property var dt: runComparisonBox.dataTab
onClicked: deviceController.eraseMemory( readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
deviceController.selectedPort) readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
readonly property string gateError: !!dt ? dt.familyGateError : ""
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
AppToolTip {
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
text: compareRunBtn.masterMissing
? "No master file registered for this wafer type (Settings → Master Files)"
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
: "Choose two different runs to compare"
}
onClicked: {
dt.comparing = true
dt.clearResults()
streamController.compareFiles(fileA, dt.compareFileB)
} }
Item { Layout.fillHeight: true } background: Rectangle {
radius: Theme.radiusSm
color: compareRunBtn.enabled
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
: Theme.buttonNeutralBackground
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
anchors.verticalCenter: parent.verticalCenter
text: !compareRunBtn.dt ? "Select both runs"
: compareRunBtn.dt.comparing
? "Comparing…"
: compareRunBtn.enabled
? "Run DTW Comparison"
: (compareRunBtn.masterMissing ? "No master for this type"
: compareRunBtn.sameFile ? "Choose different runs"
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
: "Select both runs")
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
}
BusyIndicator {
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
width: 14; height: 14
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
} }
// ── Map tab: Source file browser ────────────── // ── BOX 2: Context-Sensitive Rail Panel ────────────────────
SourcePanel { StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions + Log Actions ──────────
StatusActionsPanel {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
} }
// ── Map tab: Source file browser ──────────────
Rectangle {
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
SourcePanel {
anchors.fill: parent
anchors.margins: 14
} }
} }
} }
// ── BOX 3: Hardware Status Footer ────────────────────────── // ── BOX 3: Hardware Status Footer ──────────────────────────
Rectangle { ConnectionFooter {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
// Row 1: Title + status badge (unchanged)
RowLayout {
spacing: 8
Layout.fillWidth: true
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.2
font.bold: true
}
Rectangle {
height: 18
width: badgeLabel.implicitWidth + 14
radius: 9
color: {
var s = deviceController.connectionStatus
if (s === "Connected") return Theme.statusSuccessColor
if (s === "Disconnected") return Theme.statusErrorColor
return Theme.statusWarningColor
}
Label {
id: badgeLabel
anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected"
color: 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
} }
} }
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
// ===== Graph Tab =====
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
// snapshot recomputed whenever a file is loaded — not live-updating during
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
Item {
id: root
anchors.fill: parent
function refresh() {
if (!streamController.loadedFile) {
chart.seriesData = []
chart.sensorNames = []
return
}
chart.sensorNames = streamController.graphSensorNames
? streamController.graphSensorNames.split(",") : []
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
}
Connections {
target: streamController
function onLoadedFileChanged() { root.refresh() }
}
Component.onCompleted: root.refresh()
Rectangle {
anchors.fill: parent
anchors.margins: Theme.panelPadding
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
RunChart {
id: chart
anchors.fill: parent
anchors.margins: 8
title: "Sensor Temperature Over Time"
}
}
}
+1
View File
@@ -4,6 +4,7 @@ import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
import ISC.Tabs.components
Item { Item {
id: root id: root
+239 -243
View File
@@ -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,44 +281,97 @@ ColumnLayout {
} }
} }
ColumnLayout {
spacing: 1
Text {
text: "COM PORT"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
}
Text { Text {
text: root.portName text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight elide: Text.ElideMiddle
}
}
} }
} }
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ── Wafer & Sensor Data: far right, spans both rows ──
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%) Rectangle {
// ═══════════════════════════════════════════════════════════════════════ Layout.rowSpan: 2
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
RowLayout {
Layout.preferredWidth: 65
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 8 color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
text: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
}
GridLayout {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 6
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
}
}
}
}
// ── Directory: bottom-left cell ──
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -290,38 +385,24 @@ ColumnLayout {
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: 6 spacing: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
}
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 8
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
Layout.fillWidth: true
}
Rectangle { Rectangle {
visible: root.csvPath !== "" visible: root.csvPath !== ""
width: sessionSavedLabel.implicitWidth + 12 implicitWidth: sessionSavedLabel.implicitWidth + 12
height: 18 implicitHeight: 18
radius: 4 radius: 4
color: Theme.fieldBackground color: Theme.fieldBackground
border.color: Theme.sideBorder border.color: Theme.sideBorder
@@ -338,17 +419,33 @@ ColumnLayout {
font.letterSpacing: 0.6 font.letterSpacing: 0.6
} }
} }
}
Item { Layout.fillWidth: true } Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 12
// Path pinned bottom-left
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Button { Button {
id: browseBtn id: browseBtn
text: "Save As" text: "Select Folder"
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
implicitHeight: 24 implicitHeight: 24
implicitWidth: 70
hoverEnabled: true hoverEnabled: true
background: Rectangle { background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
@@ -363,97 +460,25 @@ ColumnLayout {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
onClicked: saveDirDialog.open() onClicked: dirOnlyDialog.open()
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 4
Text {
text: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
}
GridLayout {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 4
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
}
} }
} }
} }
} }
// ── Total Runtime: bottom center ──
StatCard { StatCard {
id: runtimeCard id: runtimeCard
Layout.preferredWidth: 35 label: "TOTAL RUNTIME"
value: { value: {
var info = deviceController.lastWaferInfo; var info = deviceController.lastWaferInfo;
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—"; if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
var secs = parseFloat(info[4]);
if (isNaN(secs)) return info[4] + "s";
var m = Math.floor(secs / 60);
var s = Math.round(secs % 60);
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
} }
label: "TOTAL RUNTIME"
} }
} }
@@ -477,6 +502,16 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 36 Layout.preferredHeight: 36
color: Theme.subtleSectionBackground color: Theme.subtleSectionBackground
radius: Theme.radiusMd
// Cover bottom corners so only top-left and top-right are rounded
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: parent.radius
color: parent.color
}
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
@@ -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;
} }
+47 -189
View File
@@ -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 // 0100, percent
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "WAFER OVERLAP"
Layout.fillWidth: true
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true
WaferMapItem {
id: overlapMapItem
anchors.fill: parent
visible: card.sensorLayout.length > 0 && card.overlapEnabled
sensors: card.sensorLayout
values: card.diffValues
bands: card.diffBands(overlapMapItem.values)
shape: card.waferShape
size: card.waferSize
target: 0
margin: 1.0
blend: card.blendAmount / 100
showLabels: true
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
}
ColumnLayout {
anchors.centerIn: parent
visible: card.sensorLayout.length === 0 || !card.overlapEnabled
spacing: 4
IconImage {
Layout.alignment: Qt.AlignHCenter
source: "../icons/map.svg"
width: 24; height: 24
sourceSize.width: 24
sourceSize.height: 24
color: Theme.sideMutedText
}
Label {
Layout.alignment: Qt.AlignHCenter
text: !card.overlapEnabled ? "Overlap map disabled"
: "No sensor map available"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
}
}
}
}
@@ -0,0 +1,10 @@
import QtQuick
import ISC
// Card container for the Data tab bento boxes.
Rectangle {
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.sidePanelRadius
}
@@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import ISC
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
// indicator + check icon) instead of the platform-default CheckBox look.
CheckBox {
id: toggle
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
IconImage {
anchors.centerIn: parent
source: "../icons/check.svg"
width: 12; height: 12
sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
@@ -0,0 +1,19 @@
import QtQuick
import QtQuick.Controls
import ISC
// Slider styled to match MapTab's right-rail heatmap slider (circular
// accent handle that grows slightly on press).
Slider {
id: panelSlider
handle: Rectangle {
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: panelSlider.pressed ? 1.15 : 1.0
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
}
}
@@ -12,6 +12,9 @@ Rectangle {
property string label: "" property string label: ""
property string iconSource: "" property string iconSource: ""
property bool destructive: false property bool destructive: false
// Set true to show the button as "active" (e.g. a one-shot toggle
// that's mid-operation), independent of hover/press state.
property bool toggled: false
property color _textColor: !root.enabled ? Theme.sideMutedText property color _textColor: !root.enabled ? Theme.sideMutedText
: root.destructive ? Theme.statusErrorColor : root.destructive ? Theme.statusErrorColor
: Theme.headingColor : Theme.headingColor
@@ -20,7 +23,7 @@ Rectangle {
implicitHeight: Theme.sideButtonHeight implicitHeight: Theme.sideButtonHeight
radius: 8 radius: 8
color: mouseArea.containsMouse || mouseArea.pressed color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
? Theme.sideActiveBackground : "transparent" ? Theme.sideActiveBackground : "transparent"
border.width: 0 border.width: 0
+117 -329
View File
@@ -1,61 +1,26 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.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" ReadoutStat {
color: Theme.sideMutedText label: "Min Temp"
font.family: Theme.uiFontFamily value: s.min !== undefined
font.pixelSize: Theme.fontXs ? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
font.letterSpacing: 1.5 : "—"
font.bold: true valueColor: Theme.sensorLow
Layout.leftMargin: 14 }
Layout.rightMargin: 14 ReadoutStat {
Layout.topMargin: 2 label: "Max Temp"
Layout.bottomMargin: 10 value: s.max !== undefined
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
: "—"
valueColor: Theme.sensorHigh
}
ReadoutStat {
label: "Diff"
value: s.diff !== undefined ? s.diff : "—"
valueColor: Theme.diffAccent
}
ReadoutStat {
label: "Average"
value: s.avg !== undefined ? s.avg : "—"
}
ReadoutStat {
label: "Sigma (Σ)"
value: s.sigma !== undefined ? s.sigma : "—"
}
ReadoutStat {
label: "3Σ Value"
value: s.threeSigma !== undefined ? s.threeSigma : "—"
}
ReadoutStat {
visible: s.ecMinDelta !== undefined
label: "E-C Δ Min"
value: s.ecMinDelta !== undefined
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
: "—"
}
ReadoutStat {
visible: s.ecMaxDelta !== undefined
label: "E-C Δ Max"
value: s.ecMaxDelta !== undefined
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
: "—"
}
}
} }
// Min Temp Row // ── DISPLAY ─────────────────────────────────────────────────────
RowLayout { PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 14 Layout.preferredHeight: displayCard.implicitHeight + 24
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MIN TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
Label {
visible: (s.minIndex !== undefined) && s.minIndex >= 0
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Max Temp Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MAX TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
Label {
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Differential Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "DIFF"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.diffAccent
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Average Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "AVERAGE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "SIGMA (Σ)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 3-Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "3Σ VALUE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: displayCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: displayCard id: displayCard
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 8 spacing: 8
Label { SectionTitle { text: "DISPLAY" }
text: "DISPLAY"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
PanelCheckBox { PanelCheckBox {
id: thicknessToggle id: thicknessToggle
text: "Show Thickness" text: "Show Thickness"
checked: false checked: false
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
// First check with no data prompts for the customer CSV;
// unchecking only hides the overlay, data stays loaded.
onToggled: {
if (checked && !root.hasThicknessData)
thicknessDialog.open();
}
}
FileDialog {
id: thicknessDialog
title: "Select Thickness Data File"
nameFilters: ["CSV files (*.csv)"]
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
onRejected: thicknessToggle.checked = false
} }
PanelCheckBox { PanelCheckBox {
id: labelsToggle id: labelsToggle
text: "Labels" text: "Labels"
checked: true checked: true
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
}
PanelCheckBox {
id: extremesToggle
text: "Highlight Min/Max"
checked: true
font.pixelSize: Theme.fontSm
} }
PanelCheckBox { PanelCheckBox {
id: clusterAverageToggle id: clusterAverageToggle
text: "Average Clusters" text: "Average Clusters"
checked: streamController.clusterAveragingEnabled checked: streamController.clusterAveragingEnabled
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
onCheckedChanged: streamController.clusterAveragingEnabled = checked onCheckedChanged: streamController.clusterAveragingEnabled = checked
} }
@@ -363,35 +176,25 @@ ColumnLayout {
Label { Label {
text: "Heatmap" text: "Heatmap"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52 Layout.preferredWidth: 52
} }
Slider { PanelSlider {
id: heatmapSlider id: heatmapSlider
from: 0 from: 0
to: 1 to: 1
value: 0 value: 0
Layout.fillWidth: true Layout.fillWidth: true
ToolTip.visible: hovered
ToolTip.text: Math.round(value * 100) + "%"
handle: Rectangle { AppToolTip {
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width) visible: heatmapSlider.hovered
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2 text: Math.round(heatmapSlider.value * 100) + "%"
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: heatmapSlider.pressed ? 1.15 : 1.0
Behavior on scale {
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
}
} }
} }
Label { Label {
text: Math.round(heatmapSlider.value * 100) + "%" text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
Layout.preferredWidth: 32 Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight horizontalAlignment: Text.AlignRight
@@ -400,38 +203,23 @@ ColumnLayout {
} }
} }
Rectangle { // ── THRESHOLDS ──────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: thresholdCard.implicitHeight + 24 Layout.preferredHeight: thresholdCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: thresholdCard id: thresholdCard
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 6 spacing: 6
Label { SectionTitle { text: "THRESHOLDS" }
text: "THRESHOLDS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
Label { Label {
text: "Set Point (°C)" text: "Set Point (°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
TextField { TextField {
id: spField id: spField
@@ -461,7 +249,7 @@ ColumnLayout {
Label { Label {
text: "Margin (±°C)" text: "Margin (±°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
TextField { TextField {
id: mgField id: mgField
@@ -492,7 +280,7 @@ ColumnLayout {
id: autoCheck id: autoCheck
text: "Auto range (mean ± 1σ)" text: "Auto range (mean ± 1σ)"
checked: true checked: true
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
onCheckedChanged: pushThresholds() onCheckedChanged: pushThresholds()
} }
} }
@@ -0,0 +1,39 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// Small labeled value tile — DTW readout panel, scrubber readout, and any
// other compact label/value stat display share this one shape.
Rectangle {
id: readoutStat
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: readoutStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: readoutStat.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: readoutStat.valueColor
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Run Chart =====
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
// the strip below shows the aggregate min/max/avg over the *visible* range
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
Item {
id: root
property alias sensorNames: chart.sensorNames
property alias seriesData: chart.seriesData
property alias title: chart.title
ColumnLayout {
anchors.fill: parent
spacing: 8
Item {
Layout.fillWidth: true
Layout.fillHeight: true
GraphQuickItem {
id: chart
anchors.fill: parent
showMinMaxMarkers: true
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
}
MouseArea {
id: interaction
anchors.fill: parent
acceptedButtons: Qt.LeftButton
property real lastX: 0
onWheel: function(wheel) {
var frac = Math.max(0, Math.min(1, wheel.x / width));
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
chart.zoomAtFraction(frac, factor);
wheel.accepted = true;
}
onPressed: function(mouse) {
lastX = mouse.x;
}
onPositionChanged: function(mouse) {
if (pressed && width > 0) {
chart.panByFraction(-(mouse.x - lastX) / width);
lastX = mouse.x;
}
}
}
}
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
Label {
text: "Min:"
color: Theme.sensorLow
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Max:"
color: Theme.sensorHigh
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Avg:"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewAvg.toFixed(2)
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Item { Layout.fillWidth: true }
Button {
id: resetZoomBtn
implicitHeight: 32
hoverEnabled: true
text: "Reset Zoom"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
background: Rectangle {
radius: Theme.radiusSm
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
}
contentItem: Text {
text: resetZoomBtn.text
color: Theme.headingColor
font: resetZoomBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: chart.resetZoom()
}
}
}
}
@@ -0,0 +1,12 @@
import QtQuick
import QtQuick.Controls
import ISC
// Uppercase card heading — shared by the Data tab cards.
Label {
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
color: Theme.sideMutedText
}
@@ -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
+64 -45
View File
@@ -46,24 +46,9 @@ ColumnLayout {
csvEditorDialog.open(); csvEditorDialog.open();
} }
ToolTip { AppToolTip {
id: editTooltip
visible: editBtn.hovered visible: editBtn.hovered
text: "Edit" text: "Edit"
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: editTooltip.text
color: Theme.headingColor
font: editTooltip.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
} }
} }
@@ -84,24 +69,9 @@ ColumnLayout {
} }
onClicked: file_browser.refreshFiles() onClicked: file_browser.refreshFiles()
ToolTip { AppToolTip {
id: refreshTooltip
visible: refreshBtn.hovered visible: refreshBtn.hovered
text: "Refresh" text: "Refresh"
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: refreshTooltip.text
color: Theme.headingColor
font: refreshTooltip.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
} }
} }
} }
@@ -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)
}
}
}
}
}
+65 -116
View File
@@ -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 {
text: "Frame"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Medium font.weight: Font.Medium
leftPadding: 8 anchors.verticalCenter: parent.verticalCenter
}
TextField {
id: frameInput
text: String(streamController.frameIndex + 1)
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.family: Theme.codeFontFamily
color: Theme.headingColor
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
padding: 0
width: Math.max(44, contentWidth + 16)
height: 28
anchors.verticalCenter: parent.verticalCenter
background: Rectangle {
color: Theme.fieldBackground
radius: Theme.radiusXs
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
border.width: 1
}
onEditingFinished: {
var val = parseInt(text)
if (!isNaN(val)) {
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
streamController.seek(targetFrame - 1)
}
text = String(streamController.frameIndex + 1)
}
Connections {
target: streamController
function onFrameUpdated() {
if (!frameInput.activeFocus) {
frameInput.text = String(streamController.frameIndex + 1)
}
}
}
}
Label {
text: "/ " + streamController.frameTotal
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
} }
// Separator 1 // Separator 1
@@ -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
}
}
}
}
}
+13 -3
View File
@@ -7,7 +7,9 @@ Item {
id: root id: root
property real blend: 0.0 property real blend: 0.0
property bool showLabels: true property bool showLabels: true
property alias showExtremes: map.showExtremes
property alias showThickness: map.showThickness property alias showThickness: map.showThickness
property alias hasThickness: map.hasThickness
WaferMapItem { WaferMapItem {
id: map id: map
@@ -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);
} }
} }
+17 -1
View File
@@ -6,5 +6,21 @@ TransportBar 1.0 TransportBar.qml
WaferMapView 1.0 WaferMapView.qml WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.qml RailActionButton 1.0 RailActionButton.qml
SelectFileDialog 1.0 SelectFileDialog.qml
SplitDialog 1.0 SplitDialog.qml SplitDialog 1.0 SplitDialog.qml
StreamStatsDialog 1.0 StreamStatsDialog.qml StreamControlPanel 1.0 StreamControlPanel.qml
StatusActionsPanel 1.0 StatusActionsPanel.qml
ConnectionFooter 1.0 ConnectionFooter.qml
UtilityFooter 1.0 UtilityFooter.qml
PanelCheckBox 1.0 PanelCheckBox.qml
ReadoutStat 1.0 ReadoutStat.qml
MetricRow 1.0 MetricRow.qml
AppToolTip 1.0 AppToolTip.qml
SectionTitle 1.0 SectionTitle.qml
PanelBox 1.0 PanelBox.qml
PanelSlider 1.0 PanelSlider.qml
EditableScrubStat 1.0 EditableScrubStat.qml
ComparisonChartCard 1.0 ComparisonChartCard.qml
OverlapMapCard 1.0 OverlapMapCard.qml
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
RunChart 1.0 RunChart.qml
-1
View File
@@ -5,4 +5,3 @@ WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml SettingsTab 1.0 SettingsTab.qml
StatusTab 1.0 StatusTab.qml StatusTab 1.0 StatusTab.qml
SelectFileDialog 1.0 SelectFileDialog.qml
+2 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import logging import logging
import sys import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import QQmlApplicationEngine
@@ -8,11 +9,22 @@ from PySide6.QtWidgets import QApplication
from pygui.backend.controllers.device_controller import DeviceController from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.constants import default_data_dir
from pygui.backend.data.file_browser import FileBrowser from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel from pygui.backend.data.local_settings_model import LocalSettingsModel
from pygui.backend.license.license_model import LicenseModel
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer). # Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
from pygui.backend.visualization import (
graph_quick_item as graph_quick_item, # noqa: F401
)
from pygui.backend.visualization import (
trend_chart_item as trend_chart_item, # noqa: F401
)
from pygui.backend.visualization import (
wafer_map_item as wafer_map_item, # noqa: F401
)
# ===== Application Entry Point ===== # ===== Application Entry Point =====
@@ -30,34 +42,49 @@ def main() -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
engine = QQmlApplicationEngine() engine = QQmlApplicationEngine()
# ===== Shared Models ===== # ===== Shared Settings =====
settings_model = LocalSettingsModel() # One LocalSettings instance, one disk read. LocalSettingsModel and
# DeviceController both hold this same object, so a save from the
# Settings tab is visible to the device layer immediately — no reload,
# no second copy to go stale.
data_dir = str(default_data_dir())
raw_settings = LocalSettings.read_settings(data_dir)
settings_model = LocalSettingsModel(raw_settings, data_dir)
select_file_dialog_model = FileBrowser() select_file_dialog_model = FileBrowser()
settings_model.loadSettings() settings_model.loadSettings()
engine.rootContext().setContextProperty("settingsModel", settings_model) engine.rootContext().setContextProperty("settingsModel", settings_model)
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model) engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
# App version from package metadata so the About dialog can't drift
# from pyproject.toml.
try:
app_version = version("pygui")
except PackageNotFoundError:
app_version = "dev"
engine.rootContext().setContextProperty("appVersion", app_version)
# ===== Device Controller (serial comm) ===== # ===== Device Controller (serial comm) =====
data_dir = str(settings_model._data_dir) # TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
raw_settings = LocalSettings.read_settings(data_dir) # license_lookup=license_model.levelForWafer into the ctor.
# THINKING: controller must answer "is this serial licensed?" at
# read/erase time (P2.1); a bound Callable[[str], str] avoids a
# LicenseModel import in the controller and keeps it testable with a
# lambda. See docs/pending/license-gating-plan.md §1.1.
device_controller = DeviceController(raw_settings, data_dir) device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller) engine.rootContext().setContextProperty("deviceController", device_controller)
# Source panel browses the same folder recordings/reads are saved to.
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
# ===== License Model (About dialog grid, replay trial) =====
license_model = LicenseModel(data_dir)
engine.rootContext().setContextProperty("licenseModel", license_model)
# ===== Session Controller (live/review wafer dashboard) ===== # ===== Session Controller (live/review wafer dashboard) =====
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {} stream_controller = SessionController()
stream_controller = SessionController(settings=raw_settings_dict)
engine.rootContext().setContextProperty("streamController", stream_controller) engine.rootContext().setContextProperty("streamController", stream_controller)
# Persist session state back to settings when it changes
def _persist_session_settings():
session_state = stream_controller.collect_settings()
for k, v in session_state.items():
if hasattr(raw_settings, k):
setattr(raw_settings, k, v)
LocalSettings.save_settings(data_dir, raw_settings)
stream_controller.settingsChanged.connect(_persist_session_settings)
# ===== QML Startup ===== # ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the # The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir. # package directory is the import path the engine searches for qmldir.
-26
View File
@@ -1,26 +0,0 @@
# ===== Backend Package =====
# Backward-compatible re-exports so existing callers keep working
# while imports are migrated to the new sub-package paths.
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
from pygui.backend.data.data_records import read_data_records # noqa: F401
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
from pygui.backend.models.frame import Frame # noqa: F401
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
from pygui.backend.models.session_model import SessionModel # noqa: F401
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
@@ -1,5 +0,0 @@
# ===== Controllers Sub-package =====
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
__all__ = ["DeviceController", "SessionController"]
@@ -13,24 +13,20 @@ from typing import Any, Optional
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.models.data_model import TemperatureTableModel from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.visualization.graph_view import GraphView from pygui.backend.visualization.graph_view import GraphView
from pygui.serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
convert_to_debug_temperatures,
convert_to_temperatures, convert_to_temperatures,
parse_binary_data, parse_binary_data,
remove_trailing_zeros, remove_trailing_zeros,
save_debug_csv,
save_to_csv, save_to_csv,
) )
from pygui.serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
# import pygui.backend.wafer_map_item
stream_controller = SessionController()
# engine.rootContext().setContextProperty("streamController", stream_controller)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -44,6 +40,7 @@ class DeviceController(QObject):
portsUpdated = Signal(list) portsUpdated = Signal(list)
connectionStatusChanged = Signal() connectionStatusChanged = Signal()
operationInProgressChanged = Signal() operationInProgressChanged = Signal()
waferDetectedChanged = Signal()
selectedPortChanged = Signal() selectedPortChanged = Signal()
detectResult = Signal(object) # WaferInfo dict or None detectResult = Signal(object) # WaferInfo dict or None
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str} readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
@@ -73,7 +70,17 @@ class DeviceController(QObject):
self._activity_log: list[str] = [] self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv") self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
# (default lambda s: "") and add:
# def _wafer_license_level(self) -> str:
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
# THINKING: single shared guard — per-call-site checks are the
# sibling-caller bug (fix read, forget erase). Empty serial → ""
# → denied = safe default. Called by P2.1.
# See docs/pending/license-gating-plan.md §1.2.
self._last_wafer_info: dict[str, Any] = {} self._last_wafer_info: dict[str, Any] = {}
self._wafer_detected: bool = False
self._last_csv_path: str = "" self._last_csv_path: str = ""
self._selected_port: str = "" self._selected_port: str = ""
self._data_row_count: int = 0 self._data_row_count: int = 0
@@ -116,6 +123,11 @@ class DeviceController(QObject):
"""Whether a hardware operation is currently running.""" """Whether a hardware operation is currently running."""
return self._operation_in_progress return self._operation_in_progress
@Property(str, notify=activityLogUpdated)
def activityLog(self) -> str:
"""Full activity log text; survives QML tab reloads."""
return "\n".join(self._activity_log)
@Property(str, notify=activityLogUpdated) @Property(str, notify=activityLogUpdated)
def saveDataDir(self) -> str: def saveDataDir(self) -> str:
"""Directory where parsed CSV data files are saved.""" """Directory where parsed CSV data files are saved."""
@@ -129,6 +141,14 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}") self._append_log(f"Save data dir set to: {path}")
self._save_status() self._save_status()
@Slot(result=str)
@slot_error_boundary
def exportDir(self) -> str:
"""Ensure and return the Export subfolder under saveDataDir."""
path = Path(self._save_data_dir) / "Export"
path.mkdir(parents=True, exist_ok=True)
return str(path)
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def openCsvFile(self, file_path: str) -> None: def openCsvFile(self, file_path: str) -> None:
@@ -204,6 +224,17 @@ class DeviceController(QObject):
info.get("cycleCount", 0), info.get("cycleCount", 0),
] ]
@Property(bool, notify=waferDetectedChanged)
def waferDetected(self) -> bool:
"""Whether a wafer is currently detected and ready for read/erase."""
return self._wafer_detected
def _set_wafer_detected(self, detected: bool) -> None:
if self._wafer_detected != detected:
self._wafer_detected = detected
self.waferDetectedChanged.emit()
@Property(object, notify=detectResult) @Property(object, notify=detectResult)
def dataModel(self) -> TemperatureTableModel: def dataModel(self) -> TemperatureTableModel:
"""QAbstractTableModel for the parsed temperature data table.""" """QAbstractTableModel for the parsed temperature data table."""
@@ -268,6 +299,7 @@ class DeviceController(QObject):
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
self._selected_port = "" self._selected_port = ""
self._last_wafer_info = {} self._last_wafer_info = {}
self._set_wafer_detected(False)
self._data_row_count = 0 self._data_row_count = 0
self._data_col_count = 0 self._data_col_count = 0
self._raw_bytes = None self._raw_bytes = None
@@ -324,6 +356,7 @@ class DeviceController(QObject):
self._graph_view.resetChart() self._graph_view.resetChart()
self._activity_log.clear() self._activity_log.clear()
self.detectResult.emit(None) self.detectResult.emit(None)
self.selectedPortChanged.emit()
self.parsedDataReady.emit({"success": False}) self.parsedDataReady.emit({"success": False})
self._set_operation_progress(True) self._set_operation_progress(True)
@@ -349,19 +382,28 @@ class DeviceController(QObject):
self, port: Optional[str], info: Optional[WaferInfo] self, port: Optional[str], info: Optional[WaferInfo]
) -> None: ) -> None:
"""Main-thread completion handler for detectWafer.""" """Main-thread completion handler for detectWafer."""
try:
if info is not None: if info is not None:
self._selected_port = port or "" # Build the dict (can raise) before declaring success, so a
self._set_connection_status("Connected") # bad payload can't leave status="Connected" while
# waferDetected stays false and Read/Erase stay greyed.
wafer_dict = self._wafer_info_to_dict(info) wafer_dict = self._wafer_info_to_dict(info)
self._selected_port = port or ""
self._last_wafer_info = wafer_dict self._last_wafer_info = wafer_dict
self._set_wafer_detected(True)
self._set_connection_status("Connected")
self.detectResult.emit(wafer_dict) self.detectResult.emit(wafer_dict)
self.selectedPortChanged.emit()
self._save_status() self._save_status()
else: else:
self._selected_port = "" self._selected_port = ""
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port") self._append_log("No wafer detected on any port")
self._last_wafer_info = {} self._last_wafer_info = {}
self._set_wafer_detected(False)
self.detectResult.emit(None) self.detectResult.emit(None)
self.selectedPortChanged.emit()
finally:
self._set_operation_progress(False) self._set_operation_progress(False)
@Slot() @Slot()
@@ -377,6 +419,16 @@ class DeviceController(QObject):
self._append_log("Already busy — ignoring read request") self._append_log("Already busy — ignoring read request")
return return
# TODO P2.1: license guard —
# if self._wafer_license_level() == "":
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
# self.readResult.emit({"error": "Wafer not licensed"})
# return
# THINKING: this slot is the trust boundary; the QML enabled
# binding (P2.2) is UX only. Reuses existing readResult error
# shape → zero new signals. Depends on P1.2. Same guard goes in
# eraseMemory (emit eraseResult there). See plan §2.1.
if not self._selected_port: if not self._selected_port:
self._append_log("No wafer detected — run Detect Wafer first") self._append_log("No wafer detected — run Detect Wafer first")
self.readResult.emit({"error": "No port — detect wafer first"}) self.readResult.emit({"error": "No port — detect wafer first"})
@@ -436,6 +488,9 @@ class DeviceController(QObject):
if self._operation_in_progress: if self._operation_in_progress:
self._append_log("Already busy — ignoring erase request") self._append_log("Already busy — ignoring erase request")
return return
# TODO P2.1: same license guard as readMemoryAsync, but emit
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
self._set_operation_progress(True) self._set_operation_progress(True)
self._set_connection_status("Erasing...") self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
@@ -502,10 +557,29 @@ class DeviceController(QObject):
self._debugFinished.emit({"error": "F1 read failed"}) self._debugFinished.emit({"error": "F1 read failed"})
return return
fc = (
self._last_wafer_info.get("familyCode", "")
if self._last_wafer_info
else ""
)
sensor_hex = parse_binary_data(sensor_data, fc)
debug_hex = parse_binary_data(debug_data, fc)
if not sensor_hex or not debug_hex:
self._debugFinished.emit({"error": "Debug binary parse failed"})
return
temp_data = convert_to_temperatures(sensor_hex, fc)
debug_temp_data = convert_to_debug_temperatures(debug_hex)
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
if csv_path is None:
self._debugFinished.emit({"error": "Debug CSV save failed"})
return
self._debugFinished.emit({ self._debugFinished.emit({
"success": True, "success": True,
"sensor_bytes": len(sensor_data), "sensor_bytes": len(sensor_data),
"debug_bytes": len(debug_data), "debug_bytes": len(debug_data),
"csv_path": csv_path,
}) })
except Exception as exc: except Exception as exc:
log.exception("Debug worker crashed: %s", exc) log.exception("Debug worker crashed: %s", exc)
@@ -519,10 +593,11 @@ class DeviceController(QObject):
self._set_connection_status("Connected") self._set_connection_status("Connected")
sensor_bytes = result.get("sensor_bytes", 0) sensor_bytes = result.get("sensor_bytes", 0)
debug_bytes = result.get("debug_bytes", 0) debug_bytes = result.get("debug_bytes", 0)
self._append_log( csv_path = result.get("csv_path", "")
f"Debug read complete: {sensor_bytes} sensor bytes, " msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
f"{debug_bytes} debug bytes" if csv_path:
) msg += f", CSV written to {csv_path}"
self._append_log(msg)
else: else:
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
err_msg = result.get("error", "Debug read failed") err_msg = result.get("error", "Debug read failed")
@@ -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 = []
try:
if is_official_csv(file_path): if is_official_csv(file_path):
sensors, records = read_official_csv(file_path) sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records) frames = frames_from_wafer_data(None, records)
else: else:
try:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
msg = f"Could not parse {Path(file_path).name}: {exc}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return
if data is None or not data.sensors: if data is None or not data.sensors:
msg = f"Could not parse {Path(file_path).name}" self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
records = read_data_records(file_path) records = read_data_records(file_path)
sensors = data.sensors sensors = data.sensors
frames = frames_from_wafer_data(data, records) frames = frames_from_wafer_data(data, records)
if not sensors: 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)
self.loadFileError.emit(msg)
return return
if not frames: except Exception as exc:
# A well-formed header with zero data rows below it — most commonly log.warning("Could not parse file %s: %s", file_path, exc)
# a live recording that was stopped before any frames arrived. self.loadFileError.emit(f"Load error: {exc}")
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()
-2
View File
@@ -1,2 +0,0 @@
# ===== Crypto Sub-package =====
from pygui.backend.crypto.crypto_helper import * # noqa: F403
-14
View File
@@ -1,14 +0,0 @@
# ===== Data Sub-package =====
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
__all__ = [
"CsvRecorder", "CSVFileMetadata",
"read_data_records", "is_official_csv", "read_official_csv",
"FileBrowser",
"LocalSettings", "LocalSettingsModel",
]
+11
View File
@@ -1,3 +1,14 @@
# ===== Backend Data Constants ===== # ===== Backend Data Constants =====
from pathlib import Path
from PySide6.QtCore import QStandardPaths
DEFAULT_DATA_DIR_NAME = "isc_data" DEFAULT_DATA_DIR_NAME = "isc_data"
def default_data_dir() -> Path:
"""Documents-folder isc_data directory used as the default app data location."""
documents_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DocumentsLocation)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
+2 -2
View File
@@ -19,8 +19,8 @@ class CSVFileMetadata:
"""Return the first character of the wafer string, or empty if none""" """Return the first character of the wafer string, or empty if none"""
return self.wafer[0] if self.wafer else "" return self.wafer[0] if self.wafer else ""
def get_date(self) -> datetime: def get_date(self) -> datetime | None:
"""Parsifes the date string. Returns current time if parsing fails""" """Parsifes the date string. Returns None if parsing fails"""
date_format = "%Y-%m-%d %H:%M:%S" date_format = "%Y-%m-%d %H:%M:%S"
try: try:
return datetime.strptime(self.date, date_format) return datetime.strptime(self.date, date_format)
+44 -8
View File
@@ -1,4 +1,5 @@
"""Append live frames""" """Append live frames"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -14,12 +15,10 @@ 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="")
@@ -50,6 +49,12 @@ class CsvRecorder:
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool: def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
"""Copy a range of frames from source CSV into a new file. """Copy a range of frames from source CSV into a new file.
Preserves the source's header structure so the segment re-imports
through FileBrowser unchanged: official CSVs keep their sensor-name
row, Z-wafer CSVs keep every metadata line through the "data"
sentinel. Frame indices count only valid data rows, matching how
the readers in data_records.py number frames.
Args: Args:
file_path: Output CSV path. file_path: Output CSV path.
source_path: Source CSV path with full wafer data source_path: Source CSV path with full wafer data
@@ -57,11 +62,42 @@ class CsvRecorder:
end_frame: Last frame index (inclusive) end_frame: Last frame index (inclusive)
Returns: Returns:
True on success. True on success (at least one frame written).
""" """
import pandas as pd from pygui.backend.data.data_records import is_data_row, is_official_csv
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
return True
if start_frame < 0 or end_frame < start_frame:
return False
with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines()
out: list[str] = []
written = 0
frame_idx = -1
in_data = is_official_csv(source_path)
for i, line in enumerate(lines):
stripped = line.rstrip().rstrip(",").strip()
if not in_data:
out.append(line)
if stripped and stripped.split(",")[0].lower() == "data":
in_data = True
continue
if i == 0:
# official format: row 0 is the sensor-name header
out.append(line)
continue
if not stripped or not is_data_row(stripped):
continue
frame_idx += 1
if start_frame <= frame_idx <= end_frame:
out.append(line if line.endswith("\n") else line + "\n")
written += 1
if written == 0:
return False
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out)
return True
+17 -5
View File
@@ -1,4 +1,5 @@
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV.""" """Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@@ -6,6 +7,20 @@ from pathlib import Path
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
def is_data_row(line: str) -> bool:
"""True if *line* is a single CSV row of numeric values (no headers/metadata)."""
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
return bool(cols) and all(_is_float(c) for c in cols)
def _is_float(s: str) -> bool:
try:
float(s)
except ValueError:
return False
return True
def read_data_records(file_path: str) -> list[DataRecord]: def read_data_records(file_path: str) -> list[DataRecord]:
records: list[DataRecord] = [] records: list[DataRecord] = []
in_data = False in_data = False
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
if line.split(",")[0].lower() == "data": if line.split(",")[0].lower() == "data":
in_data = True in_data = True
continue continue
if not is_data_row(line):
continue
cols = [c.strip() for c in line.split(",") if c.strip() != ""] cols = [c.strip() for c in line.split(",") if c.strip() != ""]
try:
nums = [float(c) for c in cols] nums = [float(c) for c in cols]
except ValueError:
continue
if not nums:
continue
records.append(DataRecord(time=nums[0], values=nums[1:])) records.append(DataRecord(time=nums[0], values=nums[1:]))
return records return records
+18 -13
View File
@@ -5,10 +5,10 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox from PySide6.QtWidgets import QFileDialog, QMessageBox
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME from pygui.backend.data.constants import default_data_dir
from pygui.backend.data.csv_file_metadata import CSVFileMetadata from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
self._refresh_files(show_empty_message=False) self._refresh_files(show_empty_message=False)
# ===== Exposed Properties ===== # ===== Exposed Properties =====
@Property("QVariantList", notify=filesChanged) @Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
def files(self) -> list[dict[str, object]]: def files(self) -> list[dict[str, object]]:
return [dict(row) for row in self._files] return [dict(row) for row in self._files]
@@ -37,12 +37,22 @@ class FileBrowser(QObject):
return str(self._current_directory) return str(self._current_directory)
# ===== Directory Selection ===== # ===== Directory Selection =====
@Slot(str)
def setCurrentDirectory(self, path: str) -> None:
"""Point the browser at `path` without opening a native dialog.
Used to keep this directory in sync with DeviceController.saveDataDir
when the user picks a save folder elsewhere in the app.
"""
self._set_current_directory(Path(path))
self._refresh_files(show_empty_message=False)
@Slot() @Slot()
def chooseDirectory(self) -> None: def chooseDirectory(self) -> None:
selected_dir = QFileDialog.getExistingDirectory( selected_dir = QFileDialog.getExistingDirectory(
None, None,
"Select CSV Folder", "Select CSV Folder",
self.currentDirectory, str(self._current_directory),
) )
if not selected_dir: if not selected_dir:
return return
@@ -71,11 +81,10 @@ class FileBrowser(QObject):
self._show_error(f"CSV file was not found:\n{file_path}") self._show_error(f"CSV file was not found:\n{file_path}")
return return
# ponytail: row is absent when saving metadata for a CSV just read from
# the wafer (not in the browser listing) — still write the sidecar.
row = self._find_row(file_path) row = self._find_row(file_path)
if row is None: if row is not None:
self._show_error("The selected CSV row is no longer available.")
return
row["wafer"] = wafer row["wafer"] = wafer
row["date"] = date row["date"] = date
row["chamber"] = chamber row["chamber"] = chamber
@@ -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)
+2 -5
View File
@@ -13,8 +13,8 @@ class LocalSettings:
# Configuration settings # Configuration settings
self.chamber_id = "" self.chamber_id = ""
self.reverse_z_wafer = False self.reverse_z_wafer = False
self.master = {} # Dict[str, str] self.master: dict[str, str] = {}
self.wafer_data_size = {} # Dict[str, int] self.wafer_data_size: dict[str, int] = {}
self.debug = False self.debug = False
self.wafer_read_retries = 8 self.wafer_read_retries = 8
# Timeout in msec for reading from the wafer (default 2 min) # Timeout in msec for reading from the wafer (default 2 min)
@@ -32,9 +32,6 @@ class LocalSettings:
self.data_col_count = 0 self.data_col_count = 0
self.last_csv_path = "" self.last_csv_path = ""
# Session persistence
self.session_last_file = ""
# ===== File Path Helpers ===== # ===== File Path Helpers =====
@classmethod @classmethod
def _settings_path(cls, directory: str) -> Path: def _settings_path(cls, directory: str) -> Path:
+78 -82
View File
@@ -2,17 +2,26 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z") MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
class LocalSettingsModel(QObject): class LocalSettingsModel(QObject):
# Draft attrs are seeded via setattr() in __init__ from _new_defaults();
# declared here so mypy knows their types.
_chamber_id: str
_reverse_z_wafer: bool
_debug_mode: bool
_wafer_read_timeout: int
_wafer_detect_timeout: int
_wafer_retries: int
_masters: dict[str, str]
chamberIdChanged = Signal() chamberIdChanged = Signal()
reverseZWaferChanged = Signal() reverseZWaferChanged = Signal()
debugModeChanged = Signal() debugModeChanged = Signal()
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
saveStatusChanged = Signal() saveStatusChanged = Signal()
lastSavedAtChanged = Signal() lastSavedAtChanged = Signal()
def __init__(self, parent: QObject | None = None) -> None: # Draft attr (this class) <-> LocalSettings attr (the shared, persisted
# instance also held by DeviceController) <-> coercer applied on both
# load and revert/reset. One table drives load/save/revert/reset instead
# of four hand-written field-by-field copies.
_FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
("_chamber_id", "chamber_id", str),
("_reverse_z_wafer", "reverse_z_wafer", bool),
("_debug_mode", "debug", bool),
("_wafer_read_timeout", "wafer_read_timeout", int),
("_wafer_detect_timeout", "wafer_detect_timeout", int),
("_wafer_retries", "wafer_read_retries", int),
)
def __init__(
self,
settings: LocalSettings,
data_dir: Path | str,
parent: QObject | None = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self._data_dir = self._resolve_data_dir() # Shared with DeviceController — saving here must mutate this same
# instance, not a throwaway copy, or the device layer keeps using
# stale config until restart.
self._settings = settings
self._data_dir = Path(data_dir)
self._defaults = self._new_defaults() self._defaults = self._new_defaults()
self._chamber_id = self._defaults["chamberId"] for draft_attr, default_value in self._defaults.items():
self._reverse_z_wafer = self._defaults["reverseZWafer"] setattr(self, draft_attr, deepcopy(default_value))
self._debug_mode = self._defaults["debugMode"]
self._wafer_read_timeout = self._defaults["waferReadTimeout"]
self._wafer_detect_timeout = self._defaults["waferDetectTimeout"]
self._wafer_retries = self._defaults["waferRetries"]
self._masters = deepcopy(self._defaults["masters"])
self._is_dirty = False self._is_dirty = False
self._is_valid = True self._is_valid = True
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
def _resolve_data_dir(self) -> Path:
documents_dir = QStandardPaths.writableLocation(
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
def _new_defaults(self) -> dict[str, Any]: def _new_defaults(self) -> dict[str, Any]:
return { return {
"chamberId": "2", "_chamber_id": "2",
"reverseZWafer": False, "_reverse_z_wafer": False,
"debugMode": False, "_debug_mode": False,
"waferReadTimeout": 120000, "_wafer_read_timeout": 120000,
"waferDetectTimeout": 5000, "_wafer_detect_timeout": 5000,
"waferRetries": 8, "_wafer_retries": 8,
"masters": {family: "" for family in MASTER_FAMILIES}, "_masters": {family: "" for family in MASTER_FAMILIES},
} }
def _snapshot(self) -> dict[str, Any]: def _snapshot(self) -> dict[str, Any]:
return { snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
"chamberId": self._chamber_id, snap["_masters"] = deepcopy(self._masters)
"reverseZWafer": self._reverse_z_wafer, return snap
"debugMode": self._debug_mode,
"waferReadTimeout": self._wafer_read_timeout,
"waferDetectTimeout": self._wafer_detect_timeout,
"waferRetries": self._wafer_retries,
"masters": deepcopy(self._masters),
}
def _set_is_dirty(self, value: bool) -> None: def _set_is_dirty(self, value: bool) -> None:
if self._is_dirty != value: if self._is_dirty != value:
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
self.waferRetriesChanged.emit() self.waferRetriesChanged.emit()
self.mastersChanged.emit() self.mastersChanged.emit()
def _to_local_settings(self) -> LocalSettings: def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
settings = LocalSettings() masters = {family: "" for family in MASTER_FAMILIES}
settings.chamber_id = self._chamber_id for family, value in source.items():
settings.reverse_z_wafer = self._reverse_z_wafer normalized = str(family).strip().upper()
settings.debug = self._debug_mode if normalized in masters:
settings.wafer_read_timeout = self._wafer_read_timeout masters[normalized] = str(value).strip()
settings.wafer_detect_timeout = self._wafer_detect_timeout return masters
settings.wafer_read_retries = self._wafer_retries
settings.master = deepcopy(self._masters)
return settings
@Property(str, notify=chamberIdChanged) @Property(str, notify=chamberIdChanged)
def chamberId(self) -> str: def chamberId(self) -> str:
return self._chamber_id return self._chamber_id
@chamberId.setter @chamberId.setter # type: ignore[no-redef]
def chamberId(self, value: str) -> None: def chamberId(self, value: str) -> None:
next_value = str(value).strip() next_value = str(value).strip()
if self._chamber_id == next_value: if self._chamber_id == next_value:
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
def reverseZWafer(self) -> bool: def reverseZWafer(self) -> bool:
return self._reverse_z_wafer return self._reverse_z_wafer
@reverseZWafer.setter @reverseZWafer.setter # type: ignore[no-redef]
def reverseZWafer(self, value: bool) -> None: def reverseZWafer(self, value: bool) -> None:
if self._reverse_z_wafer == value: if self._reverse_z_wafer == value:
return return
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
def debugMode(self) -> bool: def debugMode(self) -> bool:
return self._debug_mode return self._debug_mode
@debugMode.setter @debugMode.setter # type: ignore[no-redef]
def debugMode(self, value: bool) -> None: def debugMode(self, value: bool) -> None:
if self._debug_mode == value: if self._debug_mode == value:
return return
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
def waferReadTimeout(self) -> int: def waferReadTimeout(self) -> int:
return self._wafer_read_timeout return self._wafer_read_timeout
@waferReadTimeout.setter @waferReadTimeout.setter # type: ignore[no-redef]
def waferReadTimeout(self, value: int) -> None: def waferReadTimeout(self, value: int) -> None:
if self._wafer_read_timeout == value: if self._wafer_read_timeout == value:
return return
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
def waferDetectTimeout(self) -> int: def waferDetectTimeout(self) -> int:
return self._wafer_detect_timeout return self._wafer_detect_timeout
@waferDetectTimeout.setter @waferDetectTimeout.setter # type: ignore[no-redef]
def waferDetectTimeout(self, value: int) -> None: def waferDetectTimeout(self, value: int) -> None:
if self._wafer_detect_timeout == value: if self._wafer_detect_timeout == value:
return return
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
def waferRetries(self) -> int: def waferRetries(self) -> int:
return self._wafer_retries return self._wafer_retries
@waferRetries.setter @waferRetries.setter # type: ignore[no-redef]
def waferRetries(self, value: int) -> None: def waferRetries(self, value: int) -> None:
if self._wafer_retries == value: if self._wafer_retries == value:
return return
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def loadSettings(self) -> None: def loadSettings(self) -> None:
loaded = LocalSettings.read_settings(str(self._data_dir)) """Seed draft fields from the shared LocalSettings instance."""
self._chamber_id = str(loaded.chamber_id).strip() or str( for draft_attr, settings_attr, coerce in self._FIELD_MAP:
self._defaults["chamberId"] setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
) self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
self._reverse_z_wafer = bool(loaded.reverse_z_wafer) self._masters = self._normalized_masters(self._settings.master)
self._debug_mode = bool(loaded.debug)
self._wafer_read_timeout = int(loaded.wafer_read_timeout)
self._wafer_detect_timeout = int(loaded.wafer_detect_timeout)
self._wafer_retries = int(loaded.wafer_read_retries)
masters = {family: "" for family in MASTER_FAMILIES}
for family, value in loaded.master.items():
normalized = str(family).strip().upper()
if normalized in masters:
masters[normalized] = str(value).strip()
self._masters = masters
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._emit_all_changed() self._emit_all_changed()
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def saveSettings(self) -> None: def saveSettings(self) -> None:
"""Write draft fields onto the shared LocalSettings instance and persist it."""
self._recompute_derived() self._recompute_derived()
if not self._is_valid: if not self._is_valid:
self._set_save_status("error: invalid settings") self._set_save_status("error: invalid settings")
return return
try: try:
LocalSettings.save_settings(str(self._data_dir), self._to_local_settings()) for draft_attr, settings_attr, _coerce in self._FIELD_MAP:
setattr(self._settings, settings_attr, getattr(self, draft_attr))
self._settings.master = deepcopy(self._masters)
LocalSettings.save_settings(str(self._data_dir), self._settings)
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
self._set_save_status("Saved") self._set_save_status("Saved")
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def revertChanges(self) -> None: def revertChanges(self) -> None:
snap = self._saved_snapshot snap = self._saved_snapshot
self._chamber_id = str(snap["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(snap["reverseZWafer"]) setattr(self, draft_attr, coerce(snap[draft_attr]))
self._debug_mode = bool(snap["debugMode"]) self._masters = deepcopy(snap["_masters"])
self._wafer_read_timeout = int(snap["waferReadTimeout"])
self._wafer_detect_timeout = int(snap["waferDetectTimeout"])
self._wafer_retries = int(snap["waferRetries"])
self._masters = deepcopy(snap["masters"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def resetDefaults(self) -> None: def resetDefaults(self) -> None:
self._chamber_id = str(self._defaults["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"]) setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
self._debug_mode = bool(self._defaults["debugMode"]) self._masters = deepcopy(self._defaults["_masters"])
self._wafer_read_timeout = int(self._defaults["waferReadTimeout"])
self._wafer_detect_timeout = int(self._defaults["waferDetectTimeout"])
self._wafer_retries = int(self._defaults["waferRetries"])
self._masters = deepcopy(self._defaults["masters"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -0,0 +1,126 @@
"""C#-compatible wafer license file parsing.
License files are AES-128-CBC encrypted `.bin` blobs named
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
5-digit serial number, and ``00`` the 2-digit license level. The blob is
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
a fixed-offset record::
[0:6] serial e.g. "A00001"
[7:15] mfg date 8 hex chars
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
[19:27] license date 8 hex chars
The key is hardcoded to match the C# application exactly
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from pygui.backend.crypto.crypto_helper import CryptoHelper
log = logging.getLogger(__name__)
LICENSE_KEY = b"ThisIsA16ByteKey"
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
_MIN_TEXT_LEN = 27
# ===== License Record =====
@dataclass(frozen=True)
class License:
"""One parsed license file (all fields as stored in the file)."""
serial: str # "A00001"
level: str # "00".."03"
mfg_date_hex: str # 8 hex chars
lic_date_hex: str # 8 hex chars
@property
def level_int(self) -> int:
"""Level as int; -1 if unparseable."""
try:
return int(self.level, 10)
except ValueError:
return -1
@property
def mfg_date_decimal(self) -> str:
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
return _hex_to_decimal(self.mfg_date_hex)
@property
def lic_date_decimal(self) -> str:
"""License date shown as decimal, same conversion as the C# grid."""
return _hex_to_decimal(self.lic_date_hex)
def _hex_to_decimal(hex_str: str) -> str:
try:
return str(int(hex_str, 16))
except ValueError:
return ""
# ===== Blob Parsing =====
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
if len(blob) < 32:
raise ValueError("License blob too short")
return blob[:-16], blob[-16:]
def parse_license_blob(blob: bytes) -> License | None:
"""Decrypt and parse one license blob. None if malformed."""
try:
data, iv = split_data_iv(blob)
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
text = message.decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
log.warning("Undecryptable license blob: %s", exc)
return None
if len(text) < _MIN_TEXT_LEN:
log.warning("License text too short (%d chars)", len(text))
return None
return License(
serial=text[0:6],
mfg_date_hex=text[7:15],
level=text[16:18],
lic_date_hex=text[19:27],
)
# ===== Directory Scan =====
def read_license_files(licenses_dir: str | Path) -> list[License]:
"""Parse every valid license .bin in the directory.
A file counts only if its name matches ``X00000-00.bin`` AND equals
``{serial}-{level}.bin`` from its own decrypted payload — same
tamper check as the C# app.
"""
directory = Path(licenses_dir)
if not directory.is_dir():
return []
licenses: list[License] = []
for path in sorted(directory.glob("*.bin")):
if not LICENSE_FILENAME_RE.match(path.name):
continue
try:
blob = path.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", path, exc)
continue
lic = parse_license_blob(blob)
if lic is None:
continue
if path.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License filename mismatch: %s", path.name)
continue
licenses.append(lic)
return licenses
+146
View File
@@ -0,0 +1,146 @@
"""QML-facing license model: grid rows, Load License, replay trial marker."""
import logging
import shutil
import time
from pathlib import Path
from urllib.parse import unquote, urlparse
from PySide6.QtCore import Property, QObject, Signal, Slot
from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE,
License,
parse_license_blob,
read_license_files,
)
log = logging.getLogger(__name__)
TRIAL_DAYS = 30
def _to_local_path(path_or_url: str) -> Path:
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
if path_or_url.startswith("file:"):
return Path(unquote(urlparse(path_or_url).path))
return Path(path_or_url)
# ===== License Model =====
class LicenseModel(QObject):
"""Context property backing the About dialog license grid.
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
``DataDir\\licenses``). The 30-day replay trial marker is
``<data_dir>/replay.lic``.
"""
licensesChanged = Signal()
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._data_dir = Path(data_dir)
self._licenses_dir = self._data_dir / "licenses"
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
self._licenses: list[License] = []
self.refresh()
# ── Grid rows ─────────────────────────────────────────────────────
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
def licenses(self) -> list:
"""Rows for the About grid: serial, mfg date, level, license date."""
return [
{
"serial": lic.serial,
"mfgDate": lic.mfg_date_decimal,
"level": lic.level,
"licDate": lic.lic_date_decimal,
}
for lic in self._licenses
]
@Slot()
def refresh(self) -> None:
"""Re-scan the licenses directory."""
self._licenses = read_license_files(self._licenses_dir)
self.licensesChanged.emit()
# ── Load License ──────────────────────────────────────────────────
@Slot(str, result=bool)
def loadLicenseFile(self, path_or_url: str) -> bool:
"""Validate a picked .bin and copy it into the licenses directory."""
src = _to_local_path(path_or_url)
if not src.is_file():
log.warning("License file does not exist: %s", src)
return False
if not LICENSE_FILENAME_RE.match(src.name):
log.warning("License filename invalid: %s", src.name)
return False
try:
blob = src.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", src, exc)
return False
lic = parse_license_blob(blob)
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License file failed validation: %s", src.name)
return False
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, self._licenses_dir / src.name)
except OSError as exc:
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
return False
self.refresh()
return True
# ── Wafer detect (informational) ──────────────────────────────────
@Slot(str, result=str)
def levelForWafer(self, serial: str) -> str:
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
for lic in self._licenses:
if lic.serial == serial:
return lic.level
return ""
# ── Replay trial (30-day marker, C# parity) ───────────────────────
@property
def _trial_marker(self) -> Path:
return self._data_dir / "replay.lic"
@Slot(result=str)
def replayLicenseState(self) -> str:
""""permanent" if any license level >= 1, "temporary" if trial
marker exists, else "none" (trial not started)."""
if any(lic.level_int >= 1 for lic in self._licenses):
return "permanent"
if self._trial_marker.exists():
return "temporary"
return "none"
@Slot(result=bool)
def startReplayTrial(self) -> bool:
"""Create the trial marker (idempotent). False on I/O failure."""
try:
self._trial_marker.touch(exist_ok=True)
return True
except OSError as exc:
log.warning("Cannot create trial marker: %s", exc)
return False
@Slot(result=bool)
def replayTrialExpired(self) -> bool:
"""True once the marker is older than 30 days (or missing)."""
marker = self._trial_marker
if not marker.exists():
return True
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
return age_days > TRIAL_DAYS
-19
View File
@@ -1,19 +0,0 @@
# ===== Models Sub-package =====
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.stability_detector import StabilityDetector
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
__all__ = [
"Frame", "FramePlayer", "frames_from_wafer_data",
"Stats", "compute_stats",
"SessionModel", "SessionUpdate",
"TemperatureTableModel",
"SensorEditor",
"ThresholdConfig", "classify", "resolve_bounds",
"StabilityDetector",
]
+2 -2
View File
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
# Column 0 = row index, columns 1..N = sensor values # Column 0 = row index, columns 1..N = sensor values
return self._col_count + 1 return self._col_count + 1
def data(self, index: Any, role: int = ...) -> Any: def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
if not index.isValid(): if not index.isValid():
return None return None
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
return None return None
def headerData( def headerData(
self, section: int, orientation: int, role: int = ... self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
) -> Any: ) -> Any:
if role != Qt.ItemDataRole.DisplayRole: if role != Qt.ItemDataRole.DisplayRole:
return None return None
+18 -1
View File
@@ -4,10 +4,23 @@ from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import ZWaferData from pygui.backend.wafer.zwafer_models import ZWaferData
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]: def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
"""Build Frames from parsed DataRecords (records = list[DataRecord].)""" """Build Frames from parsed DataRecords (records = list[DataRecord].)"""
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)] return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
"""Per-sensor value series across every frame, for the whole-run Graph tab.
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
"""
if not frames:
return [], []
n_sensors = len(frames[0].values)
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
return names, series
class FramePlayer: class FramePlayer:
def __init__(self) -> None: def __init__(self) -> None:
self._frames: list[Frame] =[] self._frames: list[Frame] =[]
@@ -22,6 +35,10 @@ class FramePlayer:
def total(self) -> int: def total(self) -> int:
return len(self._frames) return len(self._frames)
@property
def frames(self) -> tuple[Frame, ...]:
return tuple(self._frames)
@property @property
def index(self) -> int: def index(self) -> int:
return self._i return self._i
+38
View File
@@ -14,6 +14,44 @@ class Stats:
sigma: float; three_sigma: float sigma: float; three_sigma: float
@dataclass(frozen=True)
class EdgeCenterStats:
min_edge_index: int; min_center_index: int
min_edge: float; min_center: float; min_delta: float
max_edge_index: int; max_center_index: int
max_edge: float; max_center: float; max_delta: float
def compute_edge_center(
values: list[float],
pairs: tuple[tuple[int, int], ...],
excluded: set[int],
) -> EdgeCenterStats | None:
"""Min/max |edge - center| over the family's pair table.
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
NaN value are skipped. Returns None when no pair is computable.
"""
n = len(values)
best: list[tuple[float, int, int]] = []
for e, c in pairs:
if e in excluded or c in excluded or e >= n or c >= n:
continue
if math.isnan(values[e]) or math.isnan(values[c]):
continue
best.append((abs(values[e] - values[c]), e, c))
if not best:
return None
min_d, min_e, min_c = min(best)
max_d, max_e, max_c = max(best)
return EdgeCenterStats(
min_edge_index=min_e, min_center_index=min_c,
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
max_edge_index=max_e, max_center_index=max_c,
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
)
def compute_stats(values: list[float]) -> Stats: def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)] clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean: if not clean:
@@ -28,6 +28,11 @@ class SensorEditor:
def has_overrides(self) -> bool: def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets) return bool(self._replacements or self._offsets)
def replaced_indices(self) -> set[int]:
"""Sensors whose value is a replacement (offsets don't count — an
offset sensor still reads from its physical location)."""
return set(self._replacements)
def active_indices(self) -> list[int]: def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override.""" """Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets)) return sorted(set(self._replacements) | set(self._offsets))
@@ -1,11 +0,0 @@
# ===== Visualization Sub-package =====
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
from pygui.backend.visualization.graph_view import GraphView
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.visualization.trend_chart_item import TrendChartItem
from pygui.backend.visualization.wafer_map_item import WaferMapItem
__all__ = [
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
"interpolate_field",
]
@@ -0,0 +1,52 @@
"""Pure coordinate-mapping math shared by the QQuickPaintedItem chart items
(GraphQuickItem, TrendChartItem). No QPainter or Qt-widget dependency, so
it's testable without a GUI — unlike the paint() methods that call it.
"""
from __future__ import annotations
def value_to_pixel(
value: float,
value_min: float,
value_max: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map a value onto a pixel range, inverted so larger values sit at
smaller pixel coordinates (screen y grows downward).
Also used for evenly-spaced tick indices: pass the tick index as
`value` and `(tick_count - 1)` as `value_max` with `value_min=0`.
"""
value_range = value_max - value_min
if value_range < 1e-9:
return pixel_start + pixel_span / 2
fraction = 1.0 - (value - value_min) / value_range
return pixel_start + fraction * pixel_span
def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: float) -> float:
"""Map a 0-based sample index onto a pixel range, left to right."""
if count <= 1:
return pixel_start + pixel_span / 2
fraction = index / (count - 1)
return pixel_start + fraction * pixel_span
def elapsed_to_pixel(
elapsed: float,
window_start: float,
window_end: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map an elapsed-seconds value onto a pixel range, left to right.
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
at larger pixel x-coordinates, matching how time flows left-to-right.
"""
window_span = window_end - window_start
if window_span < 1e-9:
return pixel_start + pixel_span
fraction = (elapsed - window_start) / window_span
return pixel_start + fraction * pixel_span
@@ -21,15 +21,60 @@ Usage from QML::
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal import math
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
def viewport_stats(
series: list[list[float]],
names: list[str],
start: int,
end: int,
) -> tuple[float, float, float, str, str]:
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
every visible point from every series, plus which sensor achieved each
extreme. Non-numeric values are skipped. No points in range -> zeros and
empty sensor names.
"""
vmin = math.inf
vmax = -math.inf
total = 0.0
count = 0
min_sensor = ""
max_sensor = ""
for i, s in enumerate(series):
name = names[i] if i < len(names) else ""
lo = max(0, start)
hi = min(len(s) - 1, end)
for j in range(lo, hi + 1):
try:
v = float(s[j])
except (TypeError, ValueError):
continue
if v < vmin:
vmin = v
min_sensor = name
if v > vmax:
vmax = v
max_sensor = name
total += v
count += 1
if count == 0:
return (0.0, 0.0, 0.0, "", "")
return (vmin, vmax, total / count, min_sensor, max_sensor)
@QmlElement @QmlElement
class GraphQuickItem(QQuickPaintedItem): class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings.""" """Painted line chart; driven by series data passed via QML property bindings."""
@@ -40,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
xLabelChanged = Signal() xLabelChanged = Signal()
yLabelChanged = Signal() yLabelChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
viewportChanged = Signal()
viewStatsChanged = Signal()
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -72,16 +119,37 @@ class GraphQuickItem(QQuickPaintedItem):
self._max_y: float = 150.0 self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50} self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
# so the Graph tab (which never sets these) is unaffected. See
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
self._view_start: int = 0
self._view_end: int = -1
self._show_min_max_markers: bool = False
self._view_min: float = 0.0
self._view_max: float = 0.0
self._view_avg: float = 0.0
self._view_min_sensor: str = ""
self._view_max_sensor: str = ""
# ── Qt properties (QML-bindable) ────────────────────────────────────────── # ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged) @Property("QVariantList", notify=seriesDataChanged)
def seriesData(self) -> list: def seriesData(self) -> list:
return self._series_data return self._series_data
@seriesData.setter @seriesData.setter # type: ignore[no-redef]
def seriesData(self, val: list) -> None: def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])] self._series_data = [list(s) for s in (val or [])]
# New data resets the viewport to the full range -- matches
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
# before RecalculateAxesScale(). Without this, a stale viewport left
# over from a previous (longer) series can point entirely past the
# new data -- every per-series slice becomes empty and the chart
# silently renders nothing instead of the new data.
self._view_start = 0
self._view_end = -1
self._auto_range() self._auto_range()
self._recompute_view_stats()
self.seriesDataChanged.emit() self.seriesDataChanged.emit()
self.update() self.update()
@@ -89,17 +157,138 @@ class GraphQuickItem(QQuickPaintedItem):
def sensorNames(self) -> list: def sensorNames(self) -> list:
return self._sensor_names return self._sensor_names
@sensorNames.setter @sensorNames.setter # type: ignore[no-redef]
def sensorNames(self, val: list) -> None: def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or []) self._sensor_names = list(val or [])
self._recompute_view_stats()
self.sensorNamesChanged.emit() self.sensorNamesChanged.emit()
self.update() self.update()
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
@Property(int, notify=viewportChanged)
def viewStartIndex(self) -> int:
return self._view_start
@viewStartIndex.setter # type: ignore[no-redef]
def viewStartIndex(self, val: int) -> None:
v = max(0, int(val))
if v == self._view_start:
return
self._view_start = v
self._on_viewport_changed()
@Property(int, notify=viewportChanged)
def viewEndIndex(self) -> int:
return self._view_end
@viewEndIndex.setter # type: ignore[no-redef]
def viewEndIndex(self, val: int) -> None:
v = int(val)
if v == self._view_end:
return
self._view_end = v
self._on_viewport_changed()
@Property(bool, notify=viewportChanged)
def showMinMaxMarkers(self) -> bool:
return self._show_min_max_markers
@showMinMaxMarkers.setter # type: ignore[no-redef]
def showMinMaxMarkers(self, val: bool) -> None:
v = bool(val)
if v == self._show_min_max_markers:
return
self._show_min_max_markers = v
self.viewportChanged.emit()
self.update()
@Property(float, notify=viewStatsChanged)
def viewMin(self) -> float:
return self._view_min
@Property(float, notify=viewStatsChanged)
def viewMax(self) -> float:
return self._view_max
@Property(float, notify=viewStatsChanged)
def viewAvg(self) -> float:
return self._view_avg
@Property(str, notify=viewStatsChanged)
def viewMinSensor(self) -> str:
return self._view_min_sensor
@Property(str, notify=viewStatsChanged)
def viewMaxSensor(self) -> str:
return self._view_max_sensor
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
# with fractions derived from wheel/drag pixel deltas) ───────────────
@Slot(float, float)
def zoomAtFraction(self, frac: float, factor: float) -> None:
"""Zoom the viewport around a fractional X position within it.
`frac` in [0, 1] is where within the current viewport to zoom around;
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
(zoom out). Clamped to data bounds and a minimum 1-index window.
"""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
f = max(0.0, min(1.0, frac))
center = start + f * width
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
new_start = center - f * new_width
new_end = new_start + new_width
self._set_viewport_clamped(new_start, new_end, max_len)
@Slot(float)
def panByFraction(self, delta_frac: float) -> None:
"""Shift the viewport by `delta_frac * window_width` indices."""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
shift = delta_frac * width
self._set_viewport_clamped(start + shift, end + shift, max_len)
@Slot()
def resetZoom(self) -> None:
"""Restore the full-range viewport sentinel (0, -1)."""
self._set_viewport(0, -1)
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
if new_start < 0.0:
new_end -= new_start
new_start = 0.0
if new_end > max_len - 1:
new_start -= new_end - (max_len - 1)
new_end = float(max_len - 1)
new_start = max(0.0, new_start)
self._set_viewport(int(round(new_start)), int(round(new_end)))
def _set_viewport(self, start: int, end: int) -> None:
"""Set the viewport bounds directly (not through the QML-facing
property setters, so this stays a plain attribute write for mypy)."""
new_start = max(0, start)
if new_start == self._view_start and end == self._view_end:
return
self._view_start = new_start
self._view_end = end
self._on_viewport_changed()
@Property(str, notify=titleChanged) @Property(str, notify=titleChanged)
def title(self) -> str: def title(self) -> str:
return self._title return self._title
@title.setter @title.setter # type: ignore[no-redef]
def title(self, val: str) -> None: def title(self, val: str) -> None:
self._title = str(val or "") self._title = str(val or "")
self.titleChanged.emit() self.titleChanged.emit()
@@ -109,7 +298,7 @@ class GraphQuickItem(QQuickPaintedItem):
def xLabel(self) -> str: def xLabel(self) -> str:
return self._x_label return self._x_label
@xLabel.setter @xLabel.setter # type: ignore[no-redef]
def xLabel(self, val: str) -> None: def xLabel(self, val: str) -> None:
self._x_label = str(val or "") self._x_label = str(val or "")
self.xLabelChanged.emit() self.xLabelChanged.emit()
@@ -119,7 +308,7 @@ class GraphQuickItem(QQuickPaintedItem):
def yLabel(self) -> str: def yLabel(self) -> str:
return self._y_label return self._y_label
@yLabel.setter @yLabel.setter # type: ignore[no-redef]
def yLabel(self, val: str) -> None: def yLabel(self, val: str) -> None:
self._y_label = str(val or "") self._y_label = str(val or "")
self.yLabelChanged.emit() self.yLabelChanged.emit()
@@ -129,7 +318,7 @@ class GraphQuickItem(QQuickPaintedItem):
def showLegend(self) -> bool: def showLegend(self) -> bool:
return self._show_legend return self._show_legend
@showLegend.setter @showLegend.setter # type: ignore[no-redef]
def showLegend(self, val: bool) -> None: def showLegend(self, val: bool) -> None:
self._show_legend = bool(val) self._show_legend = bool(val)
self.colorsChanged.emit() self.colorsChanged.emit()
@@ -141,7 +330,7 @@ class GraphQuickItem(QQuickPaintedItem):
def backgroundColor(self) -> QColor: def backgroundColor(self) -> QColor:
return self._bg_color return self._bg_color
@backgroundColor.setter @backgroundColor.setter # type: ignore[no-redef]
def backgroundColor(self, c: QColor) -> None: def backgroundColor(self, c: QColor) -> None:
self._bg_color = c self._bg_color = c
self.update() self.update()
@@ -150,7 +339,7 @@ class GraphQuickItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
self._grid_color = c self._grid_color = c
self.update() self.update()
@@ -159,7 +348,7 @@ class GraphQuickItem(QQuickPaintedItem):
def axisColor(self) -> QColor: def axisColor(self) -> QColor:
return self._axis_color return self._axis_color
@axisColor.setter @axisColor.setter # type: ignore[no-redef]
def axisColor(self, c: QColor) -> None: def axisColor(self, c: QColor) -> None:
self._axis_color = c self._axis_color = c
self.update() self.update()
@@ -168,7 +357,7 @@ class GraphQuickItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
self._text_color = c self._text_color = c
self.update() self.update()
@@ -178,7 +367,7 @@ class GraphQuickItem(QQuickPaintedItem):
"""Return hex strings of the current series color palette.""" """Return hex strings of the current series color palette."""
return [c.name() for c in self._series_colors] return [c.name() for c in self._series_colors]
@seriesColors.setter @seriesColors.setter # type: ignore[no-redef]
def seriesColors(self, hex_list: list) -> None: def seriesColors(self, hex_list: list) -> None:
colors = [] colors = []
for h in (hex_list or []): for h in (hex_list or []):
@@ -192,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
# ── internal ────────────────────────────────────────────────────────────── # ── internal ──────────────────────────────────────────────────────────────
def _resolved_view_end(self) -> int:
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
max_len = max((len(s) for s in self._series_data), default=0)
if self._view_end < 0 or self._view_end > max_len - 1:
return max_len - 1
return self._view_end
def _on_viewport_changed(self) -> None:
self._auto_range()
self._recompute_view_stats()
self.viewportChanged.emit()
self.update()
def _recompute_view_stats(self) -> None:
end = self._resolved_view_end()
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
self._series_data, self._sensor_names, self._view_start, end
)
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
self.viewStatsChanged.emit()
def _auto_range(self) -> None: def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding.""" """Set Y range to cover the current viewport's data with 10% padding."""
start = max(0, self._view_start)
end = self._resolved_view_end()
all_vals: list[float] = [] all_vals: list[float] = []
for series in self._series_data: for series in self._series_data:
for v in series: hi = min(len(series) - 1, end)
for i in range(start, hi + 1):
try: try:
all_vals.append(float(v)) all_vals.append(float(series[i]))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not all_vals: if not all_vals:
@@ -275,8 +489,7 @@ class GraphQuickItem(QQuickPaintedItem):
for i in range(n_y_ticks): for i in range(n_y_ticks):
y_val = self._min_y + i * y_step y_val = self._min_y + i * y_step
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5 y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
y_px = plot_top + y_frac * plot_h
# Grid line # Grid line
painter.setPen(grid_pen) painter.setPen(grid_pen)
@@ -318,19 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
self._x_label, self._x_label,
) )
# --- X-axis ticks --- # --- Viewport slice (replay chart zoom/pan; full range by default,
num_points = max(len(s) for s in self._series_data) if self._series_data else 0 # so unset viewport reproduces the pre-viewport rendering) ---
if num_points > 1: view_start = max(0, self._view_start)
n_x_ticks = min(num_points, max(2, int(plot_w // 80))) view_end = self._resolved_view_end()
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1) view_len = max(1, view_end - view_start + 1)
# --- X-axis ticks (labelled in original, non-sliced index space) ---
if view_len > 1:
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks): for i in range(n_x_ticks):
idx = int(round(i * x_tick_step)) rel_idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1) x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
x_px = plot_left + x_frac * plot_w
painter.setPen(axis_pen) painter.setPen(axis_pen)
painter.drawText( painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14), int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1), str(view_start + rel_idx + 1),
) )
# --- Axes boundary --- # --- Axes boundary ---
@@ -339,30 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length --- # --- Draw each series (sliced to the current viewport) ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0 min_pt: tuple[float, float] | None = None
if max_len < 1: max_pt: tuple[float, float] | None = None
return min_seen = math.inf
max_seen = -math.inf
# --- Draw each series ---
for si, series in enumerate(self._series_data): for si, series in enumerate(self._series_data):
if len(series) < 1: hi = min(len(series) - 1, view_end)
sliced = series[view_start:hi + 1] if view_start <= hi else []
if not sliced:
continue continue
color = self._series_colors[si % len(self._series_colors)] color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2) pen = QPen(color, 2)
painter.setPen(pen) painter.setPen(pen)
pts: list[tuple[float, float]] = [] pts: list[tuple[float, float]] = []
for i, val in enumerate(series): for i, val in enumerate(sliced):
try: try:
v = float(val) v = float(val)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
x_frac = i / (max_len - 1) if max_len > 1 else 0.5 x_px = index_to_pixel(i, view_len, plot_left, plot_w)
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5 y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
x_px = plot_left + x_frac * plot_w
y_px = plot_top + y_frac * plot_h
pts.append((x_px, y_px)) pts.append((x_px, y_px))
if self._show_min_max_markers:
if v < min_seen:
min_seen = v
min_pt = (x_px, y_px)
if v > max_seen:
max_seen = v
max_pt = (x_px, y_px)
if len(pts) < 2: if len(pts) < 2:
if len(pts) == 1: if len(pts) == 1:
@@ -375,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
int(pts[i + 1][0]), int(pts[i + 1][1]), int(pts[i + 1][0]), int(pts[i + 1][1]),
) )
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
if self._show_min_max_markers:
marker_font = QFont()
marker_font.setPixelSize(14)
marker_font.setBold(True)
painter.setFont(marker_font)
if max_pt is not None:
painter.setPen(QPen(QColor("#FF5757")))
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "")
if min_pt is not None:
painter.setPen(QPen(QColor("#42A5F5")))
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "")
# --- Legend --- # --- Legend ---
if self._show_legend and self._sensor_names: if self._show_legend and self._sensor_names:
legend_font = QFont() legend_font = QFont()
@@ -23,6 +23,9 @@ class GraphView(QObject):
each sensor as a separate line series. each sensor as a separate line series.
""" """
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
_trend_line: Any
# ---- signals ---- # ---- signals ----
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list} dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
@@ -227,7 +230,7 @@ class GraphView(QObject):
index_a, index_b = path[i] index_a, index_b = path[i]
if index_a < len(a) and index_b < len(b): if index_a < len(a) and index_b < len(b):
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1, line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
style=Qt.DashLine)) style=Qt.PenStyle.DashLine))
self._plot_widget.addItem(line) self._plot_widget.addItem(line)
self._plot_widget.setTitle("DTW Comparison") self._plot_widget.setTitle("DTW Comparison")
@@ -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
+170 -45
View File
@@ -14,7 +14,7 @@ import logging
import math import math
import numpy as np import numpy as np
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import ( from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
@@ -27,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]
-12
View File
@@ -1,12 +0,0 @@
# ===== Wafer Sub-package =====
from pygui.backend.wafer.family_spec import sensor_count_for
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
from pygui.backend.wafer.zwafer_parser import ZWaferParser
__all__ = [
"ZWaferData", "Sensor", "DataRecord",
"ZWaferParser",
"load_layout", "available_families",
"sensor_count_for",
]
+31 -7
View File
@@ -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")]
+1 -1
View File
@@ -115,7 +115,7 @@ class ZWaferParser:
y_coords: Optional[list], y_coords: Optional[list],
) -> None: ) -> None:
"""Build sensor list from layout arrays.""" """Build sensor list from layout arrays."""
if not all([labels, x_coords, y_coords]): if not (labels and x_coords and y_coords):
raise ValueError("Sensor layout section is incomplete or missing.") raise ValueError("Sensor layout section is incomplete or missing.")
if len(labels) != len(x_coords) or len(labels) != len(y_coords): if len(labels) != len(x_coords) or len(labels) != len(y_coords):
+70 -1
View File
@@ -10,6 +10,7 @@ Mirrors the C# Form1.cs binary parsing pipeline:
import logging import logging
import os import os
from datetime import datetime
from typing import Optional from typing import Optional
from pygui.backend.wafer.family_spec import sensor_count_for from pygui.backend.wafer.family_spec import sensor_count_for
@@ -106,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
return result return result
def _convert_debug_temp(hex_str: str) -> float:
"""Convert a 4-char hex string to a debug/cold-junction temperature.
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
2**(i-8), sign bit at bit 0. No family-code branching — this formula
applies regardless of wafer family, unlike _convert_hex_to_temp.
"""
bits = _hex_to_binary(hex_str)
value = 0.0
for i in range(4, 15):
if bits[i]:
value += 2.0 ** (i - 8)
if bits[0]:
value = -value
return round(value, 2)
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float: def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
"""Convert a singi hale 4-char hex string to a float temperature.""" """Convert a singi hale 4-char hex string to a float temperature."""
bits = _hex_to_binary(hex_str) bits = _hex_to_binary(hex_str)
@@ -224,6 +242,15 @@ def convert_to_temperatures(
return temp_value return temp_value
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
"""Convert debug/cold-junction hex values to temperature strings.
Same shape contract as convert_to_temperatures, but uses the
family-independent debug formula (_convert_debug_temp).
"""
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
def remove_trailing_zeros(data: list[list[str]]) -> None: def remove_trailing_zeros(data: list[list[str]]) -> None:
"""Remove rows of all-zero values from the end of data (in-place). """Remove rows of all-zero values from the end of data (in-place).
@@ -266,7 +293,6 @@ def save_to_csv(
try: try:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
# Build filename: P00001-20260505_133045.csv # Build filename: P00001-20260505_133045.csv
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{serial_number}-{timestamp}.csv" filename = f"{serial_number}-{timestamp}.csv"
@@ -294,3 +320,46 @@ def save_to_csv(
except Exception as exc: except Exception as exc:
log.error("CSV save failed: %s", exc) log.error("CSV save failed: %s", exc)
return None return None
def save_debug_csv(
temp_data: list[list[str]],
debug_data: list[list[str]],
output_dir: str,
) -> Optional[str]:
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
truncated to the shorter of the two row/column counts. Fixed filename
(not timestamped), unlike save_to_csv.
Args:
temp_data: 2D array of sensor temperature strings (D1, decoded via
convert_to_temperatures).
debug_data: 2D array of debug temperature strings (F1, decoded via
convert_to_debug_temperatures).
output_dir: Directory to save the CSV file.
Returns:
Full file path on success, None on empty input or write failure.
"""
if not temp_data or not debug_data:
log.error("Debug CSV save failed: temp or debug data is empty")
return None
try:
os.makedirs(output_dir, exist_ok=True)
num_cols = min(len(temp_data[0]), len(debug_data[0]))
num_rows = min(len(temp_data), len(debug_data))
filepath = os.path.join(output_dir, "debug_info.csv")
with open(filepath, "w", encoding="utf-8") as f:
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
f.write(",".join(headers) + "\n")
for i in range(num_rows):
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
f.write(",".join(row) + "\n")
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
return filepath
except Exception as exc:
log.error("Debug CSV save failed: %s", exc)
return None
+4
View File
@@ -65,6 +65,10 @@ class SerialPort:
) )
if timeout is not None: if timeout is not None:
kwargs["timeout"] = timeout kwargs["timeout"] = timeout
# Write timeout must never be None (= block forever): on macOS,
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
# hang the write, which froze detect in "Detecting..." permanently.
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
try: try:
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs) return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
+5 -5
View File
@@ -45,7 +45,7 @@ class StreamReader:
Reads up to 256 bytes in one shot. If nothing is immediately Reads up to 256 bytes in one shot. If nothing is immediately
available, yields briefly to avoid a busy spin. available, yields briefly to avoid a busy spin.
""" """
chunk = self._transport.read(max(min_bytes, 256)) chunk: bytes = self._transport.read(max(min_bytes, 256))
if not chunk: if not chunk:
time.sleep(0.005) time.sleep(0.005)
return chunk return chunk
@@ -106,7 +106,7 @@ class StreamReader:
except Exception: except Exception:
pass pass
def _run_binary(self, initial_bytes: bytes) -> None: def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
log.info("StreamReader: starting binary stream parsing") log.info("StreamReader: starting binary stream parsing")
buf = bytearray(initial_bytes) buf = bytearray(initial_bytes)
resync_attempts = 0 resync_attempts = 0
@@ -149,7 +149,7 @@ class StreamReader:
payload = buf[7:7 + payload_len] payload = buf[7:7 + payload_len]
try: try:
frame = self._parse(payload, seq) frame = self._parse(payload, seq) # type: ignore[arg-type]
self._on_frame(frame) self._on_frame(frame)
except Exception as exc: except Exception as exc:
self.error_count += 1 self.error_count += 1
@@ -238,7 +238,7 @@ class StreamReader:
for hex_val in valid_hex_words: for hex_val in valid_hex_words:
try: try:
swapped = hex_val[2:4] + hex_val[0:2] swapped = hex_val[2:4] + hex_val[0:2]
t = _convert_hex_to_temp(swapped, self._family_code) t = _convert_hex_to_temp(swapped, self._family_code) # type: ignore[arg-type]
values.append(t) values.append(t)
except Exception: except Exception:
values.append(0.0) values.append(0.0)
@@ -262,7 +262,7 @@ class StreamReader:
line = line_bytes.decode('utf-8', errors='ignore').strip() line = line_bytes.decode('utf-8', errors='ignore').strip()
if line: if line:
try: try:
frame = self._parse(line, seq) frame = self._parse(line, seq) # type: ignore[arg-type]
if frame: if frame:
self._on_frame(frame) self._on_frame(frame)
seq += 1 seq += 1
+11
View File
@@ -0,0 +1,11 @@
Wafer ID=A12
Acquisition Date=03/26/2025
Label,1,2,3
X (mm),0,10,-10
Y (mm),10,-10,-10
data
0.0,150.0,149.5,151.6
0.5,150.2,149.4,151.7
1.0,150.1,149.6,151.5
1.5,150.3,149.7,151.4
2.0,150.4,149.8,151.3
1 Wafer ID=A12
2 Acquisition Date=03/26/2025
3 Label,1,2,3
4 X (mm),0,10,-10
5 Y (mm),10,-10,-10
6 data
7 0.0,150.0,149.5,151.6
8 0.5,150.2,149.4,151.7
9 1.0,150.1,149.6,151.5
10 1.5,150.3,149.7,151.4
11 2.0,150.4,149.8,151.3
+52
View File
@@ -0,0 +1,52 @@
import pytest
from pygui.backend.visualization.chart_geometry import (
elapsed_to_pixel,
index_to_pixel,
value_to_pixel,
)
def test_value_to_pixel_min_maps_to_bottom():
assert value_to_pixel(0.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(210.0)
def test_value_to_pixel_max_maps_to_top():
assert value_to_pixel(100.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(10.0)
def test_value_to_pixel_midpoint():
assert value_to_pixel(50.0, 0.0, 100.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_value_to_pixel_degenerate_range_centers():
assert value_to_pixel(5.0, 5.0, 5.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_index_to_pixel_first_and_last():
assert index_to_pixel(0, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(0.0)
assert index_to_pixel(4, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(100.0)
def test_index_to_pixel_single_point_centers():
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
def test_elapsed_to_pixel_window_start_maps_to_left():
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
def test_elapsed_to_pixel_window_end_maps_to_right():
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
def test_elapsed_to_pixel_midpoint():
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_elapsed_to_pixel_degenerate_window_pins_right():
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
+67
View File
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
assert records[0].values == [149.0, 148.0] assert records[0].values == [149.0, 148.0]
assert records[1].time == 0.5 assert records[1].time == 0.5
assert records[1].values == [149.5, 148.5] assert records[1].values == [149.5, 148.5]
# ===== write_segment (P3.3) =====
def _make_zwafer_csv(tmp_path, rows=5):
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
path = tmp_path / "src.csv"
rec = CsvRecorder()
rec.start(str(path), sensors, serial="A12")
for i in range(rows):
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
rec.stop()
return path
def test_write_segment_zwafer_slice(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=5)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
records = read_data_records(str(dest))
assert len(records) == 3
assert records[0].values == [101.0, 201.0]
assert records[-1].values == [103.0, 203.0]
# header survives → still parses as a wafer CSV
data, _ = ZWaferParser().parse(str(dest))
assert data is not None
assert [s.label for s in data.sensors] == ["1", "2"]
def test_write_segment_full_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=4)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
assert len(read_data_records(str(dest))) == 4
def test_write_segment_out_of_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
assert not dest.exists()
def test_write_segment_invalid_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
def test_write_segment_official_csv(tmp_path):
# official format: row 0 = sensor names, rows 1+ = values only
src = tmp_path / "A00001-x.csv"
src.write_text(
"Sensor1,Sensor2\n"
"10.0,20.0\n"
"11.0,21.0\n"
"12.0,22.0\n",
encoding="utf-8",
)
dest = tmp_path / "A00001-x_seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
lines = dest.read_text(encoding="utf-8").splitlines()
assert lines[0] == "Sensor1,Sensor2"
assert lines[1] == "11.0,21.0"
assert lines[2] == "12.0,22.0"
assert len(lines) == 3
+97
View File
@@ -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()
+67
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
# docs/pending/license-gating-plan.md). Two cases:
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
# emits readResult {"error": ...} and spawns no thread.
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
# reorders the busy/license checks or renames the "serialNumber" key.
# Depends on P1.2 + P2.1 being implemented.
+13
View File
@@ -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"
+22 -1
View File
@@ -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]]
+46 -1
View File
@@ -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
+289
View File
@@ -0,0 +1,289 @@
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
import pytest
pytestmark = pytest.mark.usefixtures("qapp")
def test_no_data_initially():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
assert item.seriesData == []
assert item.sensorNames == []
def test_series_data_setter_stores_series():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
def test_series_data_auto_ranges_y_with_padding():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
# min=100, max=200, range=100, 10% pad => [90, 210]
assert item._min_y == pytest.approx(90.0)
assert item._max_y == pytest.approx(210.0)
def test_series_data_empty_keeps_default_range():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = []
assert item._min_y == 0.0
assert item._max_y == 150.0
def test_sensor_names_setter_stores_names():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.sensorNames = ["Sensor 1", "Sensor 2"]
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
def test_non_numeric_values_are_skipped_not_crashing():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, "bad", 151.0]]
assert item._min_y == pytest.approx(149.9)
assert item._max_y == pytest.approx(151.1)
# ---- viewport_stats (replay chart min/max/avg readouts) ----
# Independently hand-computed against PopupChartForm.cs's recalc_stats
# semantics: aggregate across every sensor's points in [start, end], plus
# which sensor achieved each extreme.
def test_viewport_stats_full_range_aggregate():
from pygui.backend.visualization.graph_quick_item import viewport_stats
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
names = ["Sensor 1", "Sensor 2"]
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
assert vmin == pytest.approx(147.0)
assert vmax == pytest.approx(152.0)
assert avg == pytest.approx(149.25)
assert min_sensor == "Sensor 2"
assert max_sensor == "Sensor 1"
def test_viewport_stats_restricted_to_subrange():
from pygui.backend.visualization.graph_quick_item import viewport_stats
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
names = ["Sensor 1", "Sensor 2"]
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
assert vmin == pytest.approx(147.5)
assert vmax == pytest.approx(151.0)
assert avg == pytest.approx(149.125)
assert min_sensor == "Sensor 2"
assert max_sensor == "Sensor 1"
def test_viewport_stats_empty_series_returns_zeros():
from pygui.backend.visualization.graph_quick_item import viewport_stats
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
def test_viewport_stats_skips_non_numeric():
from pygui.backend.visualization.graph_quick_item import viewport_stats
series = [[150.0, "bad", 151.0]]
names = ["Sensor 1"]
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
assert vmin == pytest.approx(150.0)
assert vmax == pytest.approx(151.0)
assert avg == pytest.approx(150.5)
assert min_sensor == "Sensor 1"
assert max_sensor == "Sensor 1"
# ---- GraphQuickItem viewport properties (replay chart) ----
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
# tab (which never sets these) is unaffected — see docs/adr/0004.
def test_viewport_defaults_to_whole_range_and_markers_off():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
assert item.showMinMaxMarkers is False
def test_view_stats_reflect_default_full_range_viewport():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.sensorNames = ["Sensor 1", "Sensor 2"]
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
assert item.viewMin == pytest.approx(147.0)
assert item.viewMax == pytest.approx(152.0)
assert item.viewAvg == pytest.approx(149.25)
assert item.viewMinSensor == "Sensor 2"
assert item.viewMaxSensor == "Sensor 1"
def test_view_stats_restrict_to_narrowed_viewport():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.sensorNames = ["Sensor 1", "Sensor 2"]
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
item.viewStartIndex = 0
item.viewEndIndex = 1
assert item.viewMin == pytest.approx(147.5)
assert item.viewMax == pytest.approx(151.0)
assert item.viewAvg == pytest.approx(149.125)
def test_auto_range_y_restricted_to_narrowed_viewport():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
assert item._min_y == pytest.approx(90.0)
assert item._max_y == pytest.approx(210.0)
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
item.viewStartIndex = 0
item.viewEndIndex = 0
assert item._min_y == pytest.approx(145.0)
assert item._max_y == pytest.approx(205.0)
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
def _eleven_point_item():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[float(i) for i in range(11)]]
return item
def test_zoom_in_centers_on_fraction():
item = _eleven_point_item()
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
item.zoomAtFraction(0.5, 0.4)
assert item.viewStartIndex == 3
assert item.viewEndIndex == 7
def test_zoom_out_clamps_to_data_bounds():
item = _eleven_point_item()
item.viewStartIndex = 3
item.viewEndIndex = 7
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
# new_width=12 exceeds the data's max width of 10, so it clamps to the
# full range (0, 10).
item.zoomAtFraction(0.5, 3.0)
assert item.viewStartIndex == 0
assert item.viewEndIndex == 10
def test_pan_shifts_viewport_by_fraction_of_width():
item = _eleven_point_item()
item.viewStartIndex = 4
item.viewEndIndex = 8
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
item.panByFraction(0.5)
assert item.viewStartIndex == 6
assert item.viewEndIndex == 10
def test_pan_clamps_at_right_edge_preserving_width():
item = _eleven_point_item()
item.viewStartIndex = 4
item.viewEndIndex = 8
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
# out of bounds (max index 10), so the whole window shifts back by 2 to
# stay in bounds while keeping width=4: (6,10).
item.panByFraction(1.0)
assert item.viewStartIndex == 6
assert item.viewEndIndex == 10
def test_reset_zoom_restores_full_range_sentinel():
item = _eleven_point_item()
item.viewStartIndex = 3
item.viewEndIndex = 7
item.resetZoom()
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
def test_zoom_noop_when_fewer_than_two_points():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[42.0]]
item.zoomAtFraction(0.5, 0.5)
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
"""Regression: zooming near the tail of a long run, then loading a new
(shorter) file, left the stale viewport pointing past the new data --
every per-series slice became empty and the chart went blank with a
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
the viewport to the full range (PopupChartForm.cs's SetData() parity).
"""
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[float(i) for i in range(20)]]
item.viewStartIndex = 15
item.viewEndIndex = 19
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
assert item.viewMin == pytest.approx(100.0)
assert item.viewMax == pytest.approx(104.0)
assert item.viewAvg == pytest.approx(102.0)
+45
View File
@@ -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
+195
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
import pytest
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
@pytest.fixture
def shared_settings():
return LocalSettings()
@pytest.fixture
def model(qapp, tmp_path, shared_settings):
return LocalSettingsModel(shared_settings, tmp_path)
def test_save_mutates_the_shared_instance_in_place(model, shared_settings):
# This is the bug candidate 1 fixes: DeviceController holds the same
# LocalSettings object, so a save here must be visible to it without
# a reload — no throwaway copy, no stale read until restart.
model.chamberId = "7"
model.waferReadTimeout = 45000
model.saveSettings()
assert shared_settings.chamber_id == "7"
assert shared_settings.wafer_read_timeout == 45000
def test_save_persists_to_disk_and_reload_round_trips(tmp_path, qapp, shared_settings):
model = LocalSettingsModel(shared_settings, tmp_path)
model.chamberId = "9"
model.waferRetries = 3
model.saveSettings()
reloaded_settings = LocalSettings.read_settings(str(tmp_path))
other_model = LocalSettingsModel(reloaded_settings, tmp_path)
other_model.loadSettings()
assert other_model.chamberId == "9"
assert other_model.waferRetries == 3
def test_revert_restores_last_saved_values(model):
model.chamberId = "2"
model.saveSettings()
model.chamberId = "unsaved-edit"
assert model.isDirty is True
model.revertChanges()
assert model.chamberId == "2"
assert model.isDirty is False
def test_invalid_chamber_id_blocks_save(model, shared_settings):
shared_settings.chamber_id = "before"
model.chamberId = ""
model.saveSettings()
assert model.saveStatus.startswith("error:")
assert shared_settings.chamber_id == "before" # unsaved, shared instance untouched
+174 -119
View File
@@ -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
+82
View File
@@ -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
+29 -1
View File
@@ -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
View File
@@ -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()
-2
View File
@@ -1,2 +0,0 @@
{
"_mock_return_value":
Generated
+211 -46
View File
@@ -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 = [