Compare commits

...

54 Commits

Author SHA1 Message Date
jack 9e3bec9031 feat(debug): add debug read with CSV export
- Parse F1 debug data via family-independent temp formula
  (bits 4-14 scaled by 2**(i-8), sign at bit 0)
- Save sensor+debug temps side-by-side to debug_info.csv
- Wire READ DEBUG button in StatusActionsPanel
- Rename ReplayChart → RunChart; move into GraphTab
- Extract PanelBox/SectionTitle/PanelSlider in ReadoutPanel
  to cut ~300 lines of duplicated styling
- Add rightRailWidth token to Theme for consistent rail sizing
- Wrap ReadoutPanel in ScrollView so Thresholds card stays
  reachable when E-C delta rows appear
2026-07-11 18:39:29 -07:00
jack fed4d9b590 feat: replay chart with zoomable viewpor 2026-07-10 20:54:24 -07:00
jack 4bb855a940 feat(graph): add graph tab for whole-run data
Provides a static line chart plotting multi-sensor temperature values
over time when a session file is loaded.
2026-07-10 20:06:48 -07:00
jack 94f917b116 feat(map): add edge-center delta stats, thickness overlay, and layout fix
- Add edge-center sensor pair tables and delta calculations per wafer family.
- Parse and interpolate wafer thickness CSV data for offscreen visualization.
- Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping.
- Compact E-C Delta text format to fit standard sidebar width.
- Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
2026-07-10 17:32:12 -07:00
jack 25fa7507ce feat(map): add min/max highlighting toggle and live safeguards
- Add "Highlight Min/Max" option to ReadoutPanel showing peak rings.
- Reset to review mode and blank wafer map when switching away from map tab.
- Disable hover highlights and sensor dialog triggers during live stream.
- Lower trendPane height and add top margin headroom for tick labels.
- Add test coverage for blanking states, toggle overrides, and interactions.
2026-07-10 16:28:55 -07:00
jack 034f13b717 feat(map): add peak highlights and live metrics to PNG export
- Draw rings around hottest and coldest sensors on export.
- Extend export_image to support second line for live metrics.
- Format detailed sensor stats in export footer.
- Add test coverage for footer rows, overlays, and stats.
2026-07-10 15:39:30 -07:00
jack 69753e35f9 feat(map): add batch export and adjust layout alignment
- Implement synchronous offscreen wafer map PNG rendering and CSV summary.
- Add "Batch Export" button to file browser sidebar.
- Adjust trend pane bottom spacer to align with About button when visible.
- Fix type check errors and add pytest coverage for batch export.
2026-07-10 15:22:36 -07:00
jack 278a48a2e4 feat(transport): editable frame field + uniform spacing 2026-07-10 14:38:12 -07:00
jack e2ce8778e0 feat(files): sync Source panel browser with chosen save directory 2026-07-10 14:37:29 -07:00
jack bdc667b2ed refactor: extract StreamControlPanel and MetricRow from WaferMapTab 2026-07-09 15:16:11 -07:00
jack b6249c2b8e fix: added property var settingsPopup: null / property var aboutDialog: null to UtilityFooter.qml and updated onClicked to use local properties instead of utilFooter.parent.*. 2026-07-09 14:30:19 -07:00
jack 2fe8aef805 feat(ui): refine AboutDialog layout and add nav pill tooltips 2026-07-09 13:56:48 -07:00
jack 4ee139ad8f fix: The QML cross-component id resolution for root.waferDetected was failing
silently. Exposing a proper waferDetected property on the Python
DeviceController ensures reliable gating, and resetting it on refresh
blocks the actions until the next successful detection.
2026-07-09 13:07:31 -07:00
jack 7b20ffa376 refactor(qml): finalize tooltip standardization and settings module imports 2026-07-09 12:50:24 -07:00
jack ac4448ed44 refactor(backend): clean up package re-exports, settings model properties, and unused signals 2026-07-09 12:49:33 -07:00
jack ca158a7946 refactor(qml): extract sidebar panels and data tab comparison cards to modular components 2026-07-09 12:49:10 -07:00
jack 637b51b999 fix(qml): resolve runtime reference errors, missing imports, and port change notifications 2026-07-09 12:47:56 -07:00
jack bb9b5d709a feat: add global ToolTip styling to HomePage 2026-07-08 00:31:46 -07:00
jack b6903af625 feat: redesign StatusTab UI with bento-grid layout and add CSV metadata editing capabilities 2026-07-07 16:41:40 -07:00
jack 92f130b3bd feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab 2026-07-07 15:05:22 -07:00
jack 1e03227788 style: increase ReadoutPanel font sizes and add CSV test fixture for data logging 2026-07-07 12:10:47 -07:00
jack 7df7fd4c6f feat: implement C#-compatible AES-128-CBC license file parsing and integrate license management UI into AboutDialog 2026-07-07 11:15:06 -07:00
jack e2d05d2c33 refactor: relocate live stream controls to ReadoutPanel and update chart axis rendering 2026-07-07 10:38:42 -07:00
jack 799155f249 refactor: optimize RBF heatmap performance by decoupling grid resolution from viewport size and update UI file handling. 2026-07-06 21:02:22 -07:00
jack 015642eea0 chore: apply linting fixes and expand ruff coverage to test suite 2026-07-06 15:52:53 -07:00
jack d63332d619 feat: add load error signaling and comparison validation 2026-07-06 15:43:13 -07:00
jack 88b0214582 refactor(ui): consolidate Theme.qml tokens and replace unicode glyphs with SVG icons 2026-07-06 15:42:18 -07:00
jack 0014ccc184 fix: gate DTW comparisons on wafer type + recording category; surface load errors 2026-07-06 15:41:29 -07:00
jack 1989ba8e5a feat: add vector icons for transport controls, implement marker hover effects, and update dialog positioning 2026-07-06 13:51:45 -07:00
jack 915720085b refactor: StreamReader ASCII decoder uses shared sensor_count_for() 2026-07-06 13:26:03 -07:00
jack e2d71662ff refactor: data_parser binary parsers use shared sensor_count_for() 2026-07-06 13:24:46 -07:00
jack 52cc747536 chore: remove dead, contradictory EXPECTED_HEX_LENGTHS from DeviceService 2026-07-06 13:21:00 -07:00
jack 185e66ea3a feat: introduce family_spec logic for wafer sensor-count partitioning 2026-07-06 13:19:47 -07:00
jack 3b919f576f fix: comparison results never reached QML; restore click-box run selection 2026-07-06 13:12:58 -07:00
jack f604308466 feat: rebuild Data tab compare view 2026-07-06 13:01:24 -07:00
jack 0f813b2376 chore: remove legacy GraphTab.qml component 2026-07-06 12:48:12 -07:00
jack 6996df73f8 feat: add recording toggle, stream stats UI 2026-07-06 12:44:43 -07:00
jack 3cc10ea7fe feat: add file comparison, data splitting, and sensor modification functionality to SessionController 2026-07-06 11:58:26 -07:00
jack ecab3d81b1 fix: resolve CSV data truncation in save_to_csv by correctly identifying column widths and remove redundant logic in ZWaferParser 2026-07-04 16:46:44 -07:00
jack 9e28a25695 refactor: implement thread-safe hardware operations, port caching, and refined state management in the device controller 2026-07-02 22:33:44 -07:00
jack da43aeb1f6 refactor: simplify StatusTab UI layout 2026-06-30 22:15:54 -07:00
jack 0a2d6b78ba refactor: update UI layout for wafer data display and add file browser auto-refresh on tab switch 2026-06-30 22:15:14 -07:00
jack 0d5cf9e4ff test: add device controller test 2026-06-30 13:24:19 -07:00
jack 27292c3bdb chore: cleanup
WaferMapTab stale comments + minor component fixes
2026-06-30 13:24:00 -07:00
jack d7962f2850 feat: wire family
code display and status restore in StatusTab + HomePage
2026-06-30 13:22:44 -07:00
jack 5105ab1ab6 feat(tabs): update all tab views, dialogs, and session controller 2026-06-29 14:58:10 -07:00
jack db21f201f6 feat(transport): redesign transport bar 2026-06-29 14:57:52 -07:00
jack c9cb7ab8d8 feat(panels): redesign ReadoutPanel and SourcePanel to match mockup
ReadoutPanel:
- Move section headers inside card containers
- Redesign readout table with uppercase labels, code fonts, color mapping
- Add full-width dividers, fix vertical spacing with fillHeight spacer
- Unify typography with Theme tokens

SourcePanel:
- Bump font sizes to new standard (fontMd primary, fontSm secondary)
- Replace unicode button text with edit.svg/refresh.svg IconImages
- Add styled hover tooltips on edit and refresh buttons
2026-06-29 14:57:13 -07:00
jack 5d079174e0 feat(home): redesign left rail with pill tab bar, connection flow, and action panels
- Add animated pill tab bar (STATUS / DATA / MAP)
- Restructure context panel with StackLayout for per-tab content
- Redesign CONNECTION FLOW widget: remove dotted trail, add status
  dot + descriptive text, neutral gray mode chip
- Add HARDWARE ACTIONS and DATA OPERATIONS panels
- Add settings/about footer with icon buttons
2026-06-29 14:56:57 -07:00
jack a54d536efe chore(icons): add edit and refresh SVGs, consolidate excel icon path
- Add edit.svg and refresh.svg icon assets
- Update excel.svg in canonical icons/ directory
- Remove duplicate icon from legacy icon/ directory
2026-06-29 14:56:26 -07:00
jack c622a4cc23 feat(components): add RailActionButton, AboutDialog, and register new types
- Add RailActionButton reusable side-rail button component
- Add AboutDialog popup component
- Register new types in qmldir files
- Register TransportBar in components qmldir
2026-06-29 14:56:16 -07:00
jack 3fef31efff feat(theme): update design tokens, typography scale, and color palette
- Add new tone palette entries and sensor color tokens
- Add codeFontFamily and uiFontFamily definitions
- Update motion/easing tokens
- Add transport bar, side rail, and status color tokens
- Revise geometry tokens (radius, spacing, sizing)
2026-06-29 14:55:50 -07:00
jack f9737259c1 add icons 2026-06-29 10:56:33 -07:00
jack bf201d6afb update readme 2026-06-25 13:51:01 -07:00
134 changed files with 9860 additions and 2635 deletions
+18
View File
@@ -40,3 +40,21 @@ dist/
*.spec *.spec
*.icns *.icns
*.ico *.ico
# Runtime scratch data
tmp/
# Superpowers brainstorming visual companion
.superpowers/
.llm-wiki/
.agents/
.pi/
# Planning / personal docs (root level)
CLAUDE.md
LEARNINGS.md
MIGRATION.md
AGENTS.md
CONVENTIONS.md
specs/
graphify-out/
+3 -3
View File
@@ -3,14 +3,14 @@
install: install:
uv sync uv sync
test: test: lint
uv run pytest tests/ uv run pytest tests/
lint: lint:
uv run ruff check src/ uv run ruff check src/ tests/
fix: fix:
uv run ruff check src/ --fix uv run ruff check src/ tests/ --fix
typecheck: typecheck:
uv run mypy src/ uv run mypy src/
+80 -1
View File
@@ -2,6 +2,38 @@
Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell. Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell.
## Key App Features
The `ISenseCloud` application provides a real-time monitor and analysis dashboard for silicon wafer temperature profiles:
- **Real-Time Data Streaming & Coalescing**: Decodes binary frame streams from thermal sensors over serial/COM connections at ~20 Hz with low CPU overhead.
- **RBF Heatmap Interpolation**: A Radial Basis Function (RBF) interpolation engine paints a smooth 2D temperature gradient across the wafer surface based on physical sensor locations.
- **Interactive Review & Playback**: A `FramePlayer` component supports variable-speed playback (play, pause, step, seek) of recorded runs.
- **Interactive Trend Charts**: Renders multi-series temperature trends using a custom QtQuick painted item (`GraphQuickItem`) with automatic scaling, custom legends, and grid layouts.
- **Tabular Data Inspection**: Direct inspection of tabular dataset grids for frame-by-frame diagnostic checkups.
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
### App Showcase
![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.*
> [!NOTE]
> **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.
> - **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.
## Key Architectural & Design Decisions
During the refactoring from the flat-layout prototype (`SettingTab` branch) to the unified package application (`main`), the following structural changes were made:
1. **Package Layout Restructuring**: Reorganized flat imports into a clean `src/` layout under `pygui.*` sub-packages (`controllers/`, `models/`, `data/`, `visualization/`, `wafer/`) for better module isolation.
2. **QML Component Decoupling**: Monolithic pages were refactored into reusable component blocks (e.g., `ReadoutPanel.qml`, `WaferMapView.qml`, `TransportBar.qml` under `src/pygui/ISC/Tabs/components/`), easing tab maintenance.
3. **Decoupled Controller Architecture**: Introduced QML-bound controllers (`DeviceController`, `SessionController`) acting as bridges between UI triggers and backend model states (`FramePlayer`, `SessionModel`), keeping UI files strictly visual.
4. **Robust Slot Error Boundaries**: Decorated QML-callable slots with a custom `@slot_error_boundary` wrapper to intercept runtime errors gracefully without terminating the Qt environment.
5. **Modernized Rendering Engine**: Replaced outdated contour algorithms with Radial Basis Function (RBF) interpolation (`rbf_heatmap.py`) to render high-fidelity, real-time thermal profiles.
## Requirements ## Requirements
- Python 3.11+ (tested with Python 3.14 in this workspace) - Python 3.11+ (tested with Python 3.14 in this workspace)
@@ -12,12 +44,15 @@ Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI s
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
@@ -28,6 +63,8 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
## Run ## Run
### Launch PySide6 QML App
```bash ```bash
make run make run
``` ```
@@ -53,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)
@@ -78,6 +116,41 @@ The application lives under a `src/` layout as the `pygui` package:
- `tests/`: pytest suite. - `tests/`: pytest suite.
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons. - `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
## Architecture
```
┌────────────────────────────────────────────────────────────┐
│ QML Layer (src/pygui/ISC/) │
│ HomePage.qml ─ WaferMapTab.qml ─ DataTab.qml │
│ StatusTab.qml ─ SettingsTab.qml ─ components/* │
└─────────────────────────┬──────────────────────────────────┘
│ Q_PROPERTY / @Slot / Signal
┌─────────────────────────▼──────────────────────────────────┐
│ Controller Layer (backend/controllers/) │
│ DeviceController ─ SessionController │
│ (serial ops, status) (streaming, playback) │
└─────────────────────────┬──────────────────────────────────┘
┌─────────────────────────▼──────────────────────────────────┐
│ Model Layer (backend/models/) │
│ FramePlayer ─ SessionModel ─ ReplayStatsTracker │
│ ThresholdClassifier ─ SensorEditor ─ ClusterAverage │
│ TemperatureTableModel │
└─────────────────────────┬──────────────────────────────────┘
┌─────────────────────────▼──────────────────────────────────┐
│ Serial / Data Layer (serialcomm/ + data/) │
│ SerialPort ─ DeviceService ─ StreamReader ─ data_parser │
│ LocalSettings ─ FileBrowser ─ CsvRecorder │
└─────────────────────────┬──────────────────────────────────┘
┌─────────────────────────▼──────────────────────────────────┐
│ Visualization (backend/visualization/) │
│ WaferMapItem ─ GraphView ─ TrendChartItem │
│ RBFHeatmap ─ WaferMapView (QML wrapper) │
└────────────────────────────────────────────────────────────┘
```
## Window Configuration ## Window Configuration
Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`: Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
@@ -109,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
@@ -122,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`).
@@ -141,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]
+203
View File
@@ -0,0 +1,203 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 600
height: 500
anchors.centerIn: Overlay.overlay
onOpened: licenseModel.refresh()
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
FileDialog {
id: licenseFileDialog
title: "Load License"
nameFilters: ["License files (*.bin)"]
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 28
spacing: 20
// ── Title ──
Label {
text: "About"
font.pixelSize: Theme.font2xl
font.bold: true
color: Theme.headingColor
}
// ── App info ──
ColumnLayout {
spacing: 6
Label {
text: "ISenseCloud"
font.pixelSize: Theme.fontXl
font.bold: true
color: Theme.headingColor
}
Label {
text: "Version " + appVersion
font.pixelSize: Theme.fontMd
color: Theme.bodyColor
}
}
// ── Separator ──
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// ── License grid ──
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 150
radius: Theme.radiusSm
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
RowLayout {
Layout.fillWidth: true
Label {
text: "LICENSE"
color: Theme.panelTitleText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 0.5
Layout.fillWidth: true
}
Label {
id: loadFailedLabel
visible: false
text: "Invalid license file"
color: Theme.statusWarningColor
font.pixelSize: Theme.fontSm
}
}
// Grid header
RowLayout {
Layout.fillWidth: true
spacing: 8
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
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 {
Layout.fillWidth: true
height: 1
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 {
visible: licenseModel.licenses.length === 0
text: "No license loaded"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
Layout.fillWidth: true
Layout.fillHeight: true
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
}
// ── 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 {
text: "Close"
Layout.preferredWidth: 100
Layout.preferredHeight: 36
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: parent.text
color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
+538 -263
View File
@@ -1,10 +1,14 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Tabs.components
import QtQuick.Dialogs import QtQuick.Dialogs
// ===== Home Workspace Shell ===== // ===== Home Workspace Shell =====
// Unified left rail: pill tab bar → context panel → status footer → utility buttons.
// Workspace fills the remaining area with the active tab's content.
Rectangle { Rectangle {
id: root id: root
anchors.fill: parent anchors.fill: parent
@@ -13,59 +17,60 @@ Rectangle {
border.color: Theme.outerFrameBorder border.color: Theme.outerFrameBorder
border.width: Theme.borderStrong border.width: Theme.borderStrong
// ===== Navigation Model =====
// ---------------------------------------------------------------------------
// TODO P1.1: Delete these 3 property blocks — superseded by mockup rail
//
// THINKING:
// The new rail uses a pill-tab-bar for navigation (Status/Data/Map) instead
// of sideActions[] + bottomTabs[]. The Repeaters below that consume these
// arrays are also deleted with the old layout (P1.1).
// selectedSideActionIndex is dead — the new rail has no concept of a
// globally-selected side action. selectedTabIndex stays, redefined below.
//
// Cross-ref: P1.2 (pill tabs), P1.3 (context panels), P1.4 (workspace)
// ---------------------------------------------------------------------------
property var sideActions: ["DETECT WAFER", "READ MEMORY", "ERASE MEMORY"]
// ===== Footer Tab Model =====
property var bottomTabs: ["Status", "Data", "Wafer Map", "Settings"]
// ===== View State ===== // ===== View State =====
property int selectedTabIndex: 0 property int selectedTabIndex: 0
property int selectedSideActionIndex: -1 onSelectedTabIndexChanged: {
if (selectedTabIndex === 2) {
file_browser.refreshFiles();
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){
//result is a waferInfo dict on success, None on failure // detectResult payload is Signal(object): a dict may cross into QML
root.waferDetected = (result != null && result!= undefined) // 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) {
// Load the freshly read CSV into the player
streamController.loadFile(result.csv_path) streamController.loadFile(result.csv_path)
// TODO P3.1: only auto-jump to Map when unlocked:
// Automatically switch to the "Wafer Map Tab" (index 3) // `&& licenseModel.licenses.length > 0` — otherwise a
root.selectedTabIndex = 3 // successful read walks straight into the locked tab.
// See plan §3.1.
if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2
}
} }
} }
} }
Connections { Connections {
target: deviceController target: deviceController
function onReadResult(result) { function onReadResult(result) {
// result has "success" key on success, "error" key on failure
root.memoryRead = (result !== null && root.memoryRead = (result !== null &&
result !== undefined && result !== undefined &&
result.success === true) result.success === true)
if (root.memoryRead) { if (root.memoryRead) {
console.log(`[P1.1] Memory read complete: ${result.bytes} bytes`) console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
} else { } else {
console.log(`[P1.1] Read failed: ${result.error || "unknown"}`) console.log("[P1.1] Read failed:", result.error || "unknown")
} }
} }
} }
@@ -76,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()
@@ -85,303 +91,572 @@ 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()
} }
} }
// --------------------------------------------------------------------------- // ===== Split Dialog (Threshold Segmentation) =====
// TODO P1.1: Delete importFileDialog + storedDataDialog (and the SplitDialog {
// `selectedTabIndex = 3` lines inside them) id: splitDialog
// }
// THINKING:
// These dialogs were only opened from the old side-rail buttons // ===== Settings Popup =====
// "IMPORT DATA" (index 4) and "STORED DATA" (index 5). With the Popup {
// mockup rail, the Map tab's file list (P1.3) replaces both — it id: settingsPopup
// drives `streamController.loadFile()` directly from a file_browser modal: true
// Repeater. Keeping these dead dialogs would bloat the QML and dim: true
// confuse future contributors. closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
// width: Math.min(root.width - 80, 800)
// `selectedTabIndex = 3` is also stale (Map tab is index 2 now). height: Math.min(root.height - 80, 600)
// The replacement flow: file_row.onClicked → loadFile() → anchors.centerIn: Overlay.overlay
// selectedTabIndex = 2.
// --------------------------------------------------------------------------- background: Rectangle {
FileDialog { radius: Theme.radiusMd
id: importFileDialog color: Theme.cardBackground
title: "Import CSV / ZWafer file" border.color: Theme.cardBorder
nameFilters: ["CSV files (*.csv)", "ZWafer files (*.zwafer)", "All files (*)"] border.width: 1
onAccepted: { }
var path = root.cleanFolderUrl(selectedFile)
streamController.setMode("review") contentItem: Rectangle {
streamController.stopStream() color: Theme.pageBackground
streamController.loadFile(path) radius: Theme.radiusMd
root.selectedTabIndex = 3 // Switch to Wafer Map tab clip: true
root.selectedSideActionIndex = -1
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 40
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
RowLayout {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 4
Label {
text: "Settings"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "\u2715"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: settingsPopup.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
} }
} }
FileDialog { Item {
id: storedDataDialog Layout.fillWidth: true
title: "Open Stored CSV File" Layout.fillHeight: true
nameFilters: ["CSV files (*.csv)", "All files (*)"] Layout.rightMargin: 6
onAccepted: { clip: true
var path = root.cleanFolderUrl(selectedFile)
streamController.setMode("review") Loader {
streamController.stopStream() anchors.fill: parent
streamController.loadFile(path) source: "Tabs/SettingsTab.qml"
root.selectedTabIndex = 3 // Switch to Wafer Map tab }
root.selectedSideActionIndex = -1 }
}
} }
} }
// ===== Main Two-Column Layout ====== // ===== About Dialog =====
// --------------------------------------------------------------------------- AboutDialog {
// TODO P1.1: Delete the ENTIRE RowLayout below (lines ~112-341) — old id: aboutDialog
// side-rail + workspace + footer-tab-strip. }
//
// THINKING: // ===== Main Layout: Rail + Workspace =====
// This ~230-line RowLayout is being replaced by:
// P1.2 — Pill tab bar (BOX 1, top of rail)
// P1.3 — Context-sensitive panel (BOX 2, mid rail)
// P1.5 — Hardware status footer (BOX 3, below BOX 2)
// P1.6 — Settings/About utility buttons (BOX 4, rail bottom)
// P1.4 — Workspace StackLayout (fills right side)
//
// None of the old structure (Repeater for sideActions[], Repeater for
// bottomTabs[], footer tab strip) survives. Keeping it would cause
// double-rendering or visual conflicts.
//
// Keep: saveDirDialog (FolderDialog), cleanFolderUrl(), _doDetect(),
// and all Connections blocks (lines 31-61).
//
// Cross-ref: P1.2, P1.3, P1.4, P1.5, P1.6
// ---------------------------------------------------------------------------
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
// ===== Left Action Rail ===== // ── LEFT RAIL ──────────────────────────────────────────────────────
// Left control rail.
Rectangle { Rectangle {
id: sideRail id: rail
Layout.preferredWidth: Theme.sideRailWidth Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.sideRailBackground color: Theme.sideRailBackground
border.color: Theme.workspaceBorder
border.width: Theme.borderThin
readonly property int actionCount: root.sideActions.length
readonly property real computedButtonHeight: Math.min(Theme.sideButtonHeight, (height - (Theme.panelPadding * 2) - (Theme.sideRailSpacing * Math.max(0, actionCount - 1))) / Math.max(1, actionCount))
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.sideRailMargin
spacing: Theme.sideRailSpacing spacing: Theme.sideRailSpacing
Repeater { // ── BOX 1: Pill Tab Bar ─────────────────────────────────────
model: root.sideActions Rectangle {
id: pillBar
Button {
id: control
text: modelData
enabled: {
switch (index) {
case 0: return true // DETECT WAFER
case 1: return root.waferDetected // READ MEMORY
case 2: return root.waferDetected // ERASE MEMORY
default: return true
}
}
property bool isActive: index === root.selectedSideActionIndex
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight) Layout.preferredHeight: Theme.sidePillHeight
hoverEnabled: true radius: 999
onClicked: { border.color: Theme.sideBorder
root.selectedSideActionIndex = index border.width: 1
root.selectedTabIndex = 0 // always jump to Status tab color: "transparent"
if (index === 0) {
if (!deviceController.saveDataDir) { ListModel {
saveDirDialog.open() id: pillModel
return ListElement {
label: "STATUS"
icon: "Tabs/icons/status.svg"
expandW: 86
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
} }
root._doDetect() ListElement {
label: "DATA"
icon: "Tabs/icons/data.svg"
expandW: 66
desc: "Manage, review, compare (DTW), and split historical sensor data records."
} }
else if (index === 1) { ListElement {
streamController.setMode("review") label: "MAP"
streamController.stopStream() icon: "Tabs/icons/map.svg"
deviceController.readMemoryAsync() expandW: 58
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
} }
else if (index === 2) { ListElement {
// ERASE MEMORY label: "GRAPH"
streamController.setMode("review") icon: "Tabs/icons/bar-chart.svg"
streamController.stopStream() expandW: 70
deviceController.eraseMemory(deviceController.selectedPort || "") desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
} }
} }
RowLayout {
anchors.fill: parent
anchors.margins: 3
spacing: 0
Repeater {
model: pillModel
delegate: Button {
id: pillBtn
checked: root.selectedTabIndex === index
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredWidth: pillBtn.checked ? model.expandW : 44
Behavior on Layout.preferredWidth {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
background: Rectangle { background: Rectangle {
color: { radius: 50
if (control.down) { color: pillBtn.checked ? Theme.sidePanelBackground : "transparent"
return Theme.buttonPressed; Behavior on color {
ColorAnimation { duration: Theme.durationFast }
} }
if (control.isActive) {
return Theme.sideActiveBackground;
} }
return "transparent";
contentItem: Row {
id: pillContent
spacing: pillBtn.checked ? 6 : 0
width: pillIcon.width + (pillBtn.checked ? pillContent.spacing + pillLabel.width : 0)
anchors.verticalCenter: parent.verticalCenter
x: (parent.width - width) / 2
Behavior on x {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
} }
border.color: control.isActive ? Theme.cardBorder : "transparent"
IconImage {
id: pillIcon
anchors.verticalCenter: parent.verticalCenter
width: 16; height: 16
source: model.icon
sourceSize.width: 16; sourceSize.height: 16
color: pillBtn.checked ? Theme.headingColor
: pillBtn.hovered ? Qt.lighter(Theme.sideMutedText, 1.7)
: Theme.sideMutedText
opacity: pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.7 : 0.4
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
}
Label {
id: pillLabel
anchors.verticalCenter: parent.verticalCenter
width: pillBtn.checked ? implicitWidth : 0
text: model.label
color: pillBtn.checked ? Theme.headingColor : Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
opacity: pillBtn.checked ? 1.0 : 0.0
clip: true
Behavior on width {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
Behavior on color {
ColorAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
Behavior on opacity {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
}
}
AppToolTip {
visible: pillBtn.hovered
text: "<b>" + model.label + "</b>: " + model.desc
}
onClicked: root.selectedTabIndex = index
}
}
}
}
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
Rectangle {
id: runComparisonBox
Layout.fillWidth: true
Layout.preferredHeight: runCompCol.implicitHeight + 28
visible: root.selectedTabIndex === 1
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1 border.width: 1
color: Theme.sidePanelBackground
readonly property var dataTab: dataTabLoader.item
component RunSlot: Rectangle {
id: slotBox
property string title
property string filePath: ""
property color accent: Theme.primaryAccent
property bool active: false
property bool locked: false
property string placeholderText: "Click to select"
signal cleared()
signal armed()
Layout.fillWidth: true
implicitHeight: 58
radius: Theme.radiusSm radius: Theme.radiusSm
color: Theme.fieldBackground
opacity: slotBox.locked ? 0.65 : 1.0
border.width: slotBox.active ? 2 : 1
border.color: slotBox.active ? accent : Theme.sideBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent
enabled: !slotBox.locked
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: slotBox.armed()
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Rectangle { Rectangle {
anchors.left: parent.left width: 8; height: 8; radius: 4
anchors.top: parent.top color: slotBox.accent
anchors.bottom: parent.bottom Layout.alignment: Qt.AlignVCenter
width: 3 }
visible: control.isActive
color: Theme.primaryAccent ColumnLayout {
radius: Theme.radiusXs 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
} }
} }
contentItem: Text { Button {
text: control.text visible: slotBox.filePath !== "" && !slotBox.locked
color: control.isActive ? Theme.headingColor : Theme.bodyColor flat: true
font.bold: control.isActive implicitWidth: 24; implicitHeight: 24
font.pixelSize: 18 onClicked: slotBox.cleared()
wrapMode: Text.WordWrap background: Rectangle {
horizontalAlignment: Text.AlignHCenter radius: Theme.radiusSm
verticalAlignment: Text.AlignVCenter 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 {
text: "RUN COMPARISON (DTW)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
Button {
id: resetRunBtn
flat: true
implicitWidth: 32
implicitHeight: 32
visible: true
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
background: Rectangle {
radius: Theme.radiusSm
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/rotate-ccw.svg"
width: 18; height: 18
sourceSize.width: 18
sourceSize.height: 18
color: Theme.bodyColor
anchors.centerIn: parent
}
AppToolTip {
visible: resetRunBtn.hovered
text: "Reset comparison"
}
}
}
PanelCheckBox {
id: useMasterCheck
Layout.fillWidth: true
text: "Compare vs Master File"
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
onToggled: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.useMasterFile = checked
if (checked) runComparisonBox.dataTab.activeBox = 2
}
}
RunSlot {
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
accent: Theme.primaryAccent
locked: useMaster
placeholderText: useMaster
? (runComparisonBox.dataTab.runBWaferType !== ""
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
: "Select Run B to resolve master")
: "Click to select"
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
filePath: runComparisonBox.dataTab
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
: ""
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileA = ""
runComparisonBox.dataTab.activeBox = 1
runComparisonBox.dataTab.clearResults()
}
}
RunSlot {
title: "SESSION (RUN B)"
accent: Theme.themeSkill
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileB = ""
runComparisonBox.dataTab.activeBox = 2
runComparisonBox.dataTab.clearResults()
}
}
Button {
id: compareRunBtn
Layout.fillWidth: true
Layout.preferredHeight: 32
readonly property var dt: runComparisonBox.dataTab
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
readonly property string gateError: !!dt ? dt.familyGateError : ""
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
AppToolTip {
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
text: compareRunBtn.masterMissing
? "No master file registered for this wafer type (Settings → Master Files)"
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
: "Choose two different runs to compare"
}
onClicked: {
dt.comparing = true
dt.clearResults()
streamController.compareFiles(fileA, dt.compareFileB)
}
background: Rectangle {
radius: Theme.radiusSm
color: compareRunBtn.enabled
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
: Theme.buttonNeutralBackground
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
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
} }
} }
} }
} }
} }
// Main workspace and footer navigation. // ── BOX 2: Context-Sensitive Rail Panel ────────────────────
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.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 ──────────────────────────
ConnectionFooter {
Layout.fillWidth: true
}
// ── BOX 4: Utility Buttons ─────────────────────────────────
UtilityFooter {
id: utilFooter
Layout.fillWidth: true
settingsPopup: settingsPopup
aboutDialog: aboutDialog
}
}
}
// ── WORKSPACE ─────────────────────────────────────────────────────
Rectangle { 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: Theme.borderThin
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.mainAreaPadding
spacing: Theme.rightPaneGap
// ===== Active Tab Content Area =====
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 220
color: Theme.responseBackground
border.color: Theme.responseBorder
border.width: 1
radius: Theme.radiusMd
StackLayout { StackLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.mainAreaPadding
currentIndex: root.selectedTabIndex currentIndex: root.selectedTabIndex
// ===== Tab Content Routing =====
Repeater {
model: root.bottomTabs
Item {
property string tabName: modelData
Loader { Loader {
anchors.fill: parent source: "Tabs/StatusTab.qml"
active: parent.tabName === "Settings" active: StackLayout.index === root.selectedTabIndex
source: parent.tabName === "Settings" ? "Tabs/SettingsTab.qml" : ""
} }
Loader { Loader {
anchors.fill: parent id: dataTabLoader
active: parent.tabName === "Status" source: "Tabs/DataTab.qml"
source: parent.tabName === "Status" ? "Tabs/StatusTab.qml" : "" active: StackLayout.index === root.selectedTabIndex
} }
Loader { Loader {
anchors.fill: parent source: "Tabs/WaferMapTab.qml"
active: parent.tabName === "Data" // TODO P3.1: append `&& licenseModel.licenses.length > 0`
source: parent.tabName === "Data" ? "Tabs/DataTab.qml" : "" // so the tab never instantiates while locked. See plan §3.1.
active: StackLayout.index === root.selectedTabIndex
} }
Label {
anchors.centerIn: parent
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map" && parent.tabName !== "Graph"
text: parent.tabName + " content"
color: Theme.bodyColor
font.pixelSize: 20
}
Loader { Loader {
anchors.fill: parent source: "Tabs/GraphTab.qml"
active: parent.tabName === "Wafer Map" active: StackLayout.index === root.selectedTabIndex
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
}
Loader {
anchors.fill: parent
active: parent.tabName === "Graph"
source: parent.tabName === "Graph" ? "Tabs/GraphTab.qml" : ""
}
}
}
}
}
// Keep the tab strip evenly distributed across the footer.
// ===== Footer Tab Strip =====
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Theme.tabBarHeight + (Theme.tabBarPadding * 2)
color: Theme.tabBarBackground
border.color: Theme.workspaceBorder
border.width: Theme.borderThin
radius: Theme.radiusMd
RowLayout {
anchors.fill: parent
anchors.margins: Theme.tabBarPadding
spacing: Theme.tabSpacing
Repeater {
model: root.bottomTabs
Button {
id: botTabBtn
property bool isActive: index === root.selectedTabIndex
text: modelData
Layout.fillWidth: true
Layout.preferredHeight: Theme.tabBarHeight
Layout.minimumWidth: Theme.tabButtonMinWidth
onClicked: root.selectedTabIndex = index
hoverEnabled: true
background: Rectangle {
color: botTabBtn.isActive ? Theme.tabActiveBackground : (botTabBtn.hovered ? Theme.tabHoverBackground : Theme.tabBackground)
border.color: Theme.tabBorder
border.width: 1
radius: Theme.tabRadius
}
contentItem: Text {
text: botTabBtn.text
color: botTabBtn.isActive ? Theme.tabActiveText : Theme.tabText
font.pixelSize: Theme.tabFontSize
font.bold: botTabBtn.isActive
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
} }
} }
} }
+331 -133
View File
@@ -1,175 +1,373 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.TableView
import ISC import ISC
import ISC.Tabs.components
import ISC.Wafer
// ===== Data Tab ===== // ===== Data Tab (Compare Content Only) =====
// Displays parsed temperature data in a scrollable table. // Per docs/design/navigation-mock.html: the Data tab shows only the Compare
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ... // Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
// is intentionally not displayed in the application.
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
// overlap map; right side panel = run selection dropdowns, overlap settings,
// and DTW readout cards.
Item { Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
readonly property int rowCount: deviceController.dataRowCount // Properties for DTW Comparison
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
property string compareFileA: ""
property string compareFileB: ""
property bool useMasterFile: false // Run A comes from settingsModel.masters[runBWaferType] instead of manual selection
// Re-layout columns whenever the model is reset function waferTypeForFile(path) {
if (!path) return ""
// A file registered as a master carries that slot's family — this is
// the only way a live recording gets a family (its filename/metadata
// don't encode one reliably).
for (var fam in settingsModel.masters)
if (settingsModel.masters[fam] === path) return fam.toUpperCase()
var rows = file_browser.files
for (var i = 0; i < rows.length; i++) {
if (rows[i].fileName === path) {
if (rows[i].isRecording) return (rows[i].masterType || "").toUpperCase()
return (rows[i].waferType || "").toUpperCase()
}
}
var base = path.split("/").pop()
if (base.toLowerCase().indexOf("live_") === 0) return ""
return base ? base.charAt(0).toUpperCase() : ""
}
// Master file must match Run B's wafer family — looking it up by that
// family is itself the comparability gate (a mismatched-family master
// simply won't exist under this key).
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : ""
readonly property string runAWaferType: root.useMasterFile ? root.runBWaferType : root.waferTypeForFile(root.compareFileA)
// Family gate: both runs must resolve to the same wafer family. An empty
// family means an unassigned live recording — comparable only after it is
// registered as a master file (Settings → Master Files).
readonly property string familyGateError: {
var fileA = root.useMasterFile ? root.masterFileForRunB : root.compareFileA
if (fileA === "" || root.compareFileB === "") return ""
if (root.runAWaferType === "" || root.runBWaferType === "")
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
if (root.runAWaferType !== root.runBWaferType)
return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
return ""
}
property real warpingDistance: -1
property int frameOffset: 0
property real frameOffsetSeconds: 0
property real maxSensorDeviation: -1
property bool comparing: false
property var trendDataA: []
property var trendDataB: []
property var timeDataA: []
property var timeDataB: []
property int frameCountA: 0
property int frameCountB: 0
property string errorMessage: ""
property string mismatchMessage: ""
// Wafer overlap properties
property var compareSensorLayout: []
property var compareSensorDiff: []
property string compareWaferShape: "round"
property real compareWaferSize: 300.0
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
function diffBands(diffs) {
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
}
function shortName(path) {
return path ? path.split("/").pop() : ""
}
function scrubTimeLabel(idx) {
var t = idx < root.timeDataA.length ? root.timeDataA[idx]
: (idx < root.timeDataB.length ? root.timeDataB[idx] : null)
return t !== null ? " (" + t.toFixed(1) + "s)" : ""
}
function formatMinutes(secondsValue) {
var mins = Math.floor(secondsValue / 60)
var secs = secondsValue % 60
return mins + ":" + (secs < 10 ? "0" : "") + secs.toFixed(1)
}
function frameForTime(t) {
var times = root.timeDataA.length > 0 ? root.timeDataA : root.timeDataB
if (times.length === 0) return 0
var closestIdx = 0
var minDiff = Math.abs(times[0] - t)
for (var i = 1; i < times.length; i++) {
var diff = Math.abs(times[i] - t)
if (diff < minDiff) {
minDiff = diff
closestIdx = i
}
}
return closestIdx
}
function clearResults() {
root.warpingDistance = -1
root.frameOffset = 0
root.frameOffsetSeconds = 0
root.maxSensorDeviation = -1
root.trendDataA = []
root.trendDataB = []
root.timeDataA = []
root.timeDataB = []
root.frameCountA = 0
root.frameCountB = 0
root.compareSensorLayout = []
root.compareSensorDiff = []
root.errorMessage = ""
root.mismatchMessage = ""
mismatchHideTimer.stop()
}
function resetComparison() {
root.compareFileA = ""
root.compareFileB = ""
root.useMasterFile = false
root.comparing = false
root.activeBox = 1
clearResults()
}
Component.onCompleted: file_browser.refreshFiles()
Timer {
id: mismatchHideTimer
interval: 6000
onTriggered: root.mismatchMessage = ""
}
// Clicking a file in the left rail's browser populates the armed run slot
Connections { Connections {
target: deviceController target: streamController
function onParsedDataReady(result) { function onLoadedFileChanged() {
if (streamController.loadedFile && root.activeBox > 0) {
if (root.activeBox === 1) {
root.compareFileA = streamController.loadedFile
root.activeBox = 2 // Auto-advance to box 2
} else if (root.activeBox === 2) {
root.compareFileB = streamController.loadedFile
root.activeBox = 0 // Finished selecting
}
}
}
function onComparisonResult(result) {
root.comparing = false
if (result && result.success) { if (result && result.success) {
dataTable.forceLayout() root.warpingDistance = result.distance
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
root.frameOffsetSeconds = result.frame_offset_seconds !== undefined ? result.frame_offset_seconds : 0
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
root.trendDataA = result.series_a || []
root.trendDataB = result.series_b || []
root.timeDataA = result.time_a || []
root.timeDataB = result.time_b || []
root.frameCountA = result.frame_count_a || 0
root.frameCountB = result.frame_count_b || 0
root.compareSensorLayout = result.sensor_layout || []
root.compareSensorDiff = result.sensor_diff || []
root.compareWaferShape = result.wafer_shape || "round"
root.compareWaferSize = result.wafer_size || 300.0
root.errorMessage = ""
chartCard.frameIndex = Math.floor(root.seriesLen / 2)
chartCard.focusScrubber()
if (root.frameCountA !== root.frameCountB) {
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
root.mismatchMessage = "Run A: " + root.frameCountA + " frames (" + timeA.toFixed(1) + "s) · "
+ "Run B: " + root.frameCountB + " frames (" + timeB.toFixed(1) + "s). "
+ "Deviation beyond the shorter run shows as \"none\"."
mismatchHideTimer.restart()
} else {
root.mismatchMessage = ""
mismatchHideTimer.stop()
}
} else {
root.clearResults()
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
} }
} }
} }
// ===== Empty State ===== // ── Main Layout ───────────────────────────────────────────────
Column {
anchors.centerIn: parent
spacing: 16
visible: root.rowCount === 0
Label {
text: "No Data"
font.pixelSize: 24
font.bold: true
color: Theme.headingColor
anchors.horizontalCenter: parent.horizontalCenter
}
Label {
text: "Read and parse wafer data to see temperature readings here."
font.pixelSize: 14
color: Theme.bodyColor
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap
}
}
// ===== Data Table =====
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap spacing: Theme.rightPaneGap
visible: root.rowCount > 0
// --- Header row ---
RowLayout {
Layout.fillWidth: true
spacing: Theme.rightPaneGap
Label {
text: "Temperature Data"
font.pixelSize: 18
font.bold: true
color: Theme.headingColor
}
Item { Layout.fillWidth: true } // ── Error Banner ───────────────────────────────────────────
Label {
text: deviceController.dataRowCount + " rows × " +
deviceController.dataColCount + " sensors"
font.pixelSize: 13
color: Theme.bodyColor
}
}
// --- Toolbar ---
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
text: "Open in Excel"
icon.source: "icon/excel.svg"
onClicked: deviceController.openCsvFile()
}
Button {
text: "Compare"
onClicked: compareDialog.open()
}
Button {
text: "Split"
onClicked: splitDialog.open()
}
Item { Layout.fillWidth: true}
}
// --- Table container ---
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.preferredHeight: errorRow.implicitHeight + 16
color: Theme.panelBackground visible: root.errorMessage !== ""
border.color: Theme.cardBorder color: Theme.errorSurface
border.width: Theme.borderThin border.color: Theme.statusErrorColor
radius: Theme.radiusSm
clip: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 1
spacing: 0
// Column header strip
HorizontalHeaderView {
id: headerView
Layout.fillWidth: true
syncView: dataTable
clip: true
delegate: Rectangle {
implicitHeight: 28
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1 border.width: 1
radius: Theme.radiusSm
Text { RowLayout {
anchors.centerIn: parent id: errorRow
// 'display' is the Qt.DisplayRole value from headerData() anchors.fill: parent
text: display !== undefined ? display : "" anchors.margins: 8
color: Theme.headingColor spacing: 8
font.pixelSize: 11
font.bold: true IconImage {
elide: Text.ElideRight source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
}
Label {
id: errorLabel
text: root.errorMessage
color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
} }
} }
} }
// Data rows // ── Body: main column | side panel ─────────────────────────
TableView { RowLayout {
id: dataTable
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
model: deviceController.dataModel spacing: 16
clip: true
reuseItems: true
columnWidthProvider: function(col) { // --- Left Column: chart + scrubber, wafer overlap map ---
if (col === 0) return 52 // row index column ColumnLayout {
const sensorCols = Math.max(1, dataTable.columns - 1) Layout.fillWidth: true
const available = dataTable.width - 52 Layout.fillHeight: true
return Math.max(52, Math.min(90, available / sensorCols)) Layout.minimumWidth: 350
spacing: 12
// Wafer Map Overlap View card
OverlapMapCard {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 220
sensorLayout: root.compareSensorLayout
diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
diffBands: root.diffBands
waferShape: root.compareWaferShape
waferSize: root.compareWaferSize
overlapEnabled: sidePanel.overlapEnabled
blendAmount: sidePanel.blendAmount
} }
rowHeightProvider: function() { return 24 } // Chart card with alignment scrubber
ComparisonChartCard {
delegate: Rectangle { id: chartCard
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground Layout.fillWidth: true
trendDataA: root.trendDataA
Text { trendDataB: root.trendDataB
anchors.centerIn: parent seriesLen: root.seriesLen
text: display !== undefined ? display : "" hasSeries: root.hasSeries
color: Theme.bodyColor comparing: root.comparing
font.pixelSize: 11
elide: Text.ElideRight
} }
} }
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded } // --- Right Column: run selection, overlap settings, readout ---
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } ComparisonSidePanel {
id: sidePanel
Layout.fillWidth: false
Layout.preferredWidth: Theme.rightRailWidth
Layout.minimumWidth: Theme.rightRailWidth
Layout.maximumWidth: Theme.rightRailWidth
Layout.fillHeight: true
warpingDistance: root.warpingDistance
frameOffset: root.frameOffset
frameOffsetSeconds: root.frameOffsetSeconds
maxSensorDeviation: root.maxSensorDeviation
seriesLen: root.seriesLen
hasSeries: root.hasSeries
trendDataA: root.trendDataA
trendDataB: root.trendDataB
timeDataA: root.timeDataA
timeDataB: root.timeDataB
frameForTime: root.frameForTime
frameIndex: chartCard.frameIndex
onFrameIndexRequested: (idx) => chartCard.frameIndex = idx
onScrubberFocusRequested: chartCard.focusScrubber()
}
}
}
// ── Frame Mismatch Banner (amber, floats over content, auto-hides) ──
Rectangle {
id: mismatchBanner
visible: root.mismatchMessage !== ""
z: 100
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 12
width: Math.min(parent.width - 48, 640)
height: mismatchRow.implicitHeight + 16
color: Theme.cardBackground
border.color: Theme.statusWarningColor
border.width: 1
radius: Theme.radiusSm
RowLayout {
id: mismatchRow
anchors.fill: parent
anchors.margins: 8
spacing: 8
IconImage {
source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.statusWarningColor
Layout.alignment: Qt.AlignTop
}
Label {
text: root.mismatchMessage
color: Theme.statusWarningColor
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Button {
flat: true
implicitWidth: 24
implicitHeight: 24
Layout.alignment: Qt.AlignTop
onClicked: {
root.mismatchMessage = ""
mismatchHideTimer.stop()
}
background: Rectangle { color: "transparent" }
contentItem: Label {
text: "✕"
color: Theme.statusWarningColor
font.pixelSize: Theme.fontXs
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
} }
} }
+29 -199
View File
@@ -1,219 +1,49 @@
import QtQuick import QtQuick
import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Wafer import ISC.Tabs.components
// ===== Graph Tab ===== // ===== Graph Tab =====
// Displays sensor temperature line charts using GraphQuickItem. // Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
// Gets data from deviceController.getChartData() (parsed CSV, batch read) // snapshot recomputed whenever a file is loaded — not live-updating during
// or from streamController.trendData (live streaming trend). // 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 { Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
property var _chartData: null // cached result from getChartData() function refresh() {
if (!streamController.loadedFile) {
ColumnLayout { chart.seriesData = []
anchors.fill: parent chart.sensorNames = []
anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap
// ── Toolbar ───────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "Line Chart"
color: Theme.headingColor
font.pixelSize: 13
font.bold: true
Layout.alignment: Qt.AlignVCenter
}
Item { Layout.fillWidth: true }
// Refresh button — pulls data from deviceController
Button {
id: refreshBtn
text: "REFRESH"
font.pixelSize: 10
font.weight: Font.Medium
implicitHeight: 26
implicitWidth: 72
hoverEnabled: true
background: Rectangle {
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: reloadData()
}
}
// ── Mode selector (Trend / Full chart) ─────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: "Source:"
color: Theme.bodyColor
font.pixelSize: 10
Layout.alignment: Qt.AlignVCenter
}
ComboBox {
id: sourceSelector
model: ["Parsed Data", "Live Trend"]
currentIndex: 0
implicitHeight: 26
font.pixelSize: 11
background: Rectangle {
color: Theme.fieldBackground
border.color: Theme.fieldBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: sourceSelector.displayText
color: Theme.fieldText
font: sourceSelector.font
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
leftPadding: 8
}
onCurrentIndexChanged: reloadData()
}
Item { Layout.fillWidth: true }
Label {
id: statusLabel
text: "Ready"
color: Theme.bodyColor
font.pixelSize: 10
font.italic: true
Layout.alignment: Qt.AlignVCenter
}
}
// ── Chart Area ─────────────────────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
GraphQuickItem {
id: graph
anchors.fill: parent
anchors.margins: 2
backgroundColor: Theme.cardBackground
gridColor: Theme.softBorder
axisColor: Theme.bodyColor
textColor: Theme.headingColor
seriesColors: [Theme.sensorLow, Theme.sensorHigh, Theme.sensorInRange,
"#FFA726", "#AB47BC", "#00BCD4"]
showLegend: true
}
}
}
// ── Data Loading ────────────────────────────────────────────────────────────
function reloadData() {
if (sourceSelector.currentIndex === 0) {
// Parsed data from DeviceController
loadParsedData()
} else {
// Live trend from SessionController
loadLiveTrend()
}
}
function loadParsedData() {
statusLabel.text = "Loading..."
var result = deviceController.getChartData()
if (!result || !result.success) {
graph.seriesData = []
graph.sensorNames = []
graph.title = "Sensor Temperature Over Time"
statusLabel.text = "No data — read a wafer first"
return return
} }
chart.sensorNames = streamController.graphSensorNames
graph.seriesData = result.series || [] ? streamController.graphSensorNames.split(",") : []
graph.sensorNames = result.sensor_names || [] chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
graph.title = "Sensor Temperature Over Time"
statusLabel.text = (result.series ? result.series.length : 0) + " sensors loaded"
}
function loadLiveTrend() {
// Live trend: connects to streamController.trendData
// The trend data comes as a JSON array of per-frame averages
statusLabel.text = "Connecting to live trend..."
// We need at least one data point
if (streamController.stats && streamController.stats.avg !== undefined) {
// Build a single-series graph from the trend buffer
// For now show a placeholder — the trendData signal handles updates
graph.title = "Live Average Temperature"
graph.yLabel = "Avg Temperature (°C)"
graph.xLabel = "Time (s)"
statusLabel.text = "Live trend — waiting for data..."
} else {
statusLabel.text = "No live data — start a stream on the Wafer Map tab"
}
}
// ── Connections ─────────────────────────────────────────────────────────────
Connections {
target: deviceController
function onParsedDataReady(result) {
if (sourceSelector.currentIndex === 0) {
root.reloadData()
}
}
} }
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onLoadedFileChanged() { root.refresh() }
if (sourceSelector.currentIndex === 1) {
try {
var avgs = JSON.parse(avgsJson)
if (avgs && avgs.length > 0) {
graph.seriesData = [avgs]
graph.sensorNames = ["Average"]
graph.title = "Live Average Temperature"
graph.yLabel = "Avg Temperature (°C)"
graph.xLabel = "Frame"
statusLabel.text = "Live: " + avgs.length + " data points"
}
} catch (e) {
// ignore parse errors
}
}
}
} }
// On startup, try to load parsed data if available Component.onCompleted: root.refresh()
Component.onCompleted: {
if (deviceController.dataRowCount > 0) { Rectangle {
reloadData() 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"
} }
} }
} }
+68 -23
View File
@@ -1,8 +1,10 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
import ISC.Tabs.components
Item { Item {
id: root id: root
@@ -27,8 +29,7 @@ Item {
} }
// ===== Settings Data Helpers ===== // ===== Settings Data Helpers =====
readonly property var masterFamilies: ["A", "B", "C", "D", "E", "F", "P", "X", "Z"] // ListModel for master families defined inline in the ColumnLayout below
function masterPath(family) { function masterPath(family) {
const table = settingsModel.masters; const table = settingsModel.masters;
return table && table[family] ? table[family] : ""; return table && table[family] ? table[family] : "";
@@ -58,7 +59,7 @@ Item {
label: Label { label: Label {
text: settingsGroup.title text: settingsGroup.title
color: Theme.panelTitleText color: Theme.panelTitleText
font.pixelSize: 16 font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
} }
} }
@@ -75,6 +76,12 @@ Item {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
} }
@@ -111,16 +118,24 @@ Item {
border.width: Theme.borderThin border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Text { Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
IconImage {
anchors.centerIn: parent anchors.centerIn: parent
text: "✓" source: "icons/check.svg"
font.pixelSize: 14 width: 12; height: 12
font.bold: true sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground color: Theme.panelBackground
visible: toggle.checked opacity: toggle.checked ? 1.0 : 0.0
// Small nudge to center the checkmark perfectly Behavior on opacity {
verticalAlignment: Text.AlignVCenter NumberAnimation { duration: Theme.durationFast }
horizontalAlignment: Text.AlignHCenter }
} }
} }
@@ -129,7 +144,7 @@ Item {
color: Theme.headingColor color: Theme.headingColor
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: 13 font.pixelSize: Theme.fontSm
} }
} }
@@ -159,6 +174,7 @@ Item {
anchors.top: settingsScroll.top anchors.top: settingsScroll.top
anchors.right: settingsScroll.right anchors.right: settingsScroll.right
anchors.bottom: settingsScroll.bottom anchors.bottom: settingsScroll.bottom
anchors.leftMargin: 6
policy: ScrollBar.AsNeeded policy: ScrollBar.AsNeeded
contentItem: Rectangle { contentItem: Rectangle {
@@ -174,16 +190,19 @@ Item {
} }
} }
// Gap reserved between the centered content and the right-edge scrollbar.
readonly property int scrollGap: 100
ColumnLayout { ColumnLayout {
id: container id: container
width: Math.min(settingsScroll.availableWidth, Theme.settingsPanelMaxWidth) width: Math.min(settingsScroll.availableWidth - settingsScroll.scrollGap, Theme.settingsPanelMaxWidth)
x: (settingsScroll.availableWidth - width) / 2 x: (settingsScroll.availableWidth - width) / 2
spacing: Theme.settingsSectionSpacing spacing: Theme.settingsSectionSpacing
// ===== Page Title ===== // ===== Page Title =====
Label { Label {
text: "Settings" text: "Settings"
font.pixelSize: 30 font.pixelSize: Theme.font3xl
font.bold: true font.bold: true
color: Theme.headingColor color: Theme.headingColor
} }
@@ -241,11 +260,24 @@ Item {
anchors.fill: parent anchors.fill: parent
spacing: Theme.settingsGridSpacing spacing: Theme.settingsGridSpacing
ListModel {
id: masterModel
ListElement { letter: "A" }
ListElement { letter: "B" }
ListElement { letter: "C" }
ListElement { letter: "D" }
ListElement { letter: "E" }
ListElement { letter: "F" }
ListElement { letter: "P" }
ListElement { letter: "X" }
ListElement { letter: "Z" }
}
Repeater { Repeater {
model: root.masterFamilies model: masterModel
RowLayout { RowLayout {
property string family: modelData property string family: model.letter
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.settingsRowSpacing spacing: Theme.settingsRowSpacing
@@ -334,7 +366,7 @@ Item {
width: 22 width: 22
height: 22 height: 22
radius: height / 2 radius: height / 2
color: "white" color: Theme.toggleThumb
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
x: Theme.isDarkMode ? parent.width - width - 3 : 3 x: Theme.isDarkMode ? parent.width - width - 3 : 3
@@ -361,7 +393,7 @@ Item {
Label { Label {
text: Theme.isDarkMode ? "Dark" : "Light" text: Theme.isDarkMode ? "Dark" : "Light"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 13 font.pixelSize: Theme.fontSm
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Behavior on color { Behavior on color {
@@ -420,12 +452,9 @@ Item {
} }
} }
Label { Row {
anchors.centerIn: parent anchors.centerIn: parent
text: "↓ Scroll for more" spacing: 4
font.pixelSize: 13
font.bold: true
color: Theme.bodyColor
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0 opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
Behavior on opacity { Behavior on opacity {
@@ -433,6 +462,22 @@ Item {
duration: 400 duration: 400
} }
} }
IconImage {
anchors.verticalCenter: parent.verticalCenter
source: "icons/chevron-down.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "Scroll for more"
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.bodyColor
}
} }
} }
} }
+469 -242
View File
@@ -1,68 +1,306 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
// ===== Status Tab ===== // ===== Status Tab — Bento Grid Dashboard =====
// Shows connection status, wafer info, and activity log. // ROW 1: Connection Status (65%) | Cycles Completed (35%)
// Initially visible — displays connection status and activity log from start. // ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
// ROW 3: Activity Log (100%, fills remaining)
ColumnLayout { ColumnLayout {
id: root id: root
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap spacing: 8
property alias waferFamilyCode: waferInfoFamily.text // ── Reusable Inline Components ──
property alias waferSerialNumber: waferSerial.text component StatCard : Rectangle {
property alias waferSensorCount: waferSensors.text property alias value: valueText.text
property alias waferRuntime: waferRuntime.text property alias label: labelText.text
property alias waferCycles: waferCycles.text property bool active: root.waferDetected
property bool waferDetected: false property int valSize: active ? Theme.font3xl : Theme.font2xl
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: valSize
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.fillHeight: true
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPixelSize: Theme.fontMd
}
}
}
// 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 value: valueText.text
property bool active: root.waferDetected
spacing: 2
Layout.fillWidth: true
Layout.fillHeight: true
Item { Layout.fillHeight: true }
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
Layout.fillWidth: true
elide: Text.ElideRight
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: Theme.fontXl
font.weight: Font.Bold
font.family: Theme.uiFontFamily
}
Item { Layout.fillHeight: true }
}
// ── Aliases for external wiring ──
property alias waferFamilyCode: waferInfoFamilyCell.value
property alias waferSerialNumber: waferSerialCell.value
property alias waferSensorCount: waferSensorsCell.value
property alias waferRuntime: runtimeCard.value
property alias waferCycles: waferCyclesCell.value
property bool waferDetected: {
var info = deviceController.lastWaferInfo;
return info && info.length > 0 && info[0] !== "";
}
// Data summary after parse
property int dataRows: 0 property int dataRows: 0
property int dataCols: 0 property int dataCols: 0
property string csvPath: "" property string csvPath: ""
property bool dataParsed: false property bool dataParsed: false
readonly property bool isConnected: deviceController.connectionStatus === "Connected"
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
readonly property string portName: deviceController.selectedPort || "—"
function cleanFolderUrl(url) { function cleanFolderUrl(url) {
return decodeURIComponent(String(url).replace(/^file:\/\//, "")) return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
} }
function currentFamilyCode() { function currentFamilyCode() {
var info = deviceController.lastWaferInfo var info = deviceController.lastWaferInfo;
return info && info.length > 0 ? (info[0] || "") : "" return info && info.length > 0 ? (info[0] || "") : "";
} }
function parseAndSavePendingRead() { function parseAndSavePendingRead() {
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "") deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "");
} }
FolderDialog { FolderDialog {
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);
root.parseAndSavePendingRead() deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
root.parseAndSavePendingRead();
} }
} }
// Directory-only picker for the DIRECTORY card button — sets the save dir
// without kicking off a parse/save of pending read data.
FolderDialog {
id: dirOnlyDialog
title: "Choose a folder to save Data"
onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder);
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 { 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 {
Layout.columnSpan: 2
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: Theme.rightPaneGap
// --- Connection Status ---
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 92
color: Theme.cardBackground color: Theme.cardBackground
border.color: { border.color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor border.width: 1
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor radius: Theme.radiusSm
return Theme.cardBorder
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
Text {
text: "CONNECTION STATUS"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
} }
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 8
Item {
Layout.fillWidth: true
implicitHeight: 22
Row {
anchors.centerIn: parent
spacing: 5
Repeater {
model: 4
Rectangle {
width: 4; height: 4; radius: 2
anchors.verticalCenter: parent.verticalCenter
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
SequentialAnimation on opacity {
running: root.isConnected || root.isBusy
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 1400 }
NumberAnimation { to: 1.0; duration: 1400 }
}
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: statusPillText.implicitWidth + 16
height: 20
radius: 10
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
Text {
id: statusPillText
anchors.centerIn: parent
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
}
}
Repeater {
model: 4
Rectangle {
width: 4; height: 4; radius: 2
anchors.verticalCenter: parent.verticalCenter
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
SequentialAnimation on opacity {
running: root.isConnected || root.isBusy
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 1400 }
NumberAnimation { to: 1.0; duration: 1400 }
}
}
}
}
}
Text {
text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
elide: Text.ElideMiddle
}
}
}
}
// ── Wafer & Sensor Data: far right, spans both rows ──
Rectangle {
Layout.rowSpan: 2
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm radius: Theme.radiusSm
ColumnLayout { ColumnLayout {
@@ -71,164 +309,188 @@ ColumnLayout {
spacing: 4 spacing: 4
Text { Text {
text: deviceController.connectionStatus text: "WAFER & SENSOR DATA"
color: { color: Theme.sideMutedText
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor font.pixelSize: Theme.fontSm
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor font.weight: Font.Bold
return Theme.bodyColor font.family: Theme.uiFontFamily
} font.letterSpacing: 0.6
font.bold: true Layout.bottomMargin: 2
font.pixelSize: 16
Layout.fillWidth: true
} }
Text { GridLayout {
text: "Port: " + (deviceController.selectedPort || "None selected")
color: Theme.bodyColor
font.pixelSize: 14
Layout.fillWidth: true 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 {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 8
Layout.alignment: Qt.AlignVCenter
Text { Text {
text: "Save dir: " + (deviceController.saveDataDir || "None selected") text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
Layout.fillWidth: true
}
Rectangle {
visible: root.csvPath !== ""
implicitWidth: sessionSavedLabel.implicitWidth + 12
implicitHeight: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Text {
id: sessionSavedLabel
anchors.centerIn: parent
text: "SESSION SAVED"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
}
}
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 12
// Path pinned bottom-left
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 12 font.pixelSize: Theme.fontMd
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7 opacity: 0.7
elide: Text.ElideMiddle elide: Text.ElideMiddle
Layout.fillWidth: true Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
} }
Button { Button {
id: browseBtn id: browseBtn
text: "BROWSE..." text: "Select Folder"
font.pixelSize: 9 font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
implicitHeight: 20 font.family: Theme.uiFontFamily
implicitWidth: 65 implicitHeight: 24
hoverEnabled: true hoverEnabled: true
background: Rectangle { background: Rectangle {
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
radius: Theme.radiusSm radius: Theme.radiusSm
} }
contentItem: Text { contentItem: Text {
text: parent.text text: browseBtn.text
color: Theme.bodyColor color: Theme.bodyColor
font: parent.font font: browseBtn.font
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
onClicked: saveDirDialog.open() onClicked: dirOnlyDialog.open()
} }
} }
} }
} }
// --- Wafer Info --- // ── Total Runtime: bottom center ──
GroupBox { StatCard {
title: "Wafer Information" id: runtimeCard
visible: root.waferDetected label: "TOTAL RUNTIME"
Layout.fillWidth: true value: {
var info = deviceController.lastWaferInfo;
ColumnLayout { if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
anchors.fill: parent var secs = parseFloat(info[4]);
anchors.margins: Theme.panelPadding if (isNaN(secs)) return info[4] + "s";
spacing: 8 var m = Math.floor(secs / 60);
var s = Math.round(secs % 60);
RowLayout { return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
Layout.fillWidth: true
spacing: Theme.rightPaneGap
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Family Code"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferInfoFamily; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Serial Number"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferSerial; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Sensor Count"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferSensors; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.rightPaneGap
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Runtime"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferRuntime; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Cycles"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferCycles; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
}
}
}
// --- Data Summary (shown after parse) ---
GroupBox {
title: "Data Summary"
visible: root.dataParsed
Layout.fillWidth: true
RowLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Rows"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { text: String(root.dataRows); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Sensors"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { text: String(root.dataCols); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "CSV"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { text: root.csvPath; color: Theme.bodyColor; font.pixelSize: 12; elide: Text.ElideMiddle }
} }
} }
} }
// --- Activity Log --- // ═══════════════════════════════════════════════════════════════════════
// ROW 3: Activity Log — ALWAYS visible (fills remaining height)
// ═══════════════════════════════════════════════════════════════════════
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.cardBackground color: Theme.cardBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd radius: Theme.radiusMd
clip: true clip: true
@@ -236,63 +498,47 @@ ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
// Header
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 40 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
anchors.leftMargin: Theme.panelPadding anchors.leftMargin: Theme.panelPadding
anchors.rightMargin: Theme.panelPadding anchors.rightMargin: Theme.panelPadding
spacing: 8
Label { IconImage {
text: "ACTIVITY LOG" source: "icons/file-text.svg"
color: Theme.bodyColor width: 14; height: 14
font.pixelSize: 10 sourceSize.width: 14
font.letterSpacing: 1.8 sourceSize.height: 14
font.weight: Font.Medium color: Theme.sideMutedText
Layout.fillWidth: true visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
Button { Label {
text: "REFRESH SESSION" text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
font.pixelSize: 10 color: Theme.sideMutedText
implicitHeight: 24 font.pixelSize: Theme.fontMd
hoverEnabled: true font.letterSpacing: 1.5
background: Rectangle { font.bold: true
color: parent.hovered ? Theme.buttonNeutralHover : "transparent" font.family: Theme.uiFontFamily
radius: Theme.radiusSm elide: Text.ElideMiddle
} Layout.fillWidth: true
contentItem: Text { Layout.alignment: Qt.AlignVCenter
text: parent.text
color: Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearSession()
}
Button {
text: "CLEAR LOG"
font.pixelSize: 10
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearActivityLog()
} }
} }
@@ -314,10 +560,12 @@ 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: 12 font.pixelSize: Theme.fontSm
wrapMode: TextArea.WordWrap wrapMode: TextArea.WordWrap
color: Theme.bodyColor color: Theme.bodyColor
background: null background: null
@@ -325,60 +573,33 @@ ColumnLayout {
} }
} }
Connections {
target: deviceController
function onActivityLogUpdated(newLog) {
activityLog.text = newLog
}
}
}
} }
// --- Signal Handlers --- // ═══════════════════════════════════════════════════════════════════════
// SIGNAL HANDLERS
// ═══════════════════════════════════════════════════════════════════════
Connections { Connections {
target: deviceController target: deviceController
function onLogMessage(message) { function onLogMessage(message) {
if (message == "CHOOSE_SAVE_DIR") { if (message == "CHOOSE_SAVE_DIR") {
saveDirDialog.open() saveDirDialog.open();
} }
} }
function onPortsUpdated() {}
// Any operation start (detect/read/erase/debug) latches the panel on.
function onPortsUpdated() {
// Status tab always visible now
}
function onDetectResult(result) { function onDetectResult(result) {
if (result && result.familyCode) { if (!result) {
root.waferDetected = true root.dataParsed = false;
waferInfoFamily.text = result.familyCode root.dataRows = 0;
waferSerial.text = result.serialNumber root.dataCols = 0;
waferSensors.text = String(result.sensorCount) root.csvPath = "";
waferRuntime.text = result.runtime + "s"
waferCycles.text = String(result.cycleCount)
} }
// On failure, keep the last displayed wafer info and do not change waferDetected.
} }
function onStatusRestored() { function onStatusRestored() {
// Show restored wafer info if available
var info = deviceController.lastWaferInfo
if (info && info.length > 0) {
root.waferDetected = true
waferInfoFamily.text = info[0] || "—"
waferSerial.text = info[1] || "—"
waferSensors.text = String(info[2] || 0)
waferRuntime.text = (info[3] || 0) + "s"
waferCycles.text = String(info[4] || 0)
}
// Show data summary if data was previously parsed
if (deviceController.dataRowCount > 0) { if (deviceController.dataRowCount > 0) {
root.dataParsed = true root.dataParsed = true;
root.dataRows = deviceController.dataRowCount root.dataRows = deviceController.dataRowCount;
root.dataCols = deviceController.dataColCount root.dataCols = deviceController.dataColCount;
root.csvPath = "" // CSV path not persisted as full path, just show it was parsed root.csvPath = "";
} }
} }
} }
@@ -386,13 +607,12 @@ ColumnLayout {
Connections { Connections {
target: deviceController target: deviceController
function onReadResult(result) { function onReadResult(result) {
root.dataParsed = false root.dataParsed = false;
root.dataRows = 0 root.dataRows = 0;
root.dataCols = 0 root.dataCols = 0;
root.csvPath = "" root.csvPath = "";
if (result && result.success === true) if (result && result.success === true)
saveDirDialog.open() saveDirDialog.open();
} }
} }
@@ -400,12 +620,19 @@ ColumnLayout {
target: deviceController target: deviceController
function onParsedDataReady(result) { function onParsedDataReady(result) {
if (result && result.success) { if (result && result.success) {
root.dataParsed = true root.dataParsed = true;
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;
} }
} }
} }
+115 -214
View File
@@ -1,6 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Tabs.components import ISC.Tabs.components
@@ -10,29 +10,26 @@ Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
// Live elapsed-time counter // Wire streamController.trendDelta (Signal(str) carrying one new
property int _liveSecs: 0 // [elapsed_s, value] pair per live frame) into the trend chart, which
Timer { // accumulates its own bounded 60s window. trendReset clears it when a
id: liveTimer // new stream starts. Parsing happens on the Python TrendChartItem side
interval: 1000 // so malformed payloads are logged there instead of crashing the QML
repeat: true // binding.
running: streamController.mode === "live" && streamController.state !== "idle" property string loadErrorMessage: ""
onTriggered: root._liveSecs++
onRunningChanged: if (!running) root._liveSecs = 0
}
function fmtTime(s) {
var m = Math.floor(s / 60)
var ss = s % 60
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss
}
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
// into the trend chart's data property. The slot parseJsonToData() is defined
// on the Python TrendChartItem; we use it so malformed payloads are logged
// there instead of crashing the QML binding.
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onTrendDelta(deltaJson) {
trendChart.setDataFromJson(avgsJson) trendChart.appendDelta(deltaJson);
}
function onTrendReset() {
trendChart.clearTrend();
}
function onLoadedFileChanged() {
root.loadErrorMessage = "";
}
function onLoadFileError(message) {
root.loadErrorMessage = message;
} }
} }
@@ -46,107 +43,15 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10 spacing: 10
// Mode toggle Item {
TabBar { Layout.fillWidth: true
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: 11
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: 11
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
onCurrentIndexChanged:
if (currentIndex === 1){
// Guard: revert to Review if not connected
if (deviceController.connectionStatus !== "Connected") {
currentIndex = 0
return
}
// Entering Live mode
streamController.setMode("live")
var fc = ""
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
fc = deviceController.lastWaferInfo[0]
}
streamController.startStream(deviceController.selectedPort, fc)
} else {
// Entering Review Mode (automatically calls stopStream in backend)
streamController.setMode("review")
}
}
// Live source info: WiFi icon + port · serial
RowLayout {
visible: streamController.mode === "live"
spacing: 5
// WiFi icon (Unicode approximation; swap for SVG if available)
Label {
id: liveIndicator
text: "◉"
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle")
? Theme.liveColor : Theme.bodyColor
font.pixelSize: 13
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: 11
}
}
Item { Layout.fillWidth: true }
// SET badge + avg temp // SET badge + avg temp
Rectangle { Rectangle {
visible: streamController.state === "set" visible: streamController.state === "set"
height: 22; radius: Theme.radiusSm height: 22
radius: Theme.radiusSm
color: "transparent" color: "transparent"
border.color: Theme.liveColor border.color: Theme.liveColor
border.width: 1 border.width: 1
@@ -154,10 +59,9 @@ Item {
Label { Label {
id: setLabel id: setLabel
anchors.centerIn: parent anchors.centerIn: parent
text: "SET " + (streamController.stats.avg !== undefined text: "SET " + (streamController.stats.avg !== undefined ? streamController.stats.avg + " avg" : "")
? streamController.stats.avg + " avg" : "")
color: Theme.liveColor color: Theme.liveColor
font.pixelSize: 10 font.pixelSize: Theme.fontXs
font.weight: Font.Medium font.weight: Font.Medium
font.letterSpacing: 0.8 font.letterSpacing: 0.8
} }
@@ -168,153 +72,150 @@ Item {
spacing: 5 spacing: 5
visible: streamController.recording visible: streamController.recording
Rectangle { Rectangle {
width: 7; height: 7; radius: 4 width: 7
height: 7
radius: 4
color: Theme.recordColor color: Theme.recordColor
SequentialAnimation on opacity { SequentialAnimation on opacity {
running: streamController.recording running: streamController.recording
loops: Animation.Infinite loops: Animation.Infinite
NumberAnimation { to: 0.2; duration: 600 } NumberAnimation {
NumberAnimation { to: 1.0; duration: 600 } to: 0.2
duration: 600
}
NumberAnimation {
to: 1.0
duration: 600
}
} }
} }
Label { Label {
text: "REC" text: "REC"
color: Theme.recordColor color: Theme.recordColor
font.pixelSize: 10 font.pixelSize: Theme.fontXs
font.weight: Font.Medium font.weight: Font.Medium
font.letterSpacing: 1.2 font.letterSpacing: 1.2
} }
} }
// LIVE timer
// RowLayout {
// spacing: 5
// visible: streamController.mode === "live" && streamController.state !== "idle"
// Rectangle {
// width: 7; height: 7; radius: 4
// color: Theme.liveColor
// }
// Label {
// text: "LIVE " + root.fmtTime(root._liveSecs)
// color: Theme.liveColor
// font.pixelSize: 10
// font.weight: Font.Medium
// font.letterSpacing: 0.8
// }
// }
// IDLE label (review / not connected)
// Label {
// visible: streamController.mode === "review" || streamController.state === "idle"
// text: streamController.state.toUpperCas`e`()
// color: Theme.bodyColor
// font.pixelSize: 11
// font.weight: Font.Medium
// font.letterSpacing: 1.2
// }
Button{
text: "Export PNG"
onClicked: exportDialog.open()
} }
FileDialog { // ── Load error banner ───────────────────────────────────────────
id: exportDialog Rectangle {
title: "Export Wafer Map" Layout.fillWidth: true
fileMode: FileDialog.SaveFile Layout.preferredHeight: errorRow.implicitHeight + 16
nameFilters: ["PNG files (*.png)"] visible: root.loadErrorMessage !== ""
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, "")) color: Theme.errorSurface
border.color: Theme.statusErrorColor
border.width: 1
radius: Theme.radiusSm
RowLayout {
id: errorRow
anchors.fill: parent
anchors.margins: 8
spacing: 8
IconImage {
source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
}
Label {
text: root.loadErrorMessage
color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
} }
} }
// ── Body: 3 zones (TODO P2.1: becomes 2 zones) ────────────────────── // ── Body: 2 columns (wafer + trend + transport | readout) ──────────
// -------------------------------------------------------------------
// TODO P2.1: Delete the SourcePanel Rectangle (lines ~219-233) —
// the file list moved to the rail (P1.3 Map context panel).
//
// THINKING:
// SourcePanel shows a file list driven by file_browser — now that
// the Map tab's context panel in the rail (P1.3) provides the same
// file list with search/filter, SourcePanel in the wafer body is
// redundant. The wafer map gets more horizontal space.
//
// After deletion: RowLayout has 2 children instead of 3.
// Add Layout.fillWidth: true to the center ColumnLayout so it
// fills the vacated width.
//
// Cross-ref: P1.3 (Map context panel), P2.1 (this task)
// -------------------------------------------------------------------
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 16 spacing: 16
// Source panel — bordered card
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
SourcePanel {
anchors.fill: parent
anchors.margins: 10
}
}
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
} }
Rectangle { Rectangle {
id: trendPane id: trendPane
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 160 Layout.fillHeight: false
visible: trendChart.hasData Layout.preferredHeight: 220
Layout.minimumHeight: 120
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
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 10
TrendChartItem { TrendChartItem {
id: trendChart id: trendChart
anchors.fill: parent
anchors.margins: 8
}
}
Item { Layout.fillWidth: true; implicitHeight: 16 }
TransportBar {
Layout.fillWidth: true Layout.fillWidth: true
visible: streamController.mode !== "live" || trendChart.hasData Layout.fillHeight: true
height: visible ? implicitHeight : 0 }
} }
} }
// Readout panel — bordered card Item {
Rectangle { Layout.fillWidth: true
Layout.preferredWidth: 220 visible: transportBar.hasContent
implicitHeight: trendPane.visible ? 52 : 16
}
TransportBar {
id: transportBar
Layout.fillWidth: true
}
}
// Readout panel — scrolls instead of clipping so the last card
// (Thresholds) is always reachable even if content outgrows
// the available height (e.g. once E-C delta rows appear).
ScrollView {
Layout.preferredWidth: Theme.rightRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ReadoutPanel { ReadoutPanel {
id: readoutPanel id: readoutPanel
anchors.fill: parent width: parent.width
anchors.margins: 10 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 } }
}
}
@@ -0,0 +1,73 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import ISC
// ===== Rail Action Button =====
// Reusable pill-style button for rail action panels.
// Icon left, uppercase label, fixed height, hover/active backgrounds.
Rectangle {
id: root
property string label: ""
property string iconSource: ""
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
: root.destructive ? Theme.statusErrorColor
: Theme.headingColor
signal clicked()
implicitHeight: Theme.sideButtonHeight
radius: 8
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
? Theme.sideActiveBackground : "transparent"
border.width: 0
Behavior on color {
ColorAnimation { duration: 100 }
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 10
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
source: root.iconSource
sourceSize.width: 18
sourceSize.height: 18
color: root._textColor
opacity: root.enabled ? 0.85 : 0.35
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: root.label
color: root._textColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.2
opacity: root.enabled ? 1.0 : 0.4
elide: Text.ElideRight
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: root.enabled
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.enabled) root.clicked()
}
}
}
+186 -191
View File
@@ -1,247 +1,231 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
// ===== Readout Panel =====
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
// 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 {
spacing: 0 id: root
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 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
Text { component BadgePill: Rectangle {
anchors.centerIn: parent implicitWidth: badgeLabel.implicitWidth + 10
text: "✓" implicitHeight: 10
font.pixelSize: 13 radius: 9
font.bold: true color: Theme.fieldBackground
color: Theme.panelBackground
visible: toggle.checked
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || 11
}
}
// ── READOUT ───────────────────────────────────────────────────────────
Label {
text: "READOUT"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
topPadding: 6
bottomPadding: 8
}
Rectangle {
Layout.fillWidth: true
implicitHeight: statsLayout.implicitHeight + 20
color: Theme.subtleSectionBackground
radius: Theme.radiusSm
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
property alias text: badgeLabel.text
Label {
id: badgeLabel
anchors.centerIn: parent
color: Theme.bodyColor
font.pixelSize: Theme.font2xs
font.weight: Font.Medium
}
}
StreamControlPanel {
Layout.fillWidth: true
onExportRequested: function(filePath, extra) {
root.exportRequested(filePath, extra);
}
}
// ── READOUT ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: readoutCol.implicitHeight + 24
ColumnLayout { ColumnLayout {
id: statsLayout id: readoutCol
anchors { fill: parent; margins: 10 } anchors.fill: parent
anchors.margins: 12
spacing: 8 spacing: 8
// Min Temp Row SectionTitle { text: "READOUT" }
RowLayout {
ReadoutStat {
label: "Min Temp"
value: s.min !== undefined
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
: "—"
valueColor: Theme.sensorLow
}
ReadoutStat {
label: "Max Temp"
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 + ")"
: "—"
}
}
}
// ── DISPLAY ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
Label { text: "Min Temp"; color: Theme.bodyColor; font.pixelSize: 11 } Layout.preferredHeight: displayCard.implicitHeight + 24
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow
font.pixelSize: 13; font.weight: Font.Bold
}
Label {
text: s.minIndex !== undefined ? "#" + s.minIndex : ""
color: Theme.bodyColor
font.pixelSize: 10
}
}
}
// Max Temp Row ColumnLayout {
RowLayout { id: displayCard
Layout.fillWidth: true anchors.fill: parent
Label { text: "Max Temp"; color: Theme.bodyColor; font.pixelSize: 11 } anchors.margins: 12
Item { Layout.fillWidth: true } spacing: 8
RowLayout {
spacing: 4
Label {
text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh
font.pixelSize: 13; font.weight: Font.Bold
}
Label {
text: s.maxIndex !== undefined ? "#" + s.maxIndex : ""
color: Theme.bodyColor
font.pixelSize: 10
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder } SectionTitle { text: "DISPLAY" }
// Differential Row
RowLayout {
Layout.fillWidth: true
Label { text: "Differential"; color: Theme.bodyColor; font.pixelSize: 11 }
Item { Layout.fillWidth: true }
Label {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.headingColor
font.pixelSize: 13; font.weight: Font.Bold
}
}
// Average Row
RowLayout {
Layout.fillWidth: true
Label { text: "Average"; color: Theme.bodyColor; font.pixelSize: 11 }
Item { Layout.fillWidth: true }
Label {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.pixelSize: 13; font.weight: Font.Bold
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
// Sigma Row
RowLayout {
Layout.fillWidth: true
Label { text: "Sigma (σ)"; color: Theme.bodyColor; font.pixelSize: 11 }
Item { Layout.fillWidth: true }
Label {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.pixelSize: 13; font.weight: Font.Bold
}
}
// 3-Sigma Row
RowLayout {
Layout.fillWidth: true
Label { text: "3σ Value"; color: Theme.bodyColor; font.pixelSize: 11 }
Item { Layout.fillWidth: true }
Label {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.bodyColor
font.pixelSize: 12; font.weight: Font.Medium
}
}
}
}
Item { height: 12 }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
Item { height: 12 }
// ── DISPLAY ───────────────────────────────────────────────────────────
Label {
text: "DISPLAY"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
topPadding: 6
bottomPadding: 8
}
PanelCheckBox { PanelCheckBox {
id: thicknessToggle id: thicknessToggle
text: "Show Thickness" text: "Show Thickness"
checked: false checked: false
font.pixelSize: 11 font.pixelSize: Theme.fontSm
onCheckedChanged: waferMapItem.showThickness = checked // 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: 11 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: 11 font.pixelSize: Theme.fontSm
onCheckedChanged: streamController.clusterAveragingEnabled = checked onCheckedChanged: streamController.clusterAveragingEnabled = checked
} }
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 6 spacing: 6
Label { Label {
text: "Heatmap" text: "Heatmap"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 11 font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52 Layout.preferredWidth: 52
} }
Slider { PanelSlider {
id: heatmapSlider id: heatmapSlider
from: 0; to: 1; value: 0 from: 0
to: 1
value: 0
Layout.fillWidth: true Layout.fillWidth: true
ToolTip.visible: hovered
ToolTip.text: Math.round(value * 100) + "%" AppToolTip {
visible: heatmapSlider.hovered
text: Math.round(heatmapSlider.value * 100) + "%"
} }
} }
Item { height: 12 }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
Item { height: 12 }
// ── THRESHOLDS ────────────────────────────────────────────────────────
Label { Label {
text: "THRESHOLDS" text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 10 font.pixelSize: Theme.fontSm
font.letterSpacing: 1.8
font.weight: Font.Medium font.weight: Font.Medium
topPadding: 6 Layout.preferredWidth: 32
bottomPadding: 8 horizontalAlignment: Text.AlignRight
} }
}
}
}
// ── THRESHOLDS ──────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: thresholdCard.implicitHeight + 24
ColumnLayout {
id: thresholdCard
anchors.fill: parent
anchors.margins: 12
spacing: 6
SectionTitle { text: "THRESHOLDS" }
Label { Label {
text: "Set Point (°C)" text: "Set Point (°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 11 font.pixelSize: Theme.fontSm
bottomPadding: 3
} }
TextField { TextField {
id: spField id: spField
Layout.fillWidth: true Layout.fillWidth: true
text: "149.0" text: "149.0"
font.pixelSize: 12 font.pixelSize: Theme.fontSm
inputMethodHints: Qt.ImhFormattedNumbersOnly inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder placeholderTextColor: Theme.fieldPlaceholder
@@ -252,23 +236,26 @@ ColumnLayout {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
onEditingFinished: pushThresholds() onEditingFinished: pushThresholds()
} }
Item { height: 10 }
Label { Label {
text: "Margin (±°C)" text: "Margin (±°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 11 font.pixelSize: Theme.fontSm
bottomPadding: 3
} }
TextField { TextField {
id: mgField id: mgField
Layout.fillWidth: true Layout.fillWidth: true
text: "1.0" text: "1.0"
font.pixelSize: 12 font.pixelSize: Theme.fontSm
inputMethodHints: Qt.ImhFormattedNumbersOnly inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder placeholderTextColor: Theme.fieldPlaceholder
@@ -279,25 +266,33 @@ ColumnLayout {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
onEditingFinished: pushThresholds() onEditingFinished: pushThresholds()
} }
Item { height: 10 }
PanelCheckBox { PanelCheckBox {
id: autoCheck id: autoCheck
text: "Auto range (mean ± 1σ)" text: "Auto range (mean ± 1σ)"
checked: true checked: true
font.pixelSize: 11 font.pixelSize: Theme.fontSm
onCheckedChanged: pushThresholds() onCheckedChanged: pushThresholds()
} }
}
}
Item { Layout.fillHeight: true } Item {
Layout.fillHeight: true
}
function pushThresholds() { function pushThresholds() {
var sp = parseFloat(spField.text) || 149.0 var sp = parseFloat(spField.text) || 149.0;
var mg = parseFloat(mgField.text) || 1.0 var mg = parseFloat(mgField.text) || 1.0;
streamController.setThresholds(sp, mg, autoCheck.checked) streamController.setThresholds(sp, mg, autoCheck.checked);
} }
} }
@@ -0,0 +1,39 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// Small labeled value tile — DTW readout panel, scrubber readout, and any
// other compact label/value stat display share this one shape.
Rectangle {
id: readoutStat
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: readoutStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: readoutStat.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: readoutStat.valueColor
}
}
}
@@ -10,6 +10,10 @@ Dialog {
standardButtons: Dialog.NoButton standardButtons: Dialog.NoButton
width: 300 width: 300
// Anchor to the top-left of the window instead of the default centered position.
x: 24
y: 24
// called by WaferMapView's TapHandler // called by WaferMapView's TapHandler
property int sensorIndex: -1 property int sensorIndex: -1
property string sensorLabel: "" property string sensorLabel: ""
@@ -17,14 +21,15 @@ Dialog {
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0 property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
function openFor(index) { function openFor(index) {
sensorIndex = index sensorIndex = index;
var dot = streamController.sensorDots[index] var dot = streamController.sensorDots[index];
if (!dot) return if (!dot)
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1) return;
currentValue = dot.value sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1);
valueField.text = "" currentValue = dot.value;
offsetField.text = "" valueField.text = "";
open() offsetField.text = "";
open();
} }
background: Rectangle { background: Rectangle {
@@ -42,11 +47,10 @@ Dialog {
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Label { Label {
text: "Sensor #" + (root.sensorIndex + 1) + text: "Sensor #" + (root.sensorIndex + 1) + (root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
(root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
color: Theme.headingColor color: Theme.headingColor
font.bold: true font.bold: true
font.pixelSize: 13 font.pixelSize: Theme.fontSm
Layout.fillWidth: true Layout.fillWidth: true
} }
Rectangle { Rectangle {
@@ -60,8 +64,8 @@ Dialog {
id: overrideTag id: overrideTag
anchors.centerIn: parent anchors.centerIn: parent
text: "OVERRIDE" text: "OVERRIDE"
color: "white" color: Theme.statusBadgeText
font.pixelSize: 10 font.pixelSize: Theme.fontXs
font.bold: true font.bold: true
font.letterSpacing: 1 font.letterSpacing: 1
} }
@@ -71,13 +75,21 @@ Dialog {
Label { Label {
text: "Live: " + root.currentValue.toFixed(2) text: "Live: " + root.currentValue.toFixed(2)
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 12 font.pixelSize: Theme.fontSm
} }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder } Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// ── replace field ───────────────────────────────────────── // ── replace field ─────────────────────────────────────────
Label { text: "Replace with value"; color: Theme.bodyColor; font.pixelSize: 11 } Label {
text: "Replace with value"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
TextField { TextField {
id: valueField id: valueField
Layout.fillWidth: true Layout.fillWidth: true
@@ -92,11 +104,21 @@ Dialog {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
} }
// ── offset field ────────────────────────────────────────── // ── offset field ──────────────────────────────────────────
Label { text: "Or add offset (± delta)"; color: Theme.bodyColor; font.pixelSize: 11 } Label {
text: "Or add offset (± delta)"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
TextField { TextField {
id: offsetField id: offsetField
Layout.fillWidth: true Layout.fillWidth: true
@@ -111,10 +133,20 @@ Dialog {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
} }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder } Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// ── buttons ─────────────────────────────────────────────── // ── buttons ───────────────────────────────────────────────
RowLayout { RowLayout {
@@ -140,20 +172,20 @@ Dialog {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
font.bold: true font.bold: true
font.pixelSize: 13 font.pixelSize: Theme.fontSm
} }
onClicked: { onClicked: {
if (valueField.text !== "") { if (valueField.text !== "") {
var v = parseFloat(valueField.text) var v = parseFloat(valueField.text);
if (!isNaN(v)) if (!isNaN(v))
streamController.replaceSensor(root.sensorIndex, v) streamController.replaceSensor(root.sensorIndex, v);
} }
if (offsetField.text !== "") { if (offsetField.text !== "") {
var d = parseFloat(offsetField.text) var d = parseFloat(offsetField.text);
if (!isNaN(d)) if (!isNaN(d))
streamController.offsetSensor(root.sensorIndex, d) streamController.offsetSensor(root.sensorIndex, d);
} }
root.close() root.close();
} }
} }
Button { Button {
@@ -175,11 +207,11 @@ Dialog {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
font.bold: true font.bold: true
font.pixelSize: 13 font.pixelSize: Theme.fontSm
} }
onClicked: { onClicked: {
streamController.clearSensorEdit(root.sensorIndex) streamController.clearSensorEdit(root.sensorIndex);
root.close() root.close();
} }
} }
Button { Button {
@@ -198,7 +230,7 @@ Dialog {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
font.bold: true font.bold: true
font.pixelSize: 13 font.pixelSize: Theme.fontSm
} }
onClicked: root.close() onClicked: root.close()
} }
+135
View File
@@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Run Chart =====
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
// the strip below shows the aggregate min/max/avg over the *visible* range
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
Item {
id: root
property alias sensorNames: chart.sensorNames
property alias seriesData: chart.seriesData
property alias title: chart.title
ColumnLayout {
anchors.fill: parent
spacing: 8
Item {
Layout.fillWidth: true
Layout.fillHeight: true
GraphQuickItem {
id: chart
anchors.fill: parent
showMinMaxMarkers: true
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
}
MouseArea {
id: interaction
anchors.fill: parent
acceptedButtons: Qt.LeftButton
property real lastX: 0
onWheel: function(wheel) {
var frac = Math.max(0, Math.min(1, wheel.x / width));
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
chart.zoomAtFraction(frac, factor);
wheel.accepted = true;
}
onPressed: function(mouse) {
lastX = mouse.x;
}
onPositionChanged: function(mouse) {
if (pressed && width > 0) {
chart.panByFraction(-(mouse.x - lastX) / width);
lastX = mouse.x;
}
}
}
}
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
Label {
text: "Min:"
color: Theme.sensorLow
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Max:"
color: Theme.sensorHigh
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Avg:"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewAvg.toFixed(2)
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Item { Layout.fillWidth: true }
Button {
id: resetZoomBtn
implicitHeight: 32
hoverEnabled: true
text: "Reset Zoom"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
background: Rectangle {
radius: Theme.radiusSm
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
}
contentItem: Text {
text: resetZoomBtn.text
color: Theme.headingColor
font: resetZoomBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: chart.resetZoom()
}
}
}
}
@@ -0,0 +1,12 @@
import QtQuick
import QtQuick.Controls
import ISC
// Uppercase card heading — shared by the Data tab cards.
Label {
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
color: Theme.sideMutedText
}
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import Qt.labs.qmlmodels import Qt.labs.qmlmodels
import ISC import ISC
@@ -24,9 +25,11 @@ Dialog {
property int selectedRow: -1 property int selectedRow: -1
property int metadataEditRow: -1 property int metadataEditRow: -1
readonly property string masterTypeFieldText: { readonly property string masterTypeFieldText: {
if (metadataEditRow < 0) return ""; if (metadataEditRow < 0)
return "";
const rec = tableModel[metadataEditRow]; const rec = tableModel[metadataEditRow];
if (!rec) return ""; if (!rec)
return "";
return rec.masterType ?? ""; return rec.masterType ?? "";
} }
@@ -113,7 +116,7 @@ Dialog {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
font.bold: true font.bold: true
font.pixelSize: 13 font.pixelSize: Theme.fontSm
} }
} }
@@ -129,6 +132,12 @@ Dialog {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
} }
@@ -163,7 +172,7 @@ Dialog {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 16 anchors.leftMargin: 16
text: root.title text: root.title
font.pixelSize: 15 font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
color: Theme.headingColor color: Theme.headingColor
} }
@@ -173,10 +182,17 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 12 anchors.rightMargin: 12
text: "✕"
implicitWidth: 32 implicitWidth: 32
implicitHeight: 32 implicitHeight: 32
onClicked: root.close() onClicked: root.close()
contentItem: IconImage {
source: "../icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.buttonNeutralText
anchors.centerIn: parent
}
} }
} }
@@ -221,7 +237,7 @@ Dialog {
text: file_browser.currentDirectory text: file_browser.currentDirectory
color: Theme.fieldText color: Theme.fieldText
elide: Text.ElideMiddle elide: Text.ElideMiddle
font.pixelSize: 13 font.pixelSize: Theme.fontSm
} }
} }
} }
@@ -242,7 +258,7 @@ Dialog {
anchors.centerIn: parent anchors.centerIn: parent
text: root.headerTitles[index] ?? "" text: root.headerTitles[index] ?? ""
font.bold: true font.bold: true
font.pixelSize: 12 font.pixelSize: Theme.fontSm
color: Theme.headingColor color: Theme.headingColor
} }
} }
@@ -329,7 +345,7 @@ Dialog {
const mt = root.normalizedRows[row]?.masterType ?? ""; const mt = root.normalizedRows[row]?.masterType ?? "";
return mt || "—"; return mt || "—";
} }
font.pixelSize: 14 font.pixelSize: Theme.fontMd
font.bold: true font.bold: true
color: { color: {
const mt = root.normalizedRows[row]?.masterType ?? ""; const mt = root.normalizedRows[row]?.masterType ?? "";
@@ -346,7 +362,7 @@ Dialog {
sourceComponent: Button { sourceComponent: Button {
hoverEnabled: true hoverEnabled: true
text: "Edit…" text: "Edit…"
font.pixelSize: 12 font.pixelSize: Theme.fontSm
onClicked: root.openMetadataEditor(row) onClicked: root.openMetadataEditor(row)
background: Rectangle { background: Rectangle {
@@ -361,7 +377,7 @@ Dialog {
color: Theme.buttonNeutralText color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
font.pixelSize: 12 font.pixelSize: Theme.fontSm
} }
} }
} }
@@ -388,7 +404,7 @@ Dialog {
return ""; return "";
} }
elide: Text.ElideRight elide: Text.ElideRight
font.pixelSize: 13 font.pixelSize: Theme.fontSm
color: Theme.bodyColor color: Theme.bodyColor
} }
} }
@@ -409,7 +425,7 @@ Dialog {
return record.notes ?? ""; return record.notes ?? "";
} }
elide: Text.ElideRight elide: Text.ElideRight
font.pixelSize: 13 font.pixelSize: Theme.fontSm
color: Theme.bodyColor color: Theme.bodyColor
} }
} }
@@ -456,7 +472,7 @@ Dialog {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
text: root.selectedPath text: root.selectedPath
elide: Text.ElideLeft elide: Text.ElideLeft
font.pixelSize: 13 font.pixelSize: Theme.fontSm
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
} }
} }
@@ -504,7 +520,7 @@ Dialog {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 16 anchors.leftMargin: 16
text: metadataEditDialog.title text: metadataEditDialog.title
font.pixelSize: 15 font.pixelSize: Theme.fontLg
font.bold: true font.bold: true
color: Theme.headingColor color: Theme.headingColor
} }
@@ -530,7 +546,7 @@ Dialog {
maximumLineCount: 3 maximumLineCount: 3
elide: Text.ElideRight elide: Text.ElideRight
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 12 font.pixelSize: Theme.fontSm
} }
Label { Label {
+208 -82
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls.impl
import ISC import ISC
import ".." import ".."
@@ -14,10 +15,11 @@ ColumnLayout {
Label { Label {
text: "SOURCE" text: "SOURCE"
color: Theme.bodyColor color: Theme.sideMutedText
font.pixelSize: 10 font.family: Theme.uiFontFamily
font.letterSpacing: 1.8 font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.letterSpacing: 1.5
font.bold: true
topPadding: 6 topPadding: 6
bottomPadding: 8 bottomPadding: 8
Layout.fillWidth: true Layout.fillWidth: true
@@ -26,51 +28,51 @@ ColumnLayout {
Button { Button {
id: editBtn id: editBtn
text: "✎" implicitWidth: 28
implicitWidth: 26 implicitHeight: 28
implicitHeight: 26
hoverEnabled: true hoverEnabled: true
font.pixelSize: 14
background: Rectangle { background: Rectangle {
color: editBtn.pressed ? Theme.buttonPressed color: editBtn.pressed ? Theme.buttonPressed : editBtn.hovered ? Theme.buttonNeutralHover : "transparent"
: editBtn.hovered ? Theme.buttonNeutralHover
: "transparent"
radius: Theme.radiusSm radius: Theme.radiusSm
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/edit.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.bodyColor color: Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: { onClicked: {
file_browser.refreshFiles() file_browser.refreshFiles();
csvEditorDialog.open() csvEditorDialog.open();
}
AppToolTip {
visible: editBtn.hovered
text: "Edit"
} }
} }
Button { Button {
id: refreshBtn id: refreshBtn
text: "⟳" implicitWidth: 28
implicitWidth: 26 implicitHeight: 28
implicitHeight: 26
hoverEnabled: true hoverEnabled: true
font.pixelSize: 14
background: Rectangle { background: Rectangle {
color: refreshBtn.pressed ? Theme.buttonPressed color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
: refreshBtn.hovered ? Theme.buttonNeutralHover
: "transparent"
radius: Theme.radiusSm radius: Theme.radiusSm
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/refresh.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.bodyColor color: Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: file_browser.refreshFiles() onClicked: file_browser.refreshFiles()
AppToolTip {
visible: refreshBtn.hovered
text: "Refresh"
}
} }
} }
@@ -79,28 +81,28 @@ ColumnLayout {
id: dirBtn id: dirBtn
Layout.fillWidth: true Layout.fillWidth: true
hoverEnabled: true hoverEnabled: true
implicitHeight: 32 implicitHeight: 36
leftPadding: 8 leftPadding: 8
rightPadding: 8 rightPadding: 8
background: Rectangle { background: Rectangle {
radius: Theme.radiusSm radius: Theme.radiusSm
color: dirBtn.pressed ? Theme.buttonNeutralPressed color: dirBtn.pressed ? Theme.buttonNeutralPressed : dirBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
: dirBtn.hovered ? Theme.buttonNeutralHover
: Theme.buttonNeutralBackground
border.width: Theme.borderThin border.width: Theme.borderThin
border.color: Theme.fieldBorder border.color: Theme.fieldBorder
} }
contentItem: RowLayout { contentItem: RowLayout {
spacing: 6 spacing: 6
Label { IconImage {
text: "▤" source: "../icons/folder.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 12
} }
Label { Label {
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
color: Theme.headingColor color: Theme.headingColor
font.pixelSize: 11 font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle elide: Text.ElideMiddle
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -113,7 +115,7 @@ ColumnLayout {
id: filter id: filter
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: "Filter…" placeholderText: "Filter…"
font.pixelSize: 11 font.pixelSize: Theme.fontSm
color: Theme.fieldText color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground selectedTextColor: Theme.fieldBackground
@@ -123,6 +125,12 @@ ColumnLayout {
color: Theme.fieldBackground color: Theme.fieldBackground
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
} }
} }
@@ -146,28 +154,62 @@ ColumnLayout {
focusPolicy: Qt.NoFocus focusPolicy: Qt.NoFocus
readonly property bool matchesFilter: { readonly property bool matchesFilter: {
if (filter.text === "") return true if (filter.text === "")
var q = filter.text.toLowerCase() return true;
return (modelData.baseName + modelData.waferType + modelData.date) var q = filter.text.toLowerCase();
.toLowerCase().indexOf(q) >= 0 return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
} }
// Active only in review mode; no highlight during live streaming readonly property var dataTab: {
readonly property bool isActive: try {
streamController.mode === "review" && return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
streamController.loadedFile !== "" && } catch (e) { return null; }
modelData.fileName === streamController.loadedFile }
// Run A's effective file: the master file when "Compare vs Master
// File" is toggled on, otherwise the manually selected file.
readonly property string effectiveRunAFile: fileItem.dataTab
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
: ""
readonly property bool isRunA: fileItem.dataTab !== null
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
readonly property bool isRunB: fileItem.dataTab !== null
&& modelData.fileName === fileItem.dataTab.compareFileB
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
readonly property bool isActive: fileItem.dataTab !== null
? (fileItem.isRunA || fileItem.isRunB)
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
// A file registered as any family's master in Settings → Master Files.
readonly property bool isMasterFile: {
var m = settingsModel.masters
for (var fam in m) {
if (m[fam] && m[fam] === modelData.fileName) return true
}
return false
}
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
visible: matchesFilter visible: matchesFilter
height: matchesFilter ? implicitHeight : 0 height: matchesFilter ? implicitHeight : 0
enabled: !liveFileBlocked
opacity: liveFileBlocked ? 0.45 : 1.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
onClicked: streamController.loadFile(modelData.fileName) onClicked: streamController.loadFile(modelData.fileName)
background: Rectangle { background: Rectangle {
color: fileItem.isActive color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
? Qt.rgba(1, 1, 1, 0.06)
: fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
radius: Theme.radiusSm radius: Theme.radiusSm
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
// Left accent bar — shown only when active // Left accent bar — shown only when active
Rectangle { Rectangle {
@@ -175,15 +217,23 @@ ColumnLayout {
width: 3 width: 3
radius: 1.5 radius: 1.5
anchors { anchors {
top: parent.top; bottom: parent.bottom; left: parent.left top: parent.top
topMargin: 5; bottomMargin: 5 bottom: parent.bottom
left: parent.left
topMargin: 5
bottomMargin: 5
} }
color: { color: {
var t = modelData.waferType if (fileItem.isRunA) return Theme.primaryAccent;
if (t === "A" || t === "E" || t === "P") return "#3B82F6" if (fileItem.isRunB) return Theme.themeSkill;
if (t === "B" || t === "C" || t === "D") return "#10B981" var t = modelData.waferType;
if (t === "Z") return "#8B5CF6" if (t === "A" || t === "E" || t === "P")
return Theme.headingColor return Theme.familyBlueAccent;
if (t === "B" || t === "C" || t === "D")
return Theme.familyGreenAccent;
if (t === "Z")
return Theme.familyVioletAccent;
return Theme.headingColor;
} }
} }
} }
@@ -193,24 +243,55 @@ ColumnLayout {
// Wafer-type avatar (circle) // Wafer-type avatar (circle)
Rectangle { Rectangle {
implicitWidth: 28; implicitHeight: 28 implicitWidth: 28
implicitHeight: 28
Layout.preferredWidth: 28 Layout.preferredWidth: 28
Layout.preferredHeight: 28 Layout.preferredHeight: 28
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
radius: 14 radius: 14
color: { color: {
var t = modelData.waferType var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") return "#1D4ED8" if (t === "A" || t === "E" || t === "P")
if (t === "B" || t === "C" || t === "D") return "#065F46" return Theme.familyBlueFill;
if (t === "Z") return "#7C3AED" if (t === "B" || t === "C" || t === "D")
return "#374151" return Theme.familyGreenFill;
if (t === "Z")
return Theme.familyVioletFill;
return Theme.familyNeutralFill;
} }
Label { Label {
anchors.centerIn: parent anchors.centerIn: parent
text: modelData.waferType || "?" text: modelData.waferType || "?"
font.pixelSize: 12 font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
color: "#FFFFFF" color: Theme.textOnColor
}
// Recording indicator dot — distinguishes a live-recorded CSV
// (SessionController.startRecording) from a read-memory dump
// (DeviceController.parseAndSaveData).
Rectangle {
visible: modelData.isRecording === true
width: 10
height: 10
radius: 5
color: Theme.recordColor
border.color: Theme.subtleSectionBackground
border.width: 1.5
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: -2
anchors.rightMargin: -2
}
// Accent ring — this file is registered as a master (Settings → Master Files).
Rectangle {
visible: fileItem.isMasterFile
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Theme.primaryAccent
border.width: 2
} }
} }
@@ -219,40 +300,83 @@ ColumnLayout {
spacing: 1 spacing: 1
Layout.fillWidth: true Layout.fillWidth: true
// Primary: wafer type + serial number // Primary: wafer type + serial number, plus a REC badge for recordings
RowLayout {
Layout.fillWidth: true
spacing: 6
Label { Label {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor color: Theme.headingColor
font.pixelSize: 13 font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
elide: Text.ElideRight elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
} }
Rectangle {
visible: modelData.isRecording === true
radius: 4
color: Qt.alpha(Theme.recordColor, 0.18)
implicitWidth: recLabel.implicitWidth + 10
implicitHeight: recLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: recLabel
anchors.centerIn: parent
text: "REC"
color: Theme.recordColor
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
Rectangle {
visible: fileItem.isMasterFile
radius: 4
color: Qt.alpha(Theme.primaryAccent, 0.18)
implicitWidth: masterLabel.implicitWidth + 10
implicitHeight: masterLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: masterLabel
anchors.centerIn: parent
text: "MASTER"
color: Theme.primaryAccent
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
}
// Secondary: date · time // Secondary: date · time
RowLayout { RowLayout {
spacing: 3 spacing: 3
Layout.fillWidth: true Layout.fillWidth: true
Label { Label {
text: { text: {
var d = modelData.date || "" var d = modelData.date || "";
return d.length >= 10 ? d.substring(0, 10) : (d || "—") return d.length >= 10 ? d.substring(0, 10) : (d || "—");
} }
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 10 font.pixelSize: Theme.fontSm
} }
Label { Label {
visible: (modelData.timeStr || "") !== "" visible: (modelData.timeStr || "") !== ""
text: "·" text: "·"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 10 font.pixelSize: Theme.fontSm
opacity: 0.45 opacity: 0.45
} }
Label { Label {
visible: (modelData.timeStr || "") !== "" visible: (modelData.timeStr || "") !== ""
text: modelData.timeStr || "" text: modelData.timeStr || ""
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 10 font.pixelSize: Theme.fontSm
opacity: 0.65 opacity: 0.65
} }
} }
@@ -260,18 +384,20 @@ ColumnLayout {
// Tertiary: edited metadata (chamber · notes) if any // Tertiary: edited metadata (chamber · notes) if any
Label { Label {
visible: { visible: {
var c = modelData.chamber || "" var c = modelData.chamber || "";
var n = modelData.notes || "" var n = modelData.notes || "";
return c !== "" || n !== "" return c !== "" || n !== "";
} }
text: { text: {
var parts = [] var parts = [];
if (modelData.chamber) parts.push(modelData.chamber) if (modelData.chamber)
if (modelData.notes) parts.push(modelData.notes) parts.push(modelData.chamber);
return parts.join(" · ") if (modelData.notes)
parts.push(modelData.notes);
return parts.join(" · ");
} }
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 9 font.pixelSize: Theme.fontXs
opacity: 0.7 opacity: 0.7
elide: Text.ElideRight elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
@@ -285,7 +411,7 @@ ColumnLayout {
Label { Label {
text: file_browser.files.length + " files · CSV only" text: file_browser.files.length + " files · CSV only"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 10 font.pixelSize: Theme.fontSm
} }
SelectFileDialog { SelectFileDialog {
@@ -0,0 +1,359 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
import ISC
// ===== Split Dialog (Threshold Segmentation) =====
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
// Backend: splitter.segment_profile() returns list of DataSegment objects.
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: Math.min(parent.width - 100, 800)
height: Math.min(parent.height - 100, 600)
anchors.centerIn: Overlay.overlay
property real threshold: 40.0
property bool segmenting: false
property var segments: []
property string currentFile: streamController.loadedFile || ""
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 16
// ── Header ────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "SPLIT DATA"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "\u2715"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// ── Threshold Input ───────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "THRESHOLD TEMPERATURE (°C)"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
RowLayout {
Layout.fillWidth: true
spacing: 8
SpinBox {
id: thresholdSpin
from: 0
to: 200
value: root.threshold
stepSize: 1
editable: true
Layout.fillWidth: true
background: Rectangle {
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
}
Label {
text: "°C"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
verticalAlignment: Text.AlignVCenter
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "SESSION CSV"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
Label {
Layout.fillWidth: true
text: root.currentFile !== ""
? root.currentFile.split("/").pop()
: "No file loaded"
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle
}
}
}
// ── Split Button ──────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Button {
id: splitBtn
text: "SPLIT"
Layout.fillWidth: true
enabled: !root.segmenting && root.currentFile !== ""
onClicked: {
root.threshold = thresholdSpin.value;
root.segmenting = true;
streamController.splitData(root.currentFile, root.threshold);
}
background: Rectangle {
radius: Theme.radiusSm
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
border.width: 1
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
BusyIndicator {
running: root.segmenting
visible: root.segmenting
width: 16
height: 16
}
}
}
}
// ── Segments Table ────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.radiusSm
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 0
spacing: 0
// Table header
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label { text: "PHASE"; Layout.preferredWidth: 100; 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: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Item { Layout.preferredWidth: 80 }
}
}
// Table body (scrollable)
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ColumnLayout {
width: parent.width - 32
spacing: 0
Repeater {
model: root.segments.length > 0 ? root.segments : []
delegate: Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 40
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
border.color: Theme.cardBorder
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label {
text: modelData.label || ""
Layout.preferredWidth: 100
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
font.bold: true
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.start_frame || 0).toString()
Layout.preferredWidth: 120
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.end_frame || 0).toString()
Layout.preferredWidth: 120
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
Layout.fillWidth: true
color: Theme.bodyColor
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
}
}
}
}
}
// Empty state
Label {
visible: root.segments.length === 0
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
Layout.fillWidth: true
Layout.preferredHeight: 100
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// ── Summary line ──────────────────────────────────────────
Label {
visible: root.segments.length > 0
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
color: Theme.bodyColor
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 ────────────────────────────────────────
Connections {
target: streamController
function onSplitResult(result) {
root.segmenting = false;
exportStatus.text = "";
if (result && result.success) {
root.segments = result.segments || [];
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
} else {
root.segments = [];
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
}
}
}
}
+252 -71
View File
@@ -1,103 +1,284 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
RowLayout { // ===== Control Bar =====
spacing: 2 // Review-mode transport footer (frame counter, play/pause/step, speed).
// 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 {
id: bar
Layout.fillWidth: true
implicitHeight: hasContent ? 80 : 0
Behavior on implicitHeight {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
clip: true
component TBtn: Button { // Only shown in review mode, and only once a file is loaded
implicitWidth: 44 readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
implicitHeight: 32
hoverEnabled: true
font.pixelSize: 13
background: Rectangle {
color: parent.pressed ? Theme.transportButtonHover
: parent.hovered ? Theme.transportButtonBg
: Theme.transportBackground
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// Frame counter badge on the left // ── REVIEW MODE ──────────────────────────────────────────────
Item {
id: reviewContent
anchors.fill: parent
// Single centered pill for frame counter + transport + speed
Rectangle { Rectangle {
id: frameCounter
implicitWidth: frameCounterText.implicitWidth + 16
implicitHeight: 32
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: Theme.borderThin
radius: Theme.radiusSm
Text {
id: frameCounterText
anchors.centerIn: parent anchors.centerIn: parent
text: "Frame " + (streamController.frameIndex + 1) + " / " + streamController.frameTotal implicitWidth: reviewRow.implicitWidth + 48
color: Theme.headingColor implicitHeight: 68
font.pixelSize: 11 radius: Theme.radiusMd
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
Row {
id: reviewRow
anchors.centerIn: parent
spacing: 24
// Frame counter
Row {
spacing: 6
anchors.verticalCenter: parent.verticalCenter
Label {
text: "Frame"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium font.weight: Font.Medium
} anchors.verticalCenter: parent.verticalCenter
} }
// Left fill — centers the button cluster TextField {
Item { Layout.fillWidth: true } id: frameInput
text: String(streamController.frameIndex + 1)
TBtn { text: "⏮"; onClicked: streamController.step(-1) } font.pixelSize: Theme.fontMd
TBtn { text: "⏹"; onClicked: streamController.stop() } font.weight: Font.Medium
TBtn { text: "⏸"; onClicked: streamController.pause() } font.family: Theme.codeFontFamily
// Play: slightly wider; leftPadding nudge compensates for ▶ optical offset
TBtn {
text: "▶"
implicitWidth: 52
contentItem: Text {
text: parent.text
color: Theme.headingColor color: Theme.headingColor
font: parent.font verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: Text.AlignHCenter horizontalAlignment: TextInput.AlignHCenter
verticalAlignment: Text.AlignVCenter selectByMouse: true
leftPadding: 2 inputMethodHints: Qt.ImhDigitsOnly
} padding: 0
onClicked: streamController.play() width: Math.max(44, contentWidth + 16)
} height: 28
TBtn { text: "⏭"; onClicked: streamController.step(1) } anchors.verticalCenter: parent.verticalCenter
// Right fill — balances centering background: Rectangle {
Item { Layout.fillWidth: true } color: Theme.fieldBackground
radius: Theme.radiusXs
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
border.width: 1
}
// Speed cycle 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
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// Media Buttons Row
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 12
// Skip-start
Button {
id: skipStartBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 56
implicitHeight: 56
hoverEnabled: true
background: Rectangle {
radius: 8
color: skipStartBtn.pressed ? Theme.transportButtonHover
: skipStartBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: IconImage {
source: "../icons/prev.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor
anchors.centerIn: parent
}
onClicked: streamController.step(-1)
}
// Stop
Button {
id: reviewStopBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 56
implicitHeight: 56
hoverEnabled: true
background: Rectangle {
radius: 8
color: reviewStopBtn.pressed ? Theme.transportButtonHover
: reviewStopBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: IconImage {
source: "../icons/stop.svg"
width: 20; height: 20
sourceSize.width: 20
sourceSize.height: 20
color: Theme.headingColor
anchors.centerIn: parent
}
onClicked: streamController.stop()
}
// Play / Pause toggle
Button {
id: playPauseBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 56
implicitHeight: 56
hoverEnabled: true
background: Rectangle {
radius: 8
color: playPauseBtn.pressed ? Theme.transportButtonHover
: playPauseBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: IconImage {
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor
anchors.centerIn: parent
}
onClicked: {
if (streamController.playing) {
streamController.pause();
} else {
streamController.play();
}
}
}
// Skip-end
Button {
id: skipEndBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 56
implicitHeight: 56
hoverEnabled: true
background: Rectangle {
radius: 8
color: skipEndBtn.pressed ? Theme.transportButtonHover
: skipEndBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: IconImage {
source: "../icons/next.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor
anchors.centerIn: parent
}
onClicked: streamController.step(1)
}
}
// Separator 2
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// Speed capsule button
Button { Button {
id: speedBtn id: speedBtn
property var speeds: [1, 2, 5] anchors.verticalCenter: parent.verticalCenter
property var speeds: [1, 2, 4]
property int idx: 0 property int idx: 0
implicitWidth: 44 implicitWidth: 72
implicitHeight: 32 implicitHeight: 48
hoverEnabled: true hoverEnabled: true
text: speeds[idx] + "×" text: speeds[idx] + "x"
font.pixelSize: 11 font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
background: Rectangle { background: Rectangle {
color: parent.pressed ? Theme.transportButtonHover radius: 8
: parent.hovered ? Theme.transportButtonBg color: speedBtn.pressed ? Theme.transportButtonHover
: Theme.transportBackground : Theme.transportButtonBg
radius: Theme.radiusSm border.color: Theme.sideBorder
border.width: 1
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
} }
contentItem: Text { contentItem: Text {
text: parent.text text: parent.text
color: Theme.bodyColor color: Theme.headingColor
font: parent.font font: parent.font
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
onClicked: { onClicked: {
idx = (idx + 1) % speeds.length idx = (idx + 1) % speeds.length;
streamController.setSpeed(speeds[idx]) streamController.setSpeed(speeds[idx]);
}
}
}
} }
} }
} }
@@ -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
}
}
}
}
}
+35 -10
View File
@@ -3,11 +3,13 @@ import QtQuick.Controls
import ISC import ISC
import ISC.Wafer import ISC.Wafer
Item { 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 hasThickness: map.hasThickness
WaferMapItem { WaferMapItem {
id: map id: map
@@ -29,21 +31,44 @@ Item {
highColor: Theme.sensorHigh highColor: Theme.sensorHigh
textColor: Theme.headingColor textColor: Theme.headingColor
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
hoverScale: 1.0 + 0.35 * hoverPulse
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
Behavior on hoverPulse {
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
}
HoverHandler {
id: hoverHandler
onPointChanged: {
map.hoveredIndex = streamController.mode === "live"
? -1
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
}
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
}
TapHandler { TapHandler {
onTapped: (ev) => { onTapped: ev => {
var idx = map.which_marker(ev.position.x, ev.position.y) if (streamController.mode === "live")
if (idx >= 0) replaceDialog.openFor(idx) return;
var idx = map.which_marker(ev.position.x, ev.position.y);
if (idx >= 0)
replaceDialog.openFor(idx);
} }
} }
} }
ReplaceSensorDialog { id: replaceDialog} ReplaceSensorDialog {
id: replaceDialog
function exportImage(filePath) {
return map.export_image(filePath)
} }
function exportImage(filePath, extra) {
return map.export_image(filePath, extra || "");
}
function loadThickness(filePath) {
return map.loadThickness(filePath);
}
} }
+19
View File
@@ -5,3 +5,22 @@ ReadoutPanel 1.0 ReadoutPanel.qml
TransportBar 1.0 TransportBar.qml 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
SelectFileDialog 1.0 SelectFileDialog.qml
SplitDialog 1.0 SplitDialog.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
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29289 1.29289C9.48043 1.10536 9.73478 1 10 1H18C19.6569 1 21 2.34315 21 4V9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V4C19 3.44772 18.5523 3 18 3H11V8C11 8.55228 10.5523 9 10 9H5V20C5 20.5523 5.44772 21 6 21H7C7.55228 21 8 21.4477 8 22C8 22.5523 7.55228 23 7 23H6C4.34315 23 3 21.6569 3 20V8C3 7.73478 3.10536 7.48043 3.29289 7.29289L9.29289 1.29289ZM6.41421 7H9V4.41421L6.41421 7ZM19 12C19.5523 12 20 12.4477 20 13V19H23C23.5523 19 24 19.4477 24 20C24 20.5523 23.5523 21 23 21H19C18.4477 21 18 20.5523 18 20V13C18 12.4477 18.4477 12 19 12ZM11.8137 12.4188C11.4927 11.9693 10.8682 11.8653 10.4188 12.1863C9.96935 12.5073 9.86526 13.1318 10.1863 13.5812L12.2711 16.5L10.1863 19.4188C9.86526 19.8682 9.96935 20.4927 10.4188 20.8137C10.8682 21.1347 11.4927 21.0307 11.8137 20.5812L13.5 18.2205L15.1863 20.5812C15.5073 21.0307 16.1318 21.1347 16.5812 20.8137C17.0307 20.4927 17.1347 19.8682 16.8137 19.4188L14.7289 16.5L16.8137 13.5812C17.1347 13.1318 17.0307 12.5073 16.5812 12.1863C16.1318 11.8653 15.5073 11.9693 15.1863 12.4188L13.5 14.7795L11.8137 12.4188Z" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>

After

Width:  |  Height:  |  Size: 294 B

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

After

Width:  |  Height:  |  Size: 262 B

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

After

Width:  |  Height:  |  Size: 334 B

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

After

Width:  |  Height:  |  Size: 239 B

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

After

Width:  |  Height:  |  Size: 236 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 3 4 7l4 4" />
<path d="M4 7h16" />
<path d="m16 21 4-4-4-4" />
<path d="M20 17H4" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>

After

Width:  |  Height:  |  Size: 329 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>

After

Width:  |  Height:  |  Size: 275 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 297 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-1.5, 0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29289 1.29289C9.48043 1.10536 9.73478 1 10 1H18C19.6569 1 21 2.34315 21 4V9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V4C19 3.44772 18.5523 3 18 3H11V8C11 8.55228 10.5523 9 10 9H5V20C5 20.5523 5.44772 21 6 21H7C7.55228 21 8 21.4477 8 22C8 22.5523 7.55228 23 7 23H6C4.34315 23 3 21.6569 3 20V8C3 7.73478 3.10536 7.48043 3.29289 7.29289L9.29289 1.29289ZM6.41421 7H9V4.41421L6.41421 7ZM19 12C19.5523 12 20 12.4477 20 13V19H23C23.5523 19 24 19.4477 24 20C24 20.5523 23.5523 21 23 21H19C18.4477 21 18 20.5523 18 20V13C18 12.4477 18.4477 12 19 12ZM11.8137 12.4188C11.4927 11.9693 10.8682 11.8653 10.4188 12.1863C9.96935 12.5073 9.86526 13.1318 10.1863 13.5812L12.2711 16.5L10.1863 19.4188C9.86526 19.8682 9.96935 20.4927 10.4188 20.8137C10.8682 21.1347 11.4927 21.0307 11.8137 20.5812L13.5 18.2205L15.1863 20.5812C15.5073 21.0307 16.1318 21.1347 16.5812 20.8137C17.0307 20.4927 17.1347 19.8682 16.8137 19.4188L14.7289 16.5L16.8137 13.5812C17.1347 13.1318 17.0307 12.5073 16.5812 12.1863C16.1318 11.8653 15.5073 11.9693 15.1863 12.4188L13.5 14.7795L11.8137 12.4188Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 392 B

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

After

Width:  |  Height:  |  Size: 342 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" />
<path d="M3 15h18" />
<path d="M9 3v18" />
<path d="M15 3v18" />
</svg>

After

Width:  |  Height:  |  Size: 355 B

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

After

Width:  |  Height:  |  Size: 237 B

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

After

Width:  |  Height:  |  Size: 313 B

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

After

Width:  |  Height:  |  Size: 250 B

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

After

Width:  |  Height:  |  Size: 238 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

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

After

Width:  |  Height:  |  Size: 393 B

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

After

Width:  |  Height:  |  Size: 297 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 586 B

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

After

Width:  |  Height:  |  Size: 285 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>

After

Width:  |  Height:  |  Size: 271 B

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

After

Width:  |  Height:  |  Size: 261 B

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

After

Width:  |  Height:  |  Size: 346 B

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

After

Width:  |  Height:  |  Size: 260 B

-2
View File
@@ -1,9 +1,7 @@
# ===== ISC Tabs Module ===== # ===== ISC Tabs Module =====
module ISC.Tabs module ISC.Tabs
GraphTab 1.0 GraphTab.qml
WaferMapTab 1.0 WaferMapTab.qml 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
+154 -75
View File
@@ -7,110 +7,191 @@ import QtQuick
// ============================================================================= // =============================================================================
// Sections: // Sections:
// 1. Mode flag // 1. Mode flag
// 2. Tone palette (raw tones; everything else derives from these) // 2. Typography (families, pixel sizes, weights)
// 3. Surfaces (page / card / panel / workspace backgrounds) // 3. Motion
// 4. Borders // 4. Tone palette (raw tones; everything else derives from these)
// 5. Text (headings, body, panel titles) // 5. Surfaces (page / card / panel / workspace backgrounds)
// 6. Controls (fields, buttons, checkbox) // 6. Borders
// 7. Tracks (pill toggle + scrollbar) // 7. Text (headings, body, panel titles)
// 8. Tabs (footer tab bar) // 8. Controls (fields, buttons, checkbox, toggle)
// 9. Side rail // 9. Interaction overlays (hover / active washes — mode-aware)
// 10. Status (success / warning / error) // 10. Tracks (pill toggle + scrollbar)
// 11. Geometry (radius / border width / spacing / sizing) // 11. Tabs (mode toggle pills)
// 12. Side rail
// 13. Transport / toolbar
// 14. Status & badges (success / warning / error, badge text)
// 15. Sensor bands (wafer map dots)
// 16. Wafer family badges (source list avatars + accent bars)
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
// 18. Geometry (radius / border width / spacing / sizing)
// ============================================================================= // =============================================================================
// Based on Codex themes:
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
// Light (absolutely): surface #F9F9F7, ink #2D2D2B
// Accent (both): #F9B98C
QtObject { QtObject {
// ── 1. Mode ────────────────────────────────────────────────────────────── // ── 1. Mode ──────────────────────────────────────────────────────────────
property bool isDarkMode: true property bool isDarkMode: true
// ── 2. Tone palette (base values; consume via semantic tokens below) ───── // ── 2. Typography ────────────────────────────────────────────────────────
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA" readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
readonly property color tone150: isDarkMode ? "#161616" : "#F6F6F6" readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F0F0F0"
readonly property color tone250: isDarkMode ? "#212121" : "#EAEAEA"
readonly property color tone300: isDarkMode ? "#282828" : "#E2E2E2"
readonly property color tone350: isDarkMode ? "#303030" : "#D6D6D6"
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
readonly property color toneBorder: isDarkMode ? "#2E2E2E" : "#D4D4D4"
// ── 3. Surfaces ────────────────────────────────────────────────────────── // pixelSize scale — map to closest token, tune globally
readonly property int font2xs: 9 // tiny metadata
readonly property int fontXs: 11 // captions, micro labels
readonly property int fontSm: 13 // sidebar labels, secondary
readonly property int fontMd: 14 // body, field values
readonly property int fontLg: 16 // section headers
readonly property int fontXl: 20 // panel / dialog titles
readonly property int font2xl: 24 // hero titles
readonly property int font3xl: 30 // display
readonly property int fontWeightRegular: 400
readonly property int fontWeightMedium: 500
readonly property int fontWeightBold: 700
// ── 3. Motion ────────────────────────────────────────────────────────────
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
readonly property int durationSlow: 260 // larger transitions (panel open/close)
readonly property var easeStandard: Easing.OutCubic
// ── 4. Tone palette (Codex theme v1 inspired) ────────────────────────────
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
readonly property color tone100: themeSurface
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
readonly property color tone200: isDarkMode ? "#151518" : "#EEEEEC"
readonly property color tone250: isDarkMode ? "#1B1B1F" : "#E8E8E6"
readonly property color tone300: isDarkMode ? "#222228" : "#E0E0DE"
readonly property color tone350: isDarkMode ? "#2B2B32" : "#D7D7D4"
readonly property color toneText: themeInk
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
// ── 5. Surfaces ──────────────────────────────────────────────────────────
readonly property color pageBackground: tone100 readonly property color pageBackground: tone100
readonly property color cardBackground: tone200 readonly property color cardBackground: tone150
readonly property color panelBackground: tone200 readonly property color panelBackground: tone150
readonly property color workspaceBackground: tone200 readonly property color workspaceBackground: tone150
readonly property color responseBackground: tone200 readonly property color responseBackground: tone150
readonly property color subtleSectionBackground: tone300 readonly property color subtleSectionBackground: tone250
// ── 4. Borders ─────────────────────────────────────────────────────────── // ── 6. Borders ───────────────────────────────────────────────────────────
readonly property color cardBorder: toneBorder readonly property color cardBorder: toneBorder
readonly property color cardSurfaceBorder: isDarkMode ? "#3C3C3C" : "#C8C8C8" readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color responseBorder: toneBorder readonly property color responseBorder: toneBorder
readonly property color workspaceBorder: toneBorder readonly property color workspaceBorder: toneBorder
readonly property color innerFrameBorder: toneBorder readonly property color innerFrameBorder: toneBorder
readonly property color outerFrameBorder: isDarkMode ? "#383838" : "#CACACA" readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color softBorder: isDarkMode ? "#252525" : "#E0E0E0" readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
// ── 5. Text ────────────────────────────────────────────────────────────── // ── 7. Text ──────────────────────────────────────────────────────────────
readonly property color headingColor: toneText readonly property color headingColor: toneText
readonly property color bodyColor: toneMute readonly property color bodyColor: toneMute
readonly property color subheadingColor: toneMute readonly property color subheadingColor: toneMute
readonly property color panelTitleText: toneMute readonly property color panelTitleText: toneMute
readonly property color disabledText: toneMute readonly property color disabledText: toneMute
// ── 6. Controls ────────────────────────────────────────────────────────── // ── 8. Controls ──────────────────────────────────────────────────────────
// Fields // Fields
readonly property color fieldBackground: tone100 readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
readonly property color fieldText: toneText readonly property color fieldText: toneText
readonly property color fieldPlaceholder: toneMute readonly property color fieldPlaceholder: toneMute
readonly property color fieldBorder: toneBorder readonly property color fieldBorder: toneBorder
readonly property color fieldBorderFocus: toneText readonly property color fieldBorderFocus: themeAccent
// Neutral buttons // Neutral buttons
readonly property color buttonNeutralBackground: tone300 readonly property color buttonNeutralBackground: tone250
readonly property color buttonNeutralHover: isDarkMode ? "#303030" : "#DDDDDD" readonly property color buttonNeutralHover: tone300
readonly property color buttonNeutralPressed: tone100 readonly property color buttonNeutralPressed: isDarkMode ? "#0F0F13" : "#FFFFFF"
readonly property color buttonNeutralText: toneText readonly property color buttonNeutralText: toneText
readonly property color buttonPressed: tone300 readonly property color buttonPressed: tone300
readonly property color primaryAccent: toneText readonly property color primaryAccent: themeAccent
// Checkbox // Checkbox
readonly property color checkboxText: toneText readonly property color checkboxText: toneText
// Toggle switch thumb (white knob reads on both track colors)
readonly property color toggleThumb: "#FFFFFF"
// ── 7. Tracks (pill toggle, scrollbar thumb) ───────────────────────────── // ── 9. Interaction overlays ──────────────────────────────────────────────
readonly property color trackBackground: isDarkMode ? "#25252B" : "#D1D5DB" // Mode-aware washes. Never use raw Qt.rgba(1,1,1,x) in views: a white
// overlay is invisible on light-mode surfaces.
readonly property color listHoverBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.04) : Qt.rgba(0, 0, 0, 0.03)
readonly property color listActiveBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.05)
readonly property color flatButtonHover: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.06)
readonly property color cardWash: isDarkMode ? Qt.rgba(1, 1, 1, 0.02) : Qt.rgba(0, 0, 0, 0.02)
// ── 8. Tabs (footer tab bar) ───────────────────────────────────────────── // ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
readonly property color tabBarBackground: tone200 readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
readonly property color tabBackground: "transparent"
readonly property color tabActiveBackground: isDarkMode ? "#333333" : "#FFFFFF"
readonly property color tabHoverBackground: tone300
readonly property color tabBorder: "transparent"
readonly property color tabText: toneMute
readonly property color tabActiveText: toneText
// ── 9. Side rail ───────────────────────────────────────────────────────── // ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
readonly property color sideRailBackground: tone200 readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color sideActiveBackground: tone300
// ── 10a. Transport / toolbar surfaces ──────────────────────────────────── // ── 12. Side rail ────────────────────────────────────────────────────────
readonly property color transportBackground: isDarkMode ? "#0D0D0D" : "#E8E8E8" readonly property color sideRailBackground: tone150
readonly property color transportButtonBg: isDarkMode ? "#2A2A2A" : "#D4D4D4" readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
readonly property color transportButtonHover: isDarkMode ? "#3A3A3A" : "#C4C4C4" readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color liveColor: isDarkMode ? "#22C55E" : "#16A34A" readonly property color sideBorder: toneBorder
readonly property color recordColor: isDarkMode ? "#EF4444" : "#DC2626" readonly property color sideMutedText: toneMute
// ── 10. Status ─────────────────────────────────────────────────────────── // ── 13. Transport / toolbar surfaces ─────────────────────────────────────
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44" readonly property color transportButtonBg: tone250
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18" readonly property color transportButtonHover: tone300
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545" readonly property color liveColor: themeAdded
readonly property color recordColor: themeRemoved
// -- 10b. Sensor bands (wafer map dots) // ── 14. Status & badges ──────────────────────────────────────────────────
readonly property color statusSuccessColor: themeAdded
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
readonly property color statusErrorColor: themeRemoved
// Text placed ON a filled status pill / saturated chip:
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
// Error banner (soft red surface + readable red text)
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
// ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
readonly property color sensorInRange: statusSuccessColor readonly property color sensorInRange: statusSuccessColor
// ponytail: high-temp shares error red — semantically correct (dangerous value),
// re-hue if colorblind testing demands a different channel.
readonly property color sensorHigh: statusErrorColor readonly property color sensorHigh: statusErrorColor
readonly property color sensorLow: isDarkMode ? "#5B9DF5" : "#2F6FE0" readonly property color sensorLow: themeSkill
readonly property color waferRingColor: toneBorder readonly property color waferRingColor: toneBorder
readonly property color waferAxisColor: softBorder readonly property color waferAxisColor: softBorder
// ── 11. Geometry ───────────────────────────────────────────────────────── // ── 16. Wafer family badges (SourcePanel list) ───────────────────────────
// Accent = left bar / outline shade; Fill = avatar circle behind textOnColor.
// Family grouping mirrors FileBrowser's waferType first-letter convention.
readonly property color familyBlueAccent: "#3B82F6" // A / E / P
readonly property color familyBlueFill: "#1D4ED8"
readonly property color familyGreenAccent: "#10B981" // B / C / D
readonly property color familyGreenFill: "#065F46"
readonly property color familyVioletAccent: "#8B5CF6" // Z
readonly property color familyVioletFill: "#7C3AED"
readonly property color familyNeutralFill: "#374151" // unknown family
// ── 17. Comparison & charts ──────────────────────────────────────────────
// Metric grading for DTW readout cards (good → caution → bad).
// Dark values are pastel (readable on tone150); light values are deepened
// so they still pass on near-white cards.
readonly property color metricGood: isDarkMode ? "#6EE7B7" : "#059669"
readonly property color metricWarn: isDarkMode ? "#FDE047" : "#B45309"
readonly property color metricBad: isDarkMode ? "#EF4444" : "#DC2626"
// DIFF column accent in ReadoutPanel
readonly property color diffAccent: themeViolet
// Canvas chart primitives (DTW plot grid + axis labels)
readonly property color chartGridLine: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.10)
readonly property color chartAxisText: isDarkMode ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(0, 0, 0, 0.45)
// ── 18. Geometry ─────────────────────────────────────────────────────────
// Radius // Radius
readonly property int radiusXs: 6 // fields, tight elements readonly property int radiusXs: 6 // fields, tight elements
readonly property int radiusSm: 8 // buttons readonly property int radiusSm: 8 // buttons
@@ -124,17 +205,15 @@ 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: 190 readonly property int sideRailWidth: 300
readonly property int sideRailSpacing: 14 readonly property int rightRailWidth: 280
readonly property int sideButtonHeight: 146 readonly property int sideRailMargin: 16
readonly property int sideButtonMinHeight: 88 readonly property int sideRailSpacing: 16
// Tab bar layout readonly property int sideButtonHeight: 44
readonly property int tabBarHeight: 34 readonly property int sidePanelRadius: 10
readonly property int tabBarPadding: 6 readonly property int sideFooterConnection: 100
readonly property int tabSpacing: 2 readonly property int sideFooterUtility: 46
readonly property int tabRadius: 8 readonly property int sidePillHeight: 54
readonly property int tabFontSize: 12
readonly property int tabButtonMinWidth: 80
// Settings page layout // Settings page layout
readonly property int settingsPanelMaxWidth: 760 readonly property int settingsPanelMaxWidth: 760
readonly property int settingsOuterMargin: 40 readonly property int settingsOuterMargin: 40
+1
View File
@@ -4,6 +4,7 @@ module ISC
# ===== Root Components ===== # ===== Root Components =====
Main 1.0 Main.qml Main 1.0 Main.qml
HomePage 1.0 HomePage.qml HomePage 1.0 HomePage.qml
AboutDialog 1.0 AboutDialog.qml
# ===== Singleton Theme ===== # ===== Singleton Theme =====
singleton Theme 1.0 Theme.qml singleton Theme 1.0 Theme.qml
+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"]
@@ -3,31 +3,30 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import subprocess
import sys
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any, Optional 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.device_service import DeviceService
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__)
@@ -39,11 +38,15 @@ class DeviceController(QObject):
# ---- public signals ---- # ---- public signals ----
portsUpdated = Signal(list) portsUpdated = Signal(list)
connectionStatusChanged = Signal()
operationInProgressChanged = Signal()
waferDetectedChanged = Signal()
selectedPortChanged = Signal()
detectResult = Signal(object) # WaferInfo dict or None detectResult = Signal(object) # WaferInfo dict or None
readResult = Signal(object) # {"success": bool, "bytes": int} or {"error": str} readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
eraseResult = Signal(object) # {"success": bool} eraseResult = Signal(dict) # {"success": bool}
debugResult = Signal(object) # {"success": bool, "sensor_bytes": int, "debug_bytes": int} debugResult = Signal(dict) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
parsedDataReady = Signal(object) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str} parsedDataReady = Signal(dict) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
statusRestored = Signal() # Emitted when status is restored from previous session statusRestored = Signal() # Emitted when status is restored from previous session
logMessage = Signal(str) logMessage = Signal(str)
activityLogUpdated = Signal(str) activityLogUpdated = Signal(str)
@@ -51,48 +54,48 @@ class DeviceController(QObject):
# ---- private signals (marshal worker-thread results back to main thread) ---- # ---- private signals (marshal worker-thread results back to main thread) ----
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None) _detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None) _readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
_eraseFinished = Signal(bool) # (success)
_debugFinished = Signal(dict) # (result_dict)
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None: def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._settings = settings self._settings = settings
self._data_dir = data_dir self._data_dir = data_dir
from pygui.serialcomm.device_service import DeviceService
self._service = DeviceService(settings) self._service = DeviceService(settings)
# Always start fresh — never restore connection status from a prior session # Always start fresh — never restore connection status or logs from a prior session
self._connection_status = "Disconnected" self._connection_status = "Disconnected"
self._operation_in_progress = False self._operation_in_progress = False
self._activity_log: list[str] = getattr(settings, 'activity_log', []) self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
from pathlib import Path
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")
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {}) # TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
self._last_csv_path: str = getattr(settings, 'last_csv_path', "") # (default lambda s: "") and add:
self._selected_port: str = getattr(settings, 'selected_port', "") # def _wafer_license_level(self) -> str:
self._data_row_count: int = getattr(settings, 'data_row_count', 0) # return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
self._data_col_count: int = getattr(settings, 'data_col_count', 0) # 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._wafer_detected: bool = False
self._last_csv_path: str = ""
self._selected_port: str = ""
self._data_row_count: int = 0
self._data_col_count: int = 0
self._data_model = TemperatureTableModel(self) self._data_model = TemperatureTableModel(self)
self._graph_view = GraphView(self) self._graph_view = GraphView(self)
# If we have persisted activity log, emit it to QML
if self._activity_log:
self.activityLogUpdated.emit("\n".join(self._activity_log))
# Log status restoration and emit signal for UI updates
if self._connection_status != "Disconnected" or self._selected_port or self._last_wafer_info:
self._append_log("Restored previous session status")
if self._selected_port:
self._append_log(f"Last selected port: {self._selected_port}")
if self._last_wafer_info:
info = self._last_wafer_info
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
self.statusRestored.emit()
# Reset connection status on fresh start # Reset connection status on fresh start
self._connection_status = "Disconnected" self._connection_status = "Disconnected"
# Marshal worker-thread results onto the Qt main thread. # Marshal worker-thread results onto the Qt main thread.
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection) self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection) self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
self._eraseFinished.connect(self._handle_erase_finished, Qt.ConnectionType.QueuedConnection)
self._debugFinished.connect(self._handle_debug_finished, Qt.ConnectionType.QueuedConnection)
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.addHandler(QmlActivityLogHandler(self)) root_logger.addHandler(QmlActivityLogHandler(self))
@@ -108,18 +111,23 @@ class DeviceController(QObject):
"""Update connection status and notify QML.""" """Update connection status and notify QML."""
if self._connection_status != status: if self._connection_status != status:
self._connection_status = status self._connection_status = status
self.portsUpdated.emit(self.availablePorts) self.connectionStatusChanged.emit()
@Property(str, notify=portsUpdated) @Property(str, notify=connectionStatusChanged)
def connectionStatus(self) -> str: def connectionStatus(self) -> str:
"""Current connection status string for display.""" """Current connection status string for display."""
return self._connection_status return self._connection_status
@Property(bool, notify=portsUpdated) @Property(bool, notify=operationInProgressChanged)
def operationInProgress(self) -> bool: def operationInProgress(self) -> bool:
"""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."""
@@ -133,7 +141,43 @@ 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()
@Property(str, notify=portsUpdated) @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_error_boundary
def openCsvFile(self, file_path: str) -> None:
"""Open a CSV file in the system default spreadsheet application."""
if not file_path:
self._append_log("No file path provided to openCsvFile")
return
try:
path = Path(file_path)
if not path.exists():
self._append_log(f"File not found: {file_path}")
return
# Platform-appropriate file opener
if sys.platform == "darwin":
subprocess.Popen(["open", str(path)])
elif sys.platform == "linux":
subprocess.Popen(["xdg-open", str(path)])
else: # Windows
os.startfile(str(path))
self._append_log(f"Opened file: {file_path}")
except Exception as exc:
log.warning("Failed to open file %s: %s", file_path, exc)
self._append_log(f"Error opening file: {exc}")
@Property(str, notify=selectedPortChanged)
def selectedPort(self) -> str: def selectedPort(self) -> str:
"""Currently selected serial port shared across the UI.""" """Currently selected serial port shared across the UI."""
return self._selected_port return self._selected_port
@@ -142,7 +186,9 @@ class DeviceController(QObject):
@slot_error_boundary @slot_error_boundary
def setSelectedPort(self, port: str) -> None: def setSelectedPort(self, port: str) -> None:
"""Set the active serial port from the port selector.""" """Set the active serial port from the port selector."""
if self._selected_port != port:
self._selected_port = port self._selected_port = port
self.selectedPortChanged.emit()
self._save_status() self._save_status()
@Property(int, notify=parsedDataReady) @Property(int, notify=parsedDataReady)
@@ -162,15 +208,33 @@ class DeviceController(QObject):
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount]. Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
""" """
info = self._last_wafer_info info = self._last_wafer_info
family_code = info.get("familyCode", "")
sensor_count = info.get("sensorCount", 0)
from pygui.serialcomm.data_parser import csv_column_count
mapped_count = csv_column_count(family_code)
if mapped_count > 0:
sensor_count = mapped_count
return [ return [
info.get("familyCode", ""), family_code,
info.get("serialNumber", ""), info.get("serialNumber", ""),
info.get("sensorCount", 0), sensor_count,
info.get("mfgDateHex", ""), info.get("mfgDateHex", ""),
info.get("runtime", 0), info.get("runtime", 0),
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."""
@@ -235,25 +299,26 @@ 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
self._operation_in_progress = False self._operation_in_progress = False
self._data_model.reset()
self._graph_view.resetChart()
self._activity_log.clear()
self.detectResult.emit(None) self.detectResult.emit(None)
self.parsedDataReady.emit({"success": False})
self.portsUpdated.emit(self.availablePorts) self.portsUpdated.emit(self.availablePorts)
self._append_log("Session refreshed/cleared") self.activityLogUpdated.emit("")
self._save_status() self._save_status()
def _set_operation_progress(self, in_progress: bool) -> None: def _set_operation_progress(self, in_progress: bool) -> None:
"""Set operation progress state and notify QML. """Set operation progress state and notify QML."""
if self._operation_in_progress != in_progress:
portsUpdated is the shared notify signal for availablePorts,
connectionStatus, and operationInProgress — emitting it forces
QML to re-evaluate all three bindings.
"""
self._operation_in_progress = in_progress self._operation_in_progress = in_progress
self.portsUpdated.emit(self._service.enumerate_ports()) self.operationInProgressChanged.emit()
# ---- Public Slots ---- # ---- Public Slots ----
@@ -261,6 +326,7 @@ class DeviceController(QObject):
@slot_error_boundary @slot_error_boundary
def refreshPorts(self) -> None: def refreshPorts(self) -> None:
"""Scan for available serial ports and emit updated list.""" """Scan for available serial ports and emit updated list."""
self._service.clear_port_scan_cache()
ports = self._service.enumerate_ports() ports = self._service.enumerate_ports()
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
self.portsUpdated.emit(ports) self.portsUpdated.emit(ports)
@@ -280,6 +346,19 @@ class DeviceController(QObject):
self._append_log("Already busy — ignoring detect request") self._append_log("Already busy — ignoring detect request")
return return
# Clear previous session data & logs for a fresh session
self._selected_port = ""
self._last_wafer_info = {}
self._data_row_count = 0
self._data_col_count = 0
self._raw_bytes = None
self._data_model.reset()
self._graph_view.resetChart()
self._activity_log.clear()
self.detectResult.emit(None)
self.selectedPortChanged.emit()
self.parsedDataReady.emit({"success": False})
self._set_operation_progress(True) self._set_operation_progress(True)
self._set_connection_status("Detecting...") self._set_connection_status("Detecting...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
@@ -303,23 +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
self._append_log( # waferDetected stays false and Read/Erase stay greyed.
f"Detected: {info.serial_number} (family={info.family_code}, "
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
)
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()
@@ -335,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"})
@@ -391,12 +485,36 @@ class DeviceController(QObject):
@slot_error_boundary @slot_error_boundary
def eraseMemory(self, port: str) -> None: def eraseMemory(self, port: str) -> None:
"""Send p1 erase command.""" """Send p1 erase command."""
if self._operation_in_progress:
self._append_log("Already busy — ignoring erase request")
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())
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...") self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
threading.Thread(
target=self._erase_worker,
args=(port,),
daemon=True,
).start()
def _erase_worker(self, port: str) -> None:
"""Background thread worker for eraseMemory."""
try:
ok = self._service.erase_wafer(port) ok = self._service.erase_wafer(port)
except Exception as exc:
log.exception("Erase worker crashed: %s", exc)
ok = False
self._eraseFinished.emit(ok)
@Slot(bool)
@slot_error_boundary
def _handle_erase_finished(self, ok: bool) -> None:
"""Main thread completion handler for eraseMemory."""
if ok: if ok:
self._set_connection_status("Connected") self._set_connection_status("Connected")
self._append_log("Erase command accepted — wait ~15 seconds") self._append_log("Erase command accepted — wait ~15 seconds")
@@ -409,45 +527,83 @@ class DeviceController(QObject):
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def readDebug(self, port: str) -> None: def readDebug(self, port: str) -> None:
"""Read both D1 (sensor) and F1 (debug/cold-junction) data. """Read both D1 (sensor) and F1 (debug/cold-junction) data."""
if self._operation_in_progress:
Emits debugResult with counts on success. self._append_log("Already busy — ignoring debug read request")
""" return
self._set_operation_progress(True) self._set_operation_progress(True)
self._set_connection_status("Reading debug...") self._set_connection_status("Reading debug...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Reading debug data on {port} ...") self._append_log(f"Reading debug data on {port} ...")
threading.Thread(
target=self._debug_worker,
args=(port,),
daemon=True,
).start()
def _debug_worker(self, port: str) -> None:
"""Background thread worker for readDebug."""
try:
# Step 1: D1 sensor data # Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="") sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None: if sensor_data is None:
self._set_connection_status("Disconnected") self._debugFinished.emit({"error": "D1 read failed"})
self._append_log("D1 read failed — aborting debug read")
self.debugResult.emit({"error": "D1 read failed"})
self._set_operation_progress(False)
return return
self._append_log(f"D1 read: {len(sensor_data)} bytes")
# Step 2: F1 debug data # Step 2: F1 debug data
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1") debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
if debug_data is None: if debug_data is None:
self._set_connection_status("Disconnected") self._debugFinished.emit({"error": "F1 read failed"})
self._append_log("F1 read failed")
self.debugResult.emit({"error": "F1 read failed"})
self._set_operation_progress(False)
return return
self._set_connection_status("Connected") fc = (
self._append_log( self._last_wafer_info.get("familyCode", "")
f"Debug read complete: {len(sensor_data)} sensor bytes, " if self._last_wafer_info
f"{len(debug_data)} debug bytes" else ""
) )
self.debugResult.emit({ 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({
"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:
log.exception("Debug worker crashed: %s", exc)
self._debugFinished.emit({"error": str(exc)})
@Slot(dict)
@slot_error_boundary
def _handle_debug_finished(self, result: dict) -> None:
"""Main thread completion handler for readDebug."""
if result.get("success"):
self._set_connection_status("Connected")
sensor_bytes = result.get("sensor_bytes", 0)
debug_bytes = result.get("debug_bytes", 0)
csv_path = result.get("csv_path", "")
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
if csv_path:
msg += f", CSV written to {csv_path}"
self._append_log(msg)
else:
self._set_connection_status("Disconnected")
err_msg = result.get("error", "Debug read failed")
self._append_log(err_msg)
self.debugResult.emit(result)
self._set_operation_progress(False) self._set_operation_progress(False)
@Slot(str, str) @Slot(str, str)
@@ -467,7 +623,6 @@ class DeviceController(QObject):
return return
if not self._save_data_dir: if not self._save_data_dir:
from pathlib import Path
self._save_data_dir = str(Path(self._data_dir) / "csv") self._save_data_dir = str(Path(self._data_dir) / "csv")
self._append_log(f"Auto-set save directory to: {self._save_data_dir}") self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
self._save_status() self._save_status()
@@ -558,10 +713,16 @@ class DeviceController(QObject):
@staticmethod @staticmethod
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]: def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML.""" """Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
from pygui.serialcomm.data_parser import csv_column_count
sensor_count = info.sensor_count
mapped_count = csv_column_count(info.family_code)
if mapped_count > 0:
sensor_count = mapped_count
return { return {
"familyCode": info.family_code, "familyCode": info.family_code,
"serialNumber": info.serial_number, "serialNumber": info.serial_number,
"sensorCount": info.sensor_count, "sensorCount": sensor_count,
"mfgDateHex": info.mfg_date_hex, "mfgDateHex": info.mfg_date_hex,
"runtime": info.runtime, "runtime": info.runtime,
"cycleCount": info.cycle_count, "cycleCount": info.cycle_count,
@@ -572,8 +733,9 @@ class QmlActivityLogHandler(logging.Handler):
super().__init__() super().__init__()
self._controller = controller self._controller = controller
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
timestamp = datetime.now().strftime("%H:%M:%S")
level = record.levelname
message = record.getMessage() message = record.getMessage()
msg = f"[{timestamp}] {level}: {message}" if "found wafer on" in message:
return
level = record.levelname
msg = f"{level}: {message}"
self._controller._append_log(msg) self._controller._append_log(msg)
@@ -11,12 +11,17 @@ from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
from pygui.backend.data.csv_recorder import CsvRecorder from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data from pygui.backend.models.frame_player import (
from pygui.backend.models.replay_stats import ReplayStatsTracker FramePlayer,
all_sensor_series,
frames_from_wafer_data,
)
from pygui.backend.models.frame_stats import compute_edge_center
from pygui.backend.models.sensor_editor import SensorEditor from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.threshold_classifier import ThresholdConfig from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader from pygui.serialcomm.stream_reader import StreamReader
@@ -35,15 +40,22 @@ class SessionController(QObject):
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# 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
# 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
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
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()
@@ -51,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)
@@ -72,28 +78,21 @@ class SessionController(QObject):
self._dirty = False self._dirty = False
self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection) self._liveFrame.connect(self._on_live_frame, 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._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)
@@ -105,6 +104,9 @@ class SessionController(QObject):
@Property(int, notify=frameUpdated) @Property(int, notify=frameUpdated)
def frameTotal(self) -> int: return self._player.total def frameTotal(self) -> int: return self._player.total
@Property(bool, notify=stateChanged)
def playing(self) -> bool: return self._play_timer.isActive()
@Property(str, notify=stateChanged) @Property(str, notify=stateChanged)
def state(self) -> str: def state(self) -> str:
if self._last: if self._last:
@@ -131,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 [
@@ -156,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:
@@ -185,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()
@@ -202,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
@@ -210,13 +240,32 @@ class SessionController(QObject):
self.clusterAveragingEnabledChanged.emit() self.clusterAveragingEnabledChanged.emit()
self._reprocess_current() self._reprocess_current()
@Property(int, notify=liveStatsChanged)
def receivedCount(self) -> int:
return self._received_count
@Property(int, notify=liveStatsChanged)
def errorCount(self) -> int:
return self._error_count
@Property(int, notify=liveStatsChanged)
def resyncCount(self) -> int:
return self._reader.resync_count if self._reader else 0
# ---- mode + thresholds ---- # ---- mode + thresholds ----
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def setMode(self, mode: str) -> None: def setMode(self, mode: str) -> None:
if mode == self._mode: if mode == self._mode:
return return
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()
@@ -243,24 +292,25 @@ class SessionController(QObject):
frames = [] frames = []
try:
if is_official_csv(file_path): if is_official_csv(file_path):
sensors, records = read_official_csv(file_path) sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records) frames = frames_from_wafer_data(None, records)
else: else:
try:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc)
return
if data is None or not data.sensors: if data is None or not data.sensors:
log.warning("Could not parse %s", file_path) self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
return return
records = read_data_records(file_path) records = read_data_records(file_path)
sensors = data.sensors sensors = data.sensors
frames = frames_from_wafer_data(data, records) frames = frames_from_wafer_data(data, records)
if not sensors or not frames: if not sensors or not frames:
log.warning("No sensors or data in %s", file_path) self.loadFileError.emit("No sensors or frames found in data file.")
return
except Exception as exc:
log.warning("Could not parse file %s: %s", file_path, exc)
self.loadFileError.emit(f"Load error: {exc}")
return return
wafer_id = "" wafer_id = ""
@@ -271,32 +321,282 @@ class SessionController(QObject):
wafer_id = stem.split("-")[0] if "-" in stem else stem wafer_id = stem.split("-")[0] if "-" in stem else stem
shape, size = resolve_shape_and_size(sensors, wafer_id) shape, size = resolve_shape_and_size(sensors, wafer_id)
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
self._sensors = WaferLayout(sensors, shape=shape, size=size) self._sensors = WaferLayout(sensors, shape=shape, size=size)
self._active_clusters = getattr(self._sensors, 'clusters', []) self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters: if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors) self._active_clusters = group_sensors_by_radius(self._sensors)
self._player.load(frames) self._player.load(frames)
self._model.reset() self._model.reset()
self._stats_tracker.reset()
self._loaded_file = file_path self._loaded_file = file_path
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
# Populate stats tracker from all loaded frames for trend chart (P3.1)
for frame in self._player._frames: @Slot()
self._stats_tracker.track(frame.seq, frame.values) @slot_error_boundary
self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) def unloadFile(self) -> None:
self.settingsChanged.emit() """Clear the loaded file, resetting the player and frame states."""
self._player.load([])
self._model.reset()
self._ec_pairs = ()
self._loaded_file = ""
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
# ---- comparison: DTW between two CSV files ----
@Slot(str, str)
@slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs
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:
if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a)
else:
recs_a = read_data_records(file_a)
if is_official_csv(file_b):
_, recs_b = read_official_csv(file_b)
else:
recs_b = read_data_records(file_b)
if not recs_a or not recs_b:
self.comparisonResult.emit({
"success": False,
"error": "No data in one or both files"
})
return
self._compare_recs_a = recs_a
self._compare_recs_b = recs_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
# reaches the same profile features later than run A.
path = result["warping_path"]
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 (bounded to
# the overlap both runs share) and take the largest per-sensor abs
# 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
max_sensor_deviation = 0.0
for i, j in zip(result["index_a"], result["index_b"]):
values_a = recs_a[i].values
values_b = recs_b[j].values
for s in range(min(num_sensors, len(values_a), len(values_b))):
diff = abs(values_a[s] - values_b[s])
if diff > max_sensor_deviation:
max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile() (file_a is assumed representative
# of both files' wafer type).
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser
sensor_layout: list[dict] = []
sensor_diff: list[float] = []
wafer_shape = "round"
wafer_size = 300.0
try:
if is_official_csv(file_a):
sensors, _ = read_official_csv(file_a)
stem = Path(file_a).stem
wafer_id = stem.split("-")[0] if "-" in stem else stem
else:
parsed, _ = ZWaferParser().parse(file_a)
sensors = parsed.sensors if parsed else []
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
if sensors:
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
last_a = recs_a[overlap_frames - 1].values
last_b = recs_b[overlap_frames - 1].values
n = min(len(sensors), len(last_a), len(last_b))
sensor_layout = [
{
"label": s.label,
"x": s.x,
"y": s.y,
"side": getattr(s, "side", "right"),
"offset_x": getattr(s, "offset_x", 0.0),
"offset_y": getattr(s, "offset_y", 0.0),
}
for s in sensors[:n]
]
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
except Exception as layout_exc:
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
self.comparisonResult.emit({
"success": True,
"distance": result["distance"],
# Lists, not tuples — tuples don't survive QVariant conversion
"warping_path": [list(p) for p in result["warping_path"][:50]],
"frame_offset": frame_offset,
"frame_offset_seconds": frame_offset_seconds,
"max_sensor_deviation": max_sensor_deviation,
"series_a": series_a,
"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_diff": sensor_diff,
"wafer_shape": wafer_shape,
"wafer_size": wafer_size,
})
except Exception as exc:
log.warning("Comparison failed: %s", exc)
self.comparisonResult.emit({
"success": False,
"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 ----
@Slot(str, float)
@slot_error_boundary
def splitData(self, file_path: str, threshold: float) -> None:
"""Segment temperature profile into Ramp/Soak/Cool phases."""
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
from pygui.backend.splitter import segment_profile
try:
if is_official_csv(file_path):
_, records = read_official_csv(file_path)
else:
records = read_data_records(file_path)
if not records:
self.splitResult.emit({
"success": False,
"error": "No data in file"
})
return
# Extract average temperatures (use first sensor as proxy)
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
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({
"success": True,
"segments": [{
"label": seg.label,
"start_frame": seg.start_frame,
"end_frame": seg.end_frame,
"avg_temp": seg.avg_temp
} for seg in segments]
})
except Exception as exc:
log.warning("Split failed: %s", exc)
self.splitResult.emit({
"success": False,
"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:
self._play_timer.start(self._next_interval_ms()) self._play_timer.start(self._next_interval_ms())
self.stateChanged.emit()
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
def pause(self) -> None: def pause(self) -> None:
self._play_timer.stop() self._play_timer.stop()
self.stateChanged.emit()
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
@@ -304,6 +604,7 @@ class SessionController(QObject):
self._play_timer.stop() self._play_timer.stop()
self._player.seek(0) self._player.seek(0)
self._emit_current() self._emit_current()
self.stateChanged.emit()
@Slot(int) @Slot(int)
@slot_error_boundary @slot_error_boundary
@@ -311,6 +612,7 @@ class SessionController(QObject):
self._play_timer.stop() self._play_timer.stop()
self._player.step(delta) self._player.step(delta)
self._emit_current() self._emit_current()
self.stateChanged.emit()
@Slot(int) @Slot(int)
@slot_error_boundary @slot_error_boundary
@@ -335,6 +637,7 @@ class SessionController(QObject):
def _advance(self) -> None: def _advance(self) -> None:
if self._player.at_end: if self._player.at_end:
self._play_timer.stop() self._play_timer.stop()
self.stateChanged.emit()
return return
self._player.step(1) self._player.step(1)
self._emit_current() self._emit_current()
@@ -373,6 +676,9 @@ class SessionController(QObject):
@Slot(str, str) @Slot(str, str)
@slot_error_boundary @slot_error_boundary
def startStream(self, port: str, family_code: str = "") -> None: def startStream(self, port: str, family_code: str = "") -> None:
if self._reader is not None:
log.warning("startStream: StreamReader is already running.")
return
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
from pygui.serialcomm.serial_port import SerialPort # transport open from pygui.serialcomm.serial_port import SerialPort # transport open
@@ -383,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:
@@ -413,24 +720,36 @@ 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._error_count = 0
self.liveStatsChanged.emit()
transport = SerialPort.open_port(port, timeout=1) transport = SerialPort.open_port(port, timeout=1)
# Send 'D2' command padded to 512 bytes to start the stream # Send 'D2' command padded to 512 bytes to start the stream
cmd = b"D2" + SerialPort.COMMAND_PAD.encode() cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
transport.write(cmd) transport.write(cmd)
import weakref
weak_self = weakref.ref(self)
def on_error(exc: Exception): def on_error(exc: Exception):
log.error("Live stream error: %s", exc) log.error("Live stream error: %s", exc)
ref = weak_self()
if ref is not None:
ref._liveError.emit()
ref.stopStream()
def on_frame(frame: Frame):
ref = weak_self()
if ref is not None:
ref._liveFrame.emit(frame)
self._reader = StreamReader( self._reader = StreamReader(
transport, parse_binary_frame, transport, parse_binary_frame,
on_frame=lambda f: self._liveFrame.emit(f), on_frame=on_frame,
on_error=on_error, on_error=on_error,
family_code=family_code or "A") family_code=family_code or "A")
self._reader.start() self._reader.start()
@@ -443,13 +762,14 @@ 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._error_count = 0
self.liveStatsChanged.emit()
if self._reader: if self._reader:
transport = self._reader._transport transport = self._reader._transport
self._reader.stop()
self._reader = None
# Send 'D2S' command padded to 512 bytes to stop the stream # Send 'D2S' command padded to 512 bytes to stop the stream
# Send BEFORE stopping the reader which will close the port on thread exit
if transport and transport.is_open: if transport and transport.is_open:
try: try:
cmd = b"D2S" + (b"F" * 509) cmd = b"D2S" + (b"F" * 509)
@@ -458,14 +778,17 @@ class SessionController(QObject):
except Exception as exc: except Exception as exc:
log.error("Error sending stop command: %s", exc) log.error("Error sending stop command: %s", exc)
self._reader.stop()
self._reader = None
self.stateChanged.emit() self.stateChanged.emit()
self.stopRecording() self.stopRecording()
@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._stats_tracker.track(frame.seq, frame.values) self.liveStatsChanged.emit()
# 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,
@@ -474,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:
@@ -498,6 +810,11 @@ class SessionController(QObject):
self.frameUpdated.emit() self.frameUpdated.emit()
self.stateChanged.emit() self.stateChanged.emit()
def _on_live_error(self) -> None:
"""Main-thread callback for worker-thread stream errors."""
self._error_count += 1
self.liveStatsChanged.emit()
# ---- recording ---- # ---- recording ----
@Slot(str, str) @Slot(str, str)
@slot_error_boundary @slot_error_boundary
@@ -540,8 +857,8 @@ class SessionController(QObject):
self._sensor_editor.clear() self._sensor_editor.clear()
self._reprocess_current() self._reprocess_current()
def _flush_trend(self) -> None: def _reset_live_trend(self) -> None:
import json """Mark a fresh stream start for elapsed-time trend deltas."""
self.trendData.emit(json.dumps(self._trend_buffer)) self._stream_start_time = time.monotonic()
self.trendReset.emit()
-2
View File
@@ -1,2 +0,0 @@
# ===== Crypto Sub-package =====
from pygui.backend.crypto.crypto_helper import * # noqa: F403
-14
View File
@@ -1,14 +0,0 @@
# ===== Data Sub-package =====
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
__all__ = [
"CsvRecorder", "CSVFileMetadata",
"read_data_records", "is_official_csv", "read_official_csv",
"FileBrowser",
"LocalSettings", "LocalSettingsModel",
]
+11
View File
@@ -1,3 +1,14 @@
# ===== Backend Data Constants ===== # ===== Backend Data Constants =====
from pathlib import Path
from PySide6.QtCore import QStandardPaths
DEFAULT_DATA_DIR_NAME = "isc_data" DEFAULT_DATA_DIR_NAME = "isc_data"
def default_data_dir() -> Path:
"""Documents-folder isc_data directory used as the default app data location."""
documents_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DocumentsLocation)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
+2 -2
View File
@@ -19,8 +19,8 @@ class CSVFileMetadata:
"""Return the first character of the wafer string, or empty if none""" """Return the first character of the wafer string, or empty if none"""
return self.wafer[0] if self.wafer else "" return self.wafer[0] if self.wafer else ""
def get_date(self) -> datetime: def get_date(self) -> datetime | None:
"""Parsifes the date string. Returns current time if parsing fails""" """Parsifes the date string. Returns None if parsing fails"""
date_format = "%Y-%m-%d %H:%M:%S" date_format = "%Y-%m-%d %H:%M:%S"
try: try:
return datetime.strptime(self.date, date_format) return datetime.strptime(self.date, date_format)
+45 -9
View File
@@ -1,4 +1,5 @@
"""Append live frames""" """Append live frames"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -12,14 +13,12 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None self._path: Optional[str] = None
@property @property
def is_recording(self) -> bool: def is_recording(self) -> bool:
return self._file_handle is not None return self._file_handle is not None
def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None: def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).parent.mkdir(parents=True, exist_ok=True)
file_handle = open(path, "w", encoding="utf-8", newline="") file_handle = open(path, "w", encoding="utf-8", newline="")
@@ -50,6 +49,12 @@ class CsvRecorder:
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool: def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
"""Copy a range of frames from source CSV into a new file. """Copy a range of frames from source CSV into a new file.
Preserves the source's header structure so the segment re-imports
through FileBrowser unchanged: official CSVs keep their sensor-name
row, Z-wafer CSVs keep every metadata line through the "data"
sentinel. Frame indices count only valid data rows, matching how
the readers in data_records.py number frames.
Args: Args:
file_path: Output CSV path. file_path: Output CSV path.
source_path: Source CSV path with full wafer data source_path: Source CSV path with full wafer data
@@ -57,11 +62,42 @@ class CsvRecorder:
end_frame: Last frame index (inclusive) end_frame: Last frame index (inclusive)
Returns: Returns:
True on success. True on success (at least one frame written).
""" """
import pandas as pd from pygui.backend.data.data_records import is_data_row, is_official_csv
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
return True
if start_frame < 0 or end_frame < start_frame:
return False
with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines()
out: list[str] = []
written = 0
frame_idx = -1
in_data = is_official_csv(source_path)
for i, line in enumerate(lines):
stripped = line.rstrip().rstrip(",").strip()
if not in_data:
out.append(line)
if stripped and stripped.split(",")[0].lower() == "data":
in_data = True
continue
if i == 0:
# official format: row 0 is the sensor-name header
out.append(line)
continue
if not stripped or not is_data_row(stripped):
continue
frame_idx += 1
if start_frame <= frame_idx <= end_frame:
out.append(line if line.endswith("\n") else line + "\n")
written += 1
if written == 0:
return False
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out)
return True
+17 -5
View File
@@ -1,4 +1,5 @@
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV.""" """Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@@ -6,6 +7,20 @@ from pathlib import Path
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
def is_data_row(line: str) -> bool:
"""True if *line* is a single CSV row of numeric values (no headers/metadata)."""
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
return bool(cols) and all(_is_float(c) for c in cols)
def _is_float(s: str) -> bool:
try:
float(s)
except ValueError:
return False
return True
def read_data_records(file_path: str) -> list[DataRecord]: def read_data_records(file_path: str) -> list[DataRecord]:
records: list[DataRecord] = [] records: list[DataRecord] = []
in_data = False in_data = False
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
if line.split(",")[0].lower() == "data": if line.split(",")[0].lower() == "data":
in_data = True in_data = True
continue continue
if not is_data_row(line):
continue
cols = [c.strip() for c in line.split(",") if c.strip() != ""] cols = [c.strip() for c in line.split(",") if c.strip() != ""]
try:
nums = [float(c) for c in cols] nums = [float(c) for c in cols]
except ValueError:
continue
if not nums:
continue
records.append(DataRecord(time=nums[0], values=nums[1:])) records.append(DataRecord(time=nums[0], values=nums[1:]))
return records return records
+25 -13
View File
@@ -5,10 +5,10 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox from PySide6.QtWidgets import QFileDialog, QMessageBox
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME from pygui.backend.data.constants import default_data_dir
from pygui.backend.data.csv_file_metadata import CSVFileMetadata from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
self._refresh_files(show_empty_message=False) self._refresh_files(show_empty_message=False)
# ===== Exposed Properties ===== # ===== Exposed Properties =====
@Property("QVariantList", notify=filesChanged) @Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
def files(self) -> list[dict[str, object]]: def files(self) -> list[dict[str, object]]:
return [dict(row) for row in self._files] return [dict(row) for row in self._files]
@@ -37,12 +37,22 @@ class FileBrowser(QObject):
return str(self._current_directory) return str(self._current_directory)
# ===== Directory Selection ===== # ===== Directory Selection =====
@Slot(str)
def setCurrentDirectory(self, path: str) -> None:
"""Point the browser at `path` without opening a native dialog.
Used to keep this directory in sync with DeviceController.saveDataDir
when the user picks a save folder elsewhere in the app.
"""
self._set_current_directory(Path(path))
self._refresh_files(show_empty_message=False)
@Slot() @Slot()
def chooseDirectory(self) -> None: def chooseDirectory(self) -> None:
selected_dir = QFileDialog.getExistingDirectory( selected_dir = QFileDialog.getExistingDirectory(
None, None,
"Select CSV Folder", "Select CSV Folder",
self.currentDirectory, str(self._current_directory),
) )
if not selected_dir: if not selected_dir:
return return
@@ -71,11 +81,10 @@ class FileBrowser(QObject):
self._show_error(f"CSV file was not found:\n{file_path}") self._show_error(f"CSV file was not found:\n{file_path}")
return return
# ponytail: row is absent when saving metadata for a CSV just read from
# the wafer (not in the browser listing) — still write the sidecar.
row = self._find_row(file_path) row = self._find_row(file_path)
if row is None: if row is not None:
self._show_error("The selected CSV row is no longer available.")
return
row["wafer"] = wafer row["wafer"] = wafer
row["date"] = date row["date"] = date
row["chamber"] = chamber row["chamber"] = chamber
@@ -119,6 +128,11 @@ class FileBrowser(QObject):
return return
for csv_path in sorted(self._current_directory.glob("*.csv")): for csv_path in sorted(self._current_directory.glob("*.csv")):
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# SessionController.startRecording (see WaferMapTab's Record button),
# distinct from the "<serial>-<timestamp>.csv" convention used by
# DeviceController.parseAndSaveData for read-memory dumps.
is_recording = csv_path.stem.lower().startswith("live_")
try: try:
metadata = self._load_metadata(csv_path) metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "") wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
@@ -146,6 +160,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), "") or metadata.master_type, "masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": wafer_type in {"A", "B", "C"}, "highlight": wafer_type in {"A", "B", "C"},
"isRecording": is_recording,
} }
) )
except Exception: except Exception:
@@ -169,6 +184,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), ""), "masterType": master_state.get(str(csv_path), ""),
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": False, "highlight": False,
"isRecording": is_recording,
} }
) )
@@ -229,11 +245,7 @@ class FileBrowser(QObject):
# ===== Internal Helpers ===== # ===== Internal Helpers =====
def _resolve_default_directory(self) -> Path: def _resolve_default_directory(self) -> Path:
documents_dir = QStandardPaths.writableLocation( return default_data_dir()
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
def _set_current_directory(self, directory: Path) -> None: def _set_current_directory(self, directory: Path) -> None:
normalized = Path(directory) normalized = Path(directory)
+2 -5
View File
@@ -13,8 +13,8 @@ class LocalSettings:
# Configuration settings # Configuration settings
self.chamber_id = "" self.chamber_id = ""
self.reverse_z_wafer = False self.reverse_z_wafer = False
self.master = {} # Dict[str, str] self.master: dict[str, str] = {}
self.wafer_data_size = {} # Dict[str, int] self.wafer_data_size: dict[str, int] = {}
self.debug = False self.debug = False
self.wafer_read_retries = 8 self.wafer_read_retries = 8
# Timeout in msec for reading from the wafer (default 2 min) # Timeout in msec for reading from the wafer (default 2 min)
@@ -32,9 +32,6 @@ class LocalSettings:
self.data_col_count = 0 self.data_col_count = 0
self.last_csv_path = "" self.last_csv_path = ""
# Session persistence
self.session_last_file = ""
# ===== File Path Helpers ===== # ===== File Path Helpers =====
@classmethod @classmethod
def _settings_path(cls, directory: str) -> Path: def _settings_path(cls, directory: str) -> Path:
+78 -82
View File
@@ -2,17 +2,26 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z") MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
class LocalSettingsModel(QObject): class LocalSettingsModel(QObject):
# Draft attrs are seeded via setattr() in __init__ from _new_defaults();
# declared here so mypy knows their types.
_chamber_id: str
_reverse_z_wafer: bool
_debug_mode: bool
_wafer_read_timeout: int
_wafer_detect_timeout: int
_wafer_retries: int
_masters: dict[str, str]
chamberIdChanged = Signal() chamberIdChanged = Signal()
reverseZWaferChanged = Signal() reverseZWaferChanged = Signal()
debugModeChanged = Signal() debugModeChanged = Signal()
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
saveStatusChanged = Signal() saveStatusChanged = Signal()
lastSavedAtChanged = Signal() lastSavedAtChanged = Signal()
def __init__(self, parent: QObject | None = None) -> None: # Draft attr (this class) <-> LocalSettings attr (the shared, persisted
# instance also held by DeviceController) <-> coercer applied on both
# load and revert/reset. One table drives load/save/revert/reset instead
# of four hand-written field-by-field copies.
_FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
("_chamber_id", "chamber_id", str),
("_reverse_z_wafer", "reverse_z_wafer", bool),
("_debug_mode", "debug", bool),
("_wafer_read_timeout", "wafer_read_timeout", int),
("_wafer_detect_timeout", "wafer_detect_timeout", int),
("_wafer_retries", "wafer_read_retries", int),
)
def __init__(
self,
settings: LocalSettings,
data_dir: Path | str,
parent: QObject | None = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self._data_dir = self._resolve_data_dir() # Shared with DeviceController — saving here must mutate this same
# instance, not a throwaway copy, or the device layer keeps using
# stale config until restart.
self._settings = settings
self._data_dir = Path(data_dir)
self._defaults = self._new_defaults() self._defaults = self._new_defaults()
self._chamber_id = self._defaults["chamberId"] for draft_attr, default_value in self._defaults.items():
self._reverse_z_wafer = self._defaults["reverseZWafer"] setattr(self, draft_attr, deepcopy(default_value))
self._debug_mode = self._defaults["debugMode"]
self._wafer_read_timeout = self._defaults["waferReadTimeout"]
self._wafer_detect_timeout = self._defaults["waferDetectTimeout"]
self._wafer_retries = self._defaults["waferRetries"]
self._masters = deepcopy(self._defaults["masters"])
self._is_dirty = False self._is_dirty = False
self._is_valid = True self._is_valid = True
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
def _resolve_data_dir(self) -> Path:
documents_dir = QStandardPaths.writableLocation(
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
def _new_defaults(self) -> dict[str, Any]: def _new_defaults(self) -> dict[str, Any]:
return { return {
"chamberId": "2", "_chamber_id": "2",
"reverseZWafer": False, "_reverse_z_wafer": False,
"debugMode": False, "_debug_mode": False,
"waferReadTimeout": 120000, "_wafer_read_timeout": 120000,
"waferDetectTimeout": 5000, "_wafer_detect_timeout": 5000,
"waferRetries": 8, "_wafer_retries": 8,
"masters": {family: "" for family in MASTER_FAMILIES}, "_masters": {family: "" for family in MASTER_FAMILIES},
} }
def _snapshot(self) -> dict[str, Any]: def _snapshot(self) -> dict[str, Any]:
return { snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
"chamberId": self._chamber_id, snap["_masters"] = deepcopy(self._masters)
"reverseZWafer": self._reverse_z_wafer, return snap
"debugMode": self._debug_mode,
"waferReadTimeout": self._wafer_read_timeout,
"waferDetectTimeout": self._wafer_detect_timeout,
"waferRetries": self._wafer_retries,
"masters": deepcopy(self._masters),
}
def _set_is_dirty(self, value: bool) -> None: def _set_is_dirty(self, value: bool) -> None:
if self._is_dirty != value: if self._is_dirty != value:
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
self.waferRetriesChanged.emit() self.waferRetriesChanged.emit()
self.mastersChanged.emit() self.mastersChanged.emit()
def _to_local_settings(self) -> LocalSettings: def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
settings = LocalSettings() masters = {family: "" for family in MASTER_FAMILIES}
settings.chamber_id = self._chamber_id for family, value in source.items():
settings.reverse_z_wafer = self._reverse_z_wafer normalized = str(family).strip().upper()
settings.debug = self._debug_mode if normalized in masters:
settings.wafer_read_timeout = self._wafer_read_timeout masters[normalized] = str(value).strip()
settings.wafer_detect_timeout = self._wafer_detect_timeout return masters
settings.wafer_read_retries = self._wafer_retries
settings.master = deepcopy(self._masters)
return settings
@Property(str, notify=chamberIdChanged) @Property(str, notify=chamberIdChanged)
def chamberId(self) -> str: def chamberId(self) -> str:
return self._chamber_id return self._chamber_id
@chamberId.setter @chamberId.setter # type: ignore[no-redef]
def chamberId(self, value: str) -> None: def chamberId(self, value: str) -> None:
next_value = str(value).strip() next_value = str(value).strip()
if self._chamber_id == next_value: if self._chamber_id == next_value:
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
def reverseZWafer(self) -> bool: def reverseZWafer(self) -> bool:
return self._reverse_z_wafer return self._reverse_z_wafer
@reverseZWafer.setter @reverseZWafer.setter # type: ignore[no-redef]
def reverseZWafer(self, value: bool) -> None: def reverseZWafer(self, value: bool) -> None:
if self._reverse_z_wafer == value: if self._reverse_z_wafer == value:
return return
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
def debugMode(self) -> bool: def debugMode(self) -> bool:
return self._debug_mode return self._debug_mode
@debugMode.setter @debugMode.setter # type: ignore[no-redef]
def debugMode(self, value: bool) -> None: def debugMode(self, value: bool) -> None:
if self._debug_mode == value: if self._debug_mode == value:
return return
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
def waferReadTimeout(self) -> int: def waferReadTimeout(self) -> int:
return self._wafer_read_timeout return self._wafer_read_timeout
@waferReadTimeout.setter @waferReadTimeout.setter # type: ignore[no-redef]
def waferReadTimeout(self, value: int) -> None: def waferReadTimeout(self, value: int) -> None:
if self._wafer_read_timeout == value: if self._wafer_read_timeout == value:
return return
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
def waferDetectTimeout(self) -> int: def waferDetectTimeout(self) -> int:
return self._wafer_detect_timeout return self._wafer_detect_timeout
@waferDetectTimeout.setter @waferDetectTimeout.setter # type: ignore[no-redef]
def waferDetectTimeout(self, value: int) -> None: def waferDetectTimeout(self, value: int) -> None:
if self._wafer_detect_timeout == value: if self._wafer_detect_timeout == value:
return return
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
def waferRetries(self) -> int: def waferRetries(self) -> int:
return self._wafer_retries return self._wafer_retries
@waferRetries.setter @waferRetries.setter # type: ignore[no-redef]
def waferRetries(self, value: int) -> None: def waferRetries(self, value: int) -> None:
if self._wafer_retries == value: if self._wafer_retries == value:
return return
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def loadSettings(self) -> None: def loadSettings(self) -> None:
loaded = LocalSettings.read_settings(str(self._data_dir)) """Seed draft fields from the shared LocalSettings instance."""
self._chamber_id = str(loaded.chamber_id).strip() or str( for draft_attr, settings_attr, coerce in self._FIELD_MAP:
self._defaults["chamberId"] setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
) self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
self._reverse_z_wafer = bool(loaded.reverse_z_wafer) self._masters = self._normalized_masters(self._settings.master)
self._debug_mode = bool(loaded.debug)
self._wafer_read_timeout = int(loaded.wafer_read_timeout)
self._wafer_detect_timeout = int(loaded.wafer_detect_timeout)
self._wafer_retries = int(loaded.wafer_read_retries)
masters = {family: "" for family in MASTER_FAMILIES}
for family, value in loaded.master.items():
normalized = str(family).strip().upper()
if normalized in masters:
masters[normalized] = str(value).strip()
self._masters = masters
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._emit_all_changed() self._emit_all_changed()
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def saveSettings(self) -> None: def saveSettings(self) -> None:
"""Write draft fields onto the shared LocalSettings instance and persist it."""
self._recompute_derived() self._recompute_derived()
if not self._is_valid: if not self._is_valid:
self._set_save_status("error: invalid settings") self._set_save_status("error: invalid settings")
return return
try: try:
LocalSettings.save_settings(str(self._data_dir), self._to_local_settings()) for draft_attr, settings_attr, _coerce in self._FIELD_MAP:
setattr(self._settings, settings_attr, getattr(self, draft_attr))
self._settings.master = deepcopy(self._masters)
LocalSettings.save_settings(str(self._data_dir), self._settings)
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
self._set_save_status("Saved") self._set_save_status("Saved")
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def revertChanges(self) -> None: def revertChanges(self) -> None:
snap = self._saved_snapshot snap = self._saved_snapshot
self._chamber_id = str(snap["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(snap["reverseZWafer"]) setattr(self, draft_attr, coerce(snap[draft_attr]))
self._debug_mode = bool(snap["debugMode"]) self._masters = deepcopy(snap["_masters"])
self._wafer_read_timeout = int(snap["waferReadTimeout"])
self._wafer_detect_timeout = int(snap["waferDetectTimeout"])
self._wafer_retries = int(snap["waferRetries"])
self._masters = deepcopy(snap["masters"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def resetDefaults(self) -> None: def resetDefaults(self) -> None:
self._chamber_id = str(self._defaults["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"]) setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
self._debug_mode = bool(self._defaults["debugMode"]) self._masters = deepcopy(self._defaults["_masters"])
self._wafer_read_timeout = int(self._defaults["waferReadTimeout"])
self._wafer_detect_timeout = int(self._defaults["waferDetectTimeout"])
self._wafer_retries = int(self._defaults["waferRetries"])
self._masters = deepcopy(self._defaults["masters"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -0,0 +1,126 @@
"""C#-compatible wafer license file parsing.
License files are AES-128-CBC encrypted `.bin` blobs named
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
5-digit serial number, and ``00`` the 2-digit license level. The blob is
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
a fixed-offset record::
[0:6] serial e.g. "A00001"
[7:15] mfg date 8 hex chars
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
[19:27] license date 8 hex chars
The key is hardcoded to match the C# application exactly
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from pygui.backend.crypto.crypto_helper import CryptoHelper
log = logging.getLogger(__name__)
LICENSE_KEY = b"ThisIsA16ByteKey"
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
_MIN_TEXT_LEN = 27
# ===== License Record =====
@dataclass(frozen=True)
class License:
"""One parsed license file (all fields as stored in the file)."""
serial: str # "A00001"
level: str # "00".."03"
mfg_date_hex: str # 8 hex chars
lic_date_hex: str # 8 hex chars
@property
def level_int(self) -> int:
"""Level as int; -1 if unparseable."""
try:
return int(self.level, 10)
except ValueError:
return -1
@property
def mfg_date_decimal(self) -> str:
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
return _hex_to_decimal(self.mfg_date_hex)
@property
def lic_date_decimal(self) -> str:
"""License date shown as decimal, same conversion as the C# grid."""
return _hex_to_decimal(self.lic_date_hex)
def _hex_to_decimal(hex_str: str) -> str:
try:
return str(int(hex_str, 16))
except ValueError:
return ""
# ===== Blob Parsing =====
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
if len(blob) < 32:
raise ValueError("License blob too short")
return blob[:-16], blob[-16:]
def parse_license_blob(blob: bytes) -> License | None:
"""Decrypt and parse one license blob. None if malformed."""
try:
data, iv = split_data_iv(blob)
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
text = message.decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
log.warning("Undecryptable license blob: %s", exc)
return None
if len(text) < _MIN_TEXT_LEN:
log.warning("License text too short (%d chars)", len(text))
return None
return License(
serial=text[0:6],
mfg_date_hex=text[7:15],
level=text[16:18],
lic_date_hex=text[19:27],
)
# ===== Directory Scan =====
def read_license_files(licenses_dir: str | Path) -> list[License]:
"""Parse every valid license .bin in the directory.
A file counts only if its name matches ``X00000-00.bin`` AND equals
``{serial}-{level}.bin`` from its own decrypted payload — same
tamper check as the C# app.
"""
directory = Path(licenses_dir)
if not directory.is_dir():
return []
licenses: list[License] = []
for path in sorted(directory.glob("*.bin")):
if not LICENSE_FILENAME_RE.match(path.name):
continue
try:
blob = path.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", path, exc)
continue
lic = parse_license_blob(blob)
if lic is None:
continue
if path.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License filename mismatch: %s", path.name)
continue
licenses.append(lic)
return licenses
+146
View File
@@ -0,0 +1,146 @@
"""QML-facing license model: grid rows, Load License, replay trial marker."""
import logging
import shutil
import time
from pathlib import Path
from urllib.parse import unquote, urlparse
from PySide6.QtCore import Property, QObject, Signal, Slot
from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE,
License,
parse_license_blob,
read_license_files,
)
log = logging.getLogger(__name__)
TRIAL_DAYS = 30
def _to_local_path(path_or_url: str) -> Path:
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
if path_or_url.startswith("file:"):
return Path(unquote(urlparse(path_or_url).path))
return Path(path_or_url)
# ===== License Model =====
class LicenseModel(QObject):
"""Context property backing the About dialog license grid.
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
``DataDir\\licenses``). The 30-day replay trial marker is
``<data_dir>/replay.lic``.
"""
licensesChanged = Signal()
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._data_dir = Path(data_dir)
self._licenses_dir = self._data_dir / "licenses"
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
self._licenses: list[License] = []
self.refresh()
# ── Grid rows ─────────────────────────────────────────────────────
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
def licenses(self) -> list:
"""Rows for the About grid: serial, mfg date, level, license date."""
return [
{
"serial": lic.serial,
"mfgDate": lic.mfg_date_decimal,
"level": lic.level,
"licDate": lic.lic_date_decimal,
}
for lic in self._licenses
]
@Slot()
def refresh(self) -> None:
"""Re-scan the licenses directory."""
self._licenses = read_license_files(self._licenses_dir)
self.licensesChanged.emit()
# ── Load License ──────────────────────────────────────────────────
@Slot(str, result=bool)
def loadLicenseFile(self, path_or_url: str) -> bool:
"""Validate a picked .bin and copy it into the licenses directory."""
src = _to_local_path(path_or_url)
if not src.is_file():
log.warning("License file does not exist: %s", src)
return False
if not LICENSE_FILENAME_RE.match(src.name):
log.warning("License filename invalid: %s", src.name)
return False
try:
blob = src.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", src, exc)
return False
lic = parse_license_blob(blob)
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License file failed validation: %s", src.name)
return False
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, self._licenses_dir / src.name)
except OSError as exc:
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
return False
self.refresh()
return True
# ── Wafer detect (informational) ──────────────────────────────────
@Slot(str, result=str)
def levelForWafer(self, serial: str) -> str:
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
for lic in self._licenses:
if lic.serial == serial:
return lic.level
return ""
# ── Replay trial (30-day marker, C# parity) ───────────────────────
@property
def _trial_marker(self) -> Path:
return self._data_dir / "replay.lic"
@Slot(result=str)
def replayLicenseState(self) -> str:
""""permanent" if any license level >= 1, "temporary" if trial
marker exists, else "none" (trial not started)."""
if any(lic.level_int >= 1 for lic in self._licenses):
return "permanent"
if self._trial_marker.exists():
return "temporary"
return "none"
@Slot(result=bool)
def startReplayTrial(self) -> bool:
"""Create the trial marker (idempotent). False on I/O failure."""
try:
self._trial_marker.touch(exist_ok=True)
return True
except OSError as exc:
log.warning("Cannot create trial marker: %s", exc)
return False
@Slot(result=bool)
def replayTrialExpired(self) -> bool:
"""True once the marker is older than 30 days (or missing)."""
marker = self._trial_marker
if not marker.exists():
return True
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
return age_days > TRIAL_DAYS
-19
View File
@@ -1,19 +0,0 @@
# ===== Models Sub-package =====
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.stability_detector import StabilityDetector
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
__all__ = [
"Frame", "FramePlayer", "frames_from_wafer_data",
"Stats", "compute_stats",
"SessionModel", "SessionUpdate",
"TemperatureTableModel",
"SensorEditor",
"ThresholdConfig", "classify", "resolve_bounds",
"StabilityDetector",
]
+2 -2
View File
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
# Column 0 = row index, columns 1..N = sensor values # Column 0 = row index, columns 1..N = sensor values
return self._col_count + 1 return self._col_count + 1
def data(self, index: Any, role: int = ...) -> Any: def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
if not index.isValid(): if not index.isValid():
return None return None
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
return None return None
def headerData( def headerData(
self, section: int, orientation: int, role: int = ... self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
) -> Any: ) -> Any:
if role != Qt.ItemDataRole.DisplayRole: if role != Qt.ItemDataRole.DisplayRole:
return None return None
+18 -1
View File
@@ -4,10 +4,23 @@ from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import ZWaferData from pygui.backend.wafer.zwafer_models import ZWaferData
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]: def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
"""Build Frames from parsed DataRecords (records = list[DataRecord].)""" """Build Frames from parsed DataRecords (records = list[DataRecord].)"""
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)] return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
"""Per-sensor value series across every frame, for the whole-run Graph tab.
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
"""
if not frames:
return [], []
n_sensors = len(frames[0].values)
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
return names, series
class FramePlayer: class FramePlayer:
def __init__(self) -> None: def __init__(self) -> None:
self._frames: list[Frame] =[] self._frames: list[Frame] =[]
@@ -22,6 +35,10 @@ class FramePlayer:
def total(self) -> int: def total(self) -> int:
return len(self._frames) return len(self._frames)
@property
def frames(self) -> tuple[Frame, ...]:
return tuple(self._frames)
@property @property
def index(self) -> int: def index(self) -> int:
return self._i return self._i
+38
View File
@@ -14,6 +14,44 @@ class Stats:
sigma: float; three_sigma: float sigma: float; three_sigma: float
@dataclass(frozen=True)
class EdgeCenterStats:
min_edge_index: int; min_center_index: int
min_edge: float; min_center: float; min_delta: float
max_edge_index: int; max_center_index: int
max_edge: float; max_center: float; max_delta: float
def compute_edge_center(
values: list[float],
pairs: tuple[tuple[int, int], ...],
excluded: set[int],
) -> EdgeCenterStats | None:
"""Min/max |edge - center| over the family's pair table.
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
NaN value are skipped. Returns None when no pair is computable.
"""
n = len(values)
best: list[tuple[float, int, int]] = []
for e, c in pairs:
if e in excluded or c in excluded or e >= n or c >= n:
continue
if math.isnan(values[e]) or math.isnan(values[c]):
continue
best.append((abs(values[e] - values[c]), e, c))
if not best:
return None
min_d, min_e, min_c = min(best)
max_d, max_e, max_c = max(best)
return EdgeCenterStats(
min_edge_index=min_e, min_center_index=min_c,
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
max_edge_index=max_e, max_center_index=max_c,
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
)
def compute_stats(values: list[float]) -> Stats: def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)] clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean: if not clean:
@@ -28,6 +28,11 @@ class SensorEditor:
def has_overrides(self) -> bool: def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets) return bool(self._replacements or self._offsets)
def replaced_indices(self) -> set[int]:
"""Sensors whose value is a replacement (offsets don't count — an
offset sensor still reads from its physical location)."""
return set(self._replacements)
def active_indices(self) -> list[int]: def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override.""" """Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets)) return sorted(set(self._replacements) | set(self._offsets))
@@ -1,11 +0,0 @@
# ===== Visualization Sub-package =====
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
from pygui.backend.visualization.graph_view import GraphView
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.visualization.trend_chart_item import TrendChartItem
from pygui.backend.visualization.wafer_map_item import WaferMapItem
__all__ = [
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
"interpolate_field",
]
@@ -0,0 +1,52 @@
"""Pure coordinate-mapping math shared by the QQuickPaintedItem chart items
(GraphQuickItem, TrendChartItem). No QPainter or Qt-widget dependency, so
it's testable without a GUI — unlike the paint() methods that call it.
"""
from __future__ import annotations
def value_to_pixel(
value: float,
value_min: float,
value_max: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map a value onto a pixel range, inverted so larger values sit at
smaller pixel coordinates (screen y grows downward).
Also used for evenly-spaced tick indices: pass the tick index as
`value` and `(tick_count - 1)` as `value_max` with `value_min=0`.
"""
value_range = value_max - value_min
if value_range < 1e-9:
return pixel_start + pixel_span / 2
fraction = 1.0 - (value - value_min) / value_range
return pixel_start + fraction * pixel_span
def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: float) -> float:
"""Map a 0-based sample index onto a pixel range, left to right."""
if count <= 1:
return pixel_start + pixel_span / 2
fraction = index / (count - 1)
return pixel_start + fraction * pixel_span
def elapsed_to_pixel(
elapsed: float,
window_start: float,
window_end: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map an elapsed-seconds value onto a pixel range, left to right.
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
at larger pixel x-coordinates, matching how time flows left-to-right.
"""
window_span = window_end - window_start
if window_span < 1e-9:
return pixel_start + pixel_span
fraction = (elapsed - window_start) / window_span
return pixel_start + fraction * pixel_span
@@ -21,15 +21,60 @@ Usage from QML::
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal import math
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
def viewport_stats(
series: list[list[float]],
names: list[str],
start: int,
end: int,
) -> tuple[float, float, float, str, str]:
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
every visible point from every series, plus which sensor achieved each
extreme. Non-numeric values are skipped. No points in range -> zeros and
empty sensor names.
"""
vmin = math.inf
vmax = -math.inf
total = 0.0
count = 0
min_sensor = ""
max_sensor = ""
for i, s in enumerate(series):
name = names[i] if i < len(names) else ""
lo = max(0, start)
hi = min(len(s) - 1, end)
for j in range(lo, hi + 1):
try:
v = float(s[j])
except (TypeError, ValueError):
continue
if v < vmin:
vmin = v
min_sensor = name
if v > vmax:
vmax = v
max_sensor = name
total += v
count += 1
if count == 0:
return (0.0, 0.0, 0.0, "", "")
return (vmin, vmax, total / count, min_sensor, max_sensor)
@QmlElement @QmlElement
class GraphQuickItem(QQuickPaintedItem): class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings.""" """Painted line chart; driven by series data passed via QML property bindings."""
@@ -40,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
xLabelChanged = Signal() xLabelChanged = Signal()
yLabelChanged = Signal() yLabelChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
viewportChanged = Signal()
viewStatsChanged = Signal()
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -72,16 +119,37 @@ class GraphQuickItem(QQuickPaintedItem):
self._max_y: float = 150.0 self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50} self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
# so the Graph tab (which never sets these) is unaffected. See
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
self._view_start: int = 0
self._view_end: int = -1
self._show_min_max_markers: bool = False
self._view_min: float = 0.0
self._view_max: float = 0.0
self._view_avg: float = 0.0
self._view_min_sensor: str = ""
self._view_max_sensor: str = ""
# ── Qt properties (QML-bindable) ────────────────────────────────────────── # ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged) @Property("QVariantList", notify=seriesDataChanged)
def seriesData(self) -> list: def seriesData(self) -> list:
return self._series_data return self._series_data
@seriesData.setter @seriesData.setter # type: ignore[no-redef]
def seriesData(self, val: list) -> None: def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])] self._series_data = [list(s) for s in (val or [])]
# New data resets the viewport to the full range -- matches
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
# before RecalculateAxesScale(). Without this, a stale viewport left
# over from a previous (longer) series can point entirely past the
# new data -- every per-series slice becomes empty and the chart
# silently renders nothing instead of the new data.
self._view_start = 0
self._view_end = -1
self._auto_range() self._auto_range()
self._recompute_view_stats()
self.seriesDataChanged.emit() self.seriesDataChanged.emit()
self.update() self.update()
@@ -89,17 +157,138 @@ class GraphQuickItem(QQuickPaintedItem):
def sensorNames(self) -> list: def sensorNames(self) -> list:
return self._sensor_names return self._sensor_names
@sensorNames.setter @sensorNames.setter # type: ignore[no-redef]
def sensorNames(self, val: list) -> None: def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or []) self._sensor_names = list(val or [])
self._recompute_view_stats()
self.sensorNamesChanged.emit() self.sensorNamesChanged.emit()
self.update() self.update()
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
@Property(int, notify=viewportChanged)
def viewStartIndex(self) -> int:
return self._view_start
@viewStartIndex.setter # type: ignore[no-redef]
def viewStartIndex(self, val: int) -> None:
v = max(0, int(val))
if v == self._view_start:
return
self._view_start = v
self._on_viewport_changed()
@Property(int, notify=viewportChanged)
def viewEndIndex(self) -> int:
return self._view_end
@viewEndIndex.setter # type: ignore[no-redef]
def viewEndIndex(self, val: int) -> None:
v = int(val)
if v == self._view_end:
return
self._view_end = v
self._on_viewport_changed()
@Property(bool, notify=viewportChanged)
def showMinMaxMarkers(self) -> bool:
return self._show_min_max_markers
@showMinMaxMarkers.setter # type: ignore[no-redef]
def showMinMaxMarkers(self, val: bool) -> None:
v = bool(val)
if v == self._show_min_max_markers:
return
self._show_min_max_markers = v
self.viewportChanged.emit()
self.update()
@Property(float, notify=viewStatsChanged)
def viewMin(self) -> float:
return self._view_min
@Property(float, notify=viewStatsChanged)
def viewMax(self) -> float:
return self._view_max
@Property(float, notify=viewStatsChanged)
def viewAvg(self) -> float:
return self._view_avg
@Property(str, notify=viewStatsChanged)
def viewMinSensor(self) -> str:
return self._view_min_sensor
@Property(str, notify=viewStatsChanged)
def viewMaxSensor(self) -> str:
return self._view_max_sensor
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
# with fractions derived from wheel/drag pixel deltas) ───────────────
@Slot(float, float)
def zoomAtFraction(self, frac: float, factor: float) -> None:
"""Zoom the viewport around a fractional X position within it.
`frac` in [0, 1] is where within the current viewport to zoom around;
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
(zoom out). Clamped to data bounds and a minimum 1-index window.
"""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
f = max(0.0, min(1.0, frac))
center = start + f * width
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
new_start = center - f * new_width
new_end = new_start + new_width
self._set_viewport_clamped(new_start, new_end, max_len)
@Slot(float)
def panByFraction(self, delta_frac: float) -> None:
"""Shift the viewport by `delta_frac * window_width` indices."""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
shift = delta_frac * width
self._set_viewport_clamped(start + shift, end + shift, max_len)
@Slot()
def resetZoom(self) -> None:
"""Restore the full-range viewport sentinel (0, -1)."""
self._set_viewport(0, -1)
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
if new_start < 0.0:
new_end -= new_start
new_start = 0.0
if new_end > max_len - 1:
new_start -= new_end - (max_len - 1)
new_end = float(max_len - 1)
new_start = max(0.0, new_start)
self._set_viewport(int(round(new_start)), int(round(new_end)))
def _set_viewport(self, start: int, end: int) -> None:
"""Set the viewport bounds directly (not through the QML-facing
property setters, so this stays a plain attribute write for mypy)."""
new_start = max(0, start)
if new_start == self._view_start and end == self._view_end:
return
self._view_start = new_start
self._view_end = end
self._on_viewport_changed()
@Property(str, notify=titleChanged) @Property(str, notify=titleChanged)
def title(self) -> str: def title(self) -> str:
return self._title return self._title
@title.setter @title.setter # type: ignore[no-redef]
def title(self, val: str) -> None: def title(self, val: str) -> None:
self._title = str(val or "") self._title = str(val or "")
self.titleChanged.emit() self.titleChanged.emit()
@@ -109,7 +298,7 @@ class GraphQuickItem(QQuickPaintedItem):
def xLabel(self) -> str: def xLabel(self) -> str:
return self._x_label return self._x_label
@xLabel.setter @xLabel.setter # type: ignore[no-redef]
def xLabel(self, val: str) -> None: def xLabel(self, val: str) -> None:
self._x_label = str(val or "") self._x_label = str(val or "")
self.xLabelChanged.emit() self.xLabelChanged.emit()
@@ -119,7 +308,7 @@ class GraphQuickItem(QQuickPaintedItem):
def yLabel(self) -> str: def yLabel(self) -> str:
return self._y_label return self._y_label
@yLabel.setter @yLabel.setter # type: ignore[no-redef]
def yLabel(self, val: str) -> None: def yLabel(self, val: str) -> None:
self._y_label = str(val or "") self._y_label = str(val or "")
self.yLabelChanged.emit() self.yLabelChanged.emit()
@@ -129,7 +318,7 @@ class GraphQuickItem(QQuickPaintedItem):
def showLegend(self) -> bool: def showLegend(self) -> bool:
return self._show_legend return self._show_legend
@showLegend.setter @showLegend.setter # type: ignore[no-redef]
def showLegend(self, val: bool) -> None: def showLegend(self, val: bool) -> None:
self._show_legend = bool(val) self._show_legend = bool(val)
self.colorsChanged.emit() self.colorsChanged.emit()
@@ -141,7 +330,7 @@ class GraphQuickItem(QQuickPaintedItem):
def backgroundColor(self) -> QColor: def backgroundColor(self) -> QColor:
return self._bg_color return self._bg_color
@backgroundColor.setter @backgroundColor.setter # type: ignore[no-redef]
def backgroundColor(self, c: QColor) -> None: def backgroundColor(self, c: QColor) -> None:
self._bg_color = c self._bg_color = c
self.update() self.update()
@@ -150,7 +339,7 @@ class GraphQuickItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
self._grid_color = c self._grid_color = c
self.update() self.update()
@@ -159,7 +348,7 @@ class GraphQuickItem(QQuickPaintedItem):
def axisColor(self) -> QColor: def axisColor(self) -> QColor:
return self._axis_color return self._axis_color
@axisColor.setter @axisColor.setter # type: ignore[no-redef]
def axisColor(self, c: QColor) -> None: def axisColor(self, c: QColor) -> None:
self._axis_color = c self._axis_color = c
self.update() self.update()
@@ -168,7 +357,7 @@ class GraphQuickItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
self._text_color = c self._text_color = c
self.update() self.update()
@@ -178,7 +367,7 @@ class GraphQuickItem(QQuickPaintedItem):
"""Return hex strings of the current series color palette.""" """Return hex strings of the current series color palette."""
return [c.name() for c in self._series_colors] return [c.name() for c in self._series_colors]
@seriesColors.setter @seriesColors.setter # type: ignore[no-redef]
def seriesColors(self, hex_list: list) -> None: def seriesColors(self, hex_list: list) -> None:
colors = [] colors = []
for h in (hex_list or []): for h in (hex_list or []):
@@ -192,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
# ── internal ────────────────────────────────────────────────────────────── # ── internal ──────────────────────────────────────────────────────────────
def _resolved_view_end(self) -> int:
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
max_len = max((len(s) for s in self._series_data), default=0)
if self._view_end < 0 or self._view_end > max_len - 1:
return max_len - 1
return self._view_end
def _on_viewport_changed(self) -> None:
self._auto_range()
self._recompute_view_stats()
self.viewportChanged.emit()
self.update()
def _recompute_view_stats(self) -> None:
end = self._resolved_view_end()
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
self._series_data, self._sensor_names, self._view_start, end
)
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
self.viewStatsChanged.emit()
def _auto_range(self) -> None: def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding.""" """Set Y range to cover the current viewport's data with 10% padding."""
start = max(0, self._view_start)
end = self._resolved_view_end()
all_vals: list[float] = [] all_vals: list[float] = []
for series in self._series_data: for series in self._series_data:
for v in series: hi = min(len(series) - 1, end)
for i in range(start, hi + 1):
try: try:
all_vals.append(float(v)) all_vals.append(float(series[i]))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not all_vals: if not all_vals:
@@ -275,8 +489,7 @@ class GraphQuickItem(QQuickPaintedItem):
for i in range(n_y_ticks): for i in range(n_y_ticks):
y_val = self._min_y + i * y_step y_val = self._min_y + i * y_step
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5 y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
y_px = plot_top + y_frac * plot_h
# Grid line # Grid line
painter.setPen(grid_pen) painter.setPen(grid_pen)
@@ -318,19 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
self._x_label, self._x_label,
) )
# --- X-axis ticks --- # --- Viewport slice (replay chart zoom/pan; full range by default,
num_points = max(len(s) for s in self._series_data) if self._series_data else 0 # so unset viewport reproduces the pre-viewport rendering) ---
if num_points > 1: view_start = max(0, self._view_start)
n_x_ticks = min(num_points, max(2, int(plot_w // 80))) view_end = self._resolved_view_end()
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1) view_len = max(1, view_end - view_start + 1)
# --- X-axis ticks (labelled in original, non-sliced index space) ---
if view_len > 1:
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks): for i in range(n_x_ticks):
idx = int(round(i * x_tick_step)) rel_idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1) x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
x_px = plot_left + x_frac * plot_w
painter.setPen(axis_pen) painter.setPen(axis_pen)
painter.drawText( painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14), int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1), str(view_start + rel_idx + 1),
) )
# --- Axes boundary --- # --- Axes boundary ---
@@ -339,30 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length --- # --- Draw each series (sliced to the current viewport) ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0 min_pt: tuple[float, float] | None = None
if max_len < 1: max_pt: tuple[float, float] | None = None
return min_seen = math.inf
max_seen = -math.inf
# --- Draw each series ---
for si, series in enumerate(self._series_data): for si, series in enumerate(self._series_data):
if len(series) < 1: hi = min(len(series) - 1, view_end)
sliced = series[view_start:hi + 1] if view_start <= hi else []
if not sliced:
continue continue
color = self._series_colors[si % len(self._series_colors)] color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2) pen = QPen(color, 2)
painter.setPen(pen) painter.setPen(pen)
pts: list[tuple[float, float]] = [] pts: list[tuple[float, float]] = []
for i, val in enumerate(series): for i, val in enumerate(sliced):
try: try:
v = float(val) v = float(val)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
x_frac = i / (max_len - 1) if max_len > 1 else 0.5 x_px = index_to_pixel(i, view_len, plot_left, plot_w)
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5 y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
x_px = plot_left + x_frac * plot_w
y_px = plot_top + y_frac * plot_h
pts.append((x_px, y_px)) pts.append((x_px, y_px))
if self._show_min_max_markers:
if v < min_seen:
min_seen = v
min_pt = (x_px, y_px)
if v > max_seen:
max_seen = v
max_pt = (x_px, y_px)
if len(pts) < 2: if len(pts) < 2:
if len(pts) == 1: if len(pts) == 1:
@@ -375,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
int(pts[i + 1][0]), int(pts[i + 1][1]), int(pts[i + 1][0]), int(pts[i + 1][1]),
) )
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
if self._show_min_max_markers:
marker_font = QFont()
marker_font.setPixelSize(14)
marker_font.setBold(True)
painter.setFont(marker_font)
if max_pt is not None:
painter.setPen(QPen(QColor("#FF5757")))
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "")
if min_pt is not None:
painter.setPen(QPen(QColor("#42A5F5")))
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "")
# --- Legend --- # --- Legend ---
if self._show_legend and self._sensor_names: if self._show_legend and self._sensor_names:
legend_font = QFont() legend_font = QFont()
@@ -23,6 +23,9 @@ class GraphView(QObject):
each sensor as a separate line series. each sensor as a separate line series.
""" """
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
_trend_line: Any
# ---- signals ---- # ---- signals ----
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list} dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
@@ -227,7 +230,7 @@ class GraphView(QObject):
index_a, index_b = path[i] index_a, index_b = path[i]
if index_a < len(a) and index_b < len(b): if index_a < len(a) and index_b < len(b):
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1, line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
style=Qt.DashLine)) style=Qt.PenStyle.DashLine))
self._plot_widget.addItem(line) self._plot_widget.addItem(line)
self._plot_widget.setTitle("DTW Comparison") self._plot_widget.setTitle("DTW Comparison")
+50 -6
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from scipy.interpolate import RBFInterpolator from scipy.interpolate import RBFInterpolator
from scipy.ndimage import zoom as _ndi_zoom
try: try:
import cupy as _cupy # type: ignore import cupy as _cupy # type: ignore
@@ -17,6 +18,17 @@ except Exception:
_KERNEL = "thin_plate_spline" _KERNEL = "thin_plate_spline"
_SMOOTHING = 0.0 _SMOOTHING = 0.0
# Sensor density (≤244 points) carries no more spatial information than this;
# evaluating on a bigger grid than the widget's pixel size just burns CPU that
# a GPU-bilinear QImage upscale reproduces visually. See alpha-release-polish-plan.md §2.2.
GRID_RES = 100
# Bilinear-scaling the raw 100x100 field straight to widget size shows visible
# facets on real (higher-contrast) sensor data. A cheap cubic pre-smooth of the
# field (RBF grid is the expensive part; resampling a small array is not) removes
# the facets before the final QImage upscale does the rest.
REFINE_FACTOR = 2
def interpolate_field( def interpolate_field(
xs: np.ndarray, xs: np.ndarray,
@@ -28,26 +40,30 @@ def interpolate_field(
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
round_clip: bool = False, round_clip: bool = False,
) -> np.ndarray: ) -> np.ndarray:
"""Return a (height, width) float64 array of interpolated values. """Return a float64 array of interpolated values, capped at GRID_RES per side.
Args: Args:
xs, ys: sensor positions in mm (1-D arrays, length N) xs, ys: sensor positions in mm (1-D arrays, length N)
vs: sensor values (length N) vs: sensor values (length N)
width/height: output grid dimensions in pixels width/height: desired output dimensions in pixels; the grid is solved at
min(dim, GRID_RES) and the caller upscales the resulting image
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
round_clip: if True, pixels outside the inscribed ellipse become NaN round_clip: if True, pixels outside the inscribed ellipse become NaN
""" """
coords = np.column_stack([xs, ys]) coords = np.column_stack([xs, ys])
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING) rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
grid_w = min(width, GRID_RES)
grid_h = min(height, GRID_RES)
xmin, xmax, ymin, ymax = extent xmin, xmax, ymin, ymax = extent
gx = np.linspace(xmin, xmax, width) gx = np.linspace(xmin, xmax, grid_w)
gy = np.linspace(ymin, ymax, height) gy = np.linspace(ymin, ymax, grid_h)
grid_x, grid_y = np.meshgrid(gx, gy) grid_x, grid_y = np.meshgrid(gx, gy)
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()]) flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later # RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
field = rbf(flat).reshape(height, width) field = rbf(flat).reshape(grid_h, grid_w)
if round_clip: if round_clip:
cx = (xmin + xmax) / 2 cx = (xmin + xmax) / 2
@@ -57,5 +73,33 @@ def interpolate_field(
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2 dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
field = np.where(dist <= 1.0, field, np.nan) field = np.where(dist <= 1.0, field, np.nan)
return field.astype(np.float64) result: np.ndarray = field.astype(np.float64)
return result
def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
"""Cubic-upsample a coarse (NaN-free) field by `factor`.
Callers that need a circular boundary should pass an *unclipped* field
(round_clip=False) and mask with ellipse_alpha() afterwards — cubic
resampling across a NaN/zero-filled hole rings at the boundary, and any
mask derived from the coarse grid keeps its staircase when upscaled.
"""
refined: np.ndarray = _ndi_zoom(field, factor, order=3)
return refined
def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray:
"""Anti-aliased coverage mask (0..1) of the ellipse inscribed in the grid.
Computed analytically at the target resolution — unlike upsampling a
binary clip mask from the coarse RBF grid, this cannot show grid steps.
`feather_px` is the half-width of the linear edge ramp, in grid pixels.
"""
y = (np.arange(height) + 0.5) / height * 2.0 - 1.0
x = (np.arange(width) + 0.5) / width * 2.0 - 1.0
yy, xx = np.meshgrid(y, x, indexing="ij")
r = np.sqrt(xx * xx + yy * yy)
f = feather_px * 2.0 / min(width, height)
return np.clip((1.0 - r) / f + 0.5, 0.0, 1.0)
@@ -1,12 +1,15 @@
"""QQuickPaintedItem trend chart — running-average temperature over time. """QQuickPaintedItem trend chart — running-average temperature over time.
Renders a single polyline series (per-frame average temperatures) using QPainter. Renders a single polyline series (per-frame average temperatures) over a fixed
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can 0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`. rolling window. Mirrors the @QmlElement registration pattern used by
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
and used as `TrendChartItem { }`.
The series is driven by the existing `streamController.trendData` signal, which The series is driven by `streamController.trendDelta` (one new
emits a JSON-encoded list of floats from both review-mode replay and live-mode `[elapsed_s, value]` point per live frame) and cleared on
streaming. `streamController.trendReset` (new stream started). See
docs/adr/0001-trend-chart-axes-standardization.md.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,17 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
WINDOW_SECONDS = 60.0
Y_MIN = 0.0
Y_MAX = 200.0
Y_TICKS = (0, 40, 80, 120, 160, 200)
@QmlElement @QmlElement
class TrendChartItem(QQuickPaintedItem): class TrendChartItem(QQuickPaintedItem):
"""Painted trend chart; driven by a list of floats via the `data` property.""" """Painted trend chart; driven by incremental (elapsed_s, value) points."""
dataChanged = Signal()
hasDataChanged = Signal() hasDataChanged = Signal()
lineColorChanged = Signal() lineColorChanged = Signal()
gridColorChanged = Signal() gridColorChanged = Signal()
@@ -42,41 +51,35 @@ class TrendChartItem(QQuickPaintedItem):
self.setAntialiasing(True) self.setAntialiasing(True)
self.setFillColor(QColor("transparent")) self.setFillColor(QColor("transparent"))
self._data: list[float] = [] self._points: list[tuple[float, float]] = []
self._padding: int = 8 self._padding: int = 8
# Separate from `_padding` (breathing room around the plot area): these
# reserve space for the axis labels themselves, which are wider than
# `_padding` alone — that mismatch is what clipped y-axis text before.
self._margin_left: int = 48
self._margin_bottom: int = 30
# Top tick label is drawn centered on the plot's top edge (y_px - 7),
# so the plot needs headroom or the label kisses the card border.
self._margin_top: int = 10
self._line_color = QColor("#5B9DF5") self._line_color = QColor("#5B9DF5")
self._grid_color = QColor("#2A3441") self._grid_color = QColor("#2A3441")
self._text_color = QColor("#CBD5E1") self._text_color = QColor("#CBD5E1")
# ── Qt properties ───────────────────────────────────────────────────── # ── Qt properties ─────────────────────────────────────────────────────
@Property("QVariantList", notify=dataChanged)
def data(self) -> list[float]:
return self._data
@data.setter
def data(self, val) -> None:
coerced = self._coerce_floats(val)
if coerced == self._data:
return
self._data = coerced
self.dataChanged.emit()
self.hasDataChanged.emit()
self.update()
@Property(bool, notify=hasDataChanged) @Property(bool, notify=hasDataChanged)
def hasData(self) -> bool: def hasData(self) -> bool:
"""True when the data list has at least one point. """True when at least one point is buffered.
QML can bind to this safely (QVariantList `.length` is not bindable). QML can bind to this safely (a plain list length is not bindable).
""" """
return len(self._data) > 0 return len(self._points) > 0
@Property(QColor, notify=lineColorChanged) @Property(QColor, notify=lineColorChanged)
def lineColor(self) -> QColor: def lineColor(self) -> QColor:
return self._line_color return self._line_color
@lineColor.setter @lineColor.setter # type: ignore[no-redef]
def lineColor(self, c: QColor) -> None: def lineColor(self, c: QColor) -> None:
if c == self._line_color: if c == self._line_color:
return return
@@ -88,7 +91,7 @@ class TrendChartItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
if c == self._grid_color: if c == self._grid_color:
return return
@@ -100,7 +103,7 @@ class TrendChartItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
if c == self._text_color: if c == self._text_color:
return return
@@ -112,7 +115,7 @@ class TrendChartItem(QQuickPaintedItem):
def padding(self) -> int: def padding(self) -> int:
return self._padding return self._padding
@padding.setter @padding.setter # type: ignore[no-redef]
def padding(self, val: int) -> None: def padding(self, val: int) -> None:
v = max(0, int(val)) v = max(0, int(val))
if v == self._padding: if v == self._padding:
@@ -121,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
self.paddingChanged.emit() self.paddingChanged.emit()
self.update() self.update()
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ── # ── slots for QML: streamController.trendDelta / trendReset ────────────
@Slot(str) @Slot(str)
def setDataFromJson(self, avgs_json: str) -> None: def appendDelta(self, delta_json: str) -> None:
"""Slot for QML Connections handler: parse JSON array and update data. """Append new (elapsed_s, value) points and prune the 60s window.
Mirrors `streamController.trendData` (which emits JSON-encoded floats). Mirrors `streamController.trendDelta`, which emits one new pair per
live frame as `[[elapsed_s, value], ...]`.
""" """
try: try:
parsed = json.loads(avgs_json) if avgs_json else [] parsed = json.loads(delta_json) if delta_json else []
except (json.JSONDecodeError, TypeError) as exc: except (json.JSONDecodeError, TypeError) as exc:
log.error("TrendChartItem: failed to parse trend JSON: %s", exc) log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
return return
coerced = self._coerce_floats(parsed)
if coerced == self._data: had_data = len(self._points) > 0
added = False
for pair in parsed:
try:
elapsed, value = float(pair[0]), float(pair[1])
except (TypeError, ValueError, IndexError):
continue
self._points.append((elapsed, value))
added = True
if not added:
return return
self._data = coerced
self.dataChanged.emit() latest_elapsed = self._points[-1][0]
cutoff = latest_elapsed - WINDOW_SECONDS
self._points = [p for p in self._points if p[0] >= cutoff]
if not had_data:
self.hasDataChanged.emit()
self.update()
@Slot()
def clearTrend(self) -> None:
"""Clear all buffered points (new stream started)."""
if not self._points:
return
self._points = []
self.hasDataChanged.emit() self.hasDataChanged.emit()
self.update() self.update()
@@ -153,57 +180,39 @@ class TrendChartItem(QQuickPaintedItem):
if w <= 0 or h <= 0: if w <= 0 or h <= 0:
return return
plot_rect = QRectF(self._padding, self._padding, left = self._padding + self._margin_left
max(1, w - 2 * self._padding), top = self._padding + self._margin_top
max(1, h - 2 * self._padding)) bottom_inset = self._padding + self._margin_bottom
plot_rect = QRectF(left, top,
max(1, w - left - self._padding),
max(1, h - top - bottom_inset))
self._draw_grid(painter, plot_rect) self._draw_grid(painter, plot_rect)
if len(self._data) < 2: if len(self._points) < 2:
return return
y_min, y_max = self._y_range() window_end = self._points[-1][0]
self._draw_axes_labels(painter, plot_rect, y_min, y_max) window_start = max(0.0, window_end - WINDOW_SECONDS)
self._draw_line(painter, plot_rect, y_min, y_max) self._draw_axes_labels(painter, plot_rect, window_start, window_end)
self._draw_line(painter, plot_rect, window_start, window_end)
# ── internals ───────────────────────────────────────────────────────── # ── internals ─────────────────────────────────────────────────────────
def _coerce_floats(self, val) -> list[float]: def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
if not val: return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
return []
out: list[float] = []
for v in val:
try:
out.append(float(v))
except (TypeError, ValueError):
continue
return out
def _y_range(self) -> tuple[float, float]: def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
lo = min(self._data) px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
hi = max(self._data) # Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
if hi - lo < 1e-9: return max(plot_rect.top(), min(plot_rect.bottom(), px))
return lo - 5.0, hi + 5.0
buf = (hi - lo) * 0.1
return lo - buf, hi + buf
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1:
return plot_rect.left()
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
if y_max - y_min < 1e-9:
return plot_rect.center().y()
t = (v - y_min) / (y_max - y_min)
return plot_rect.bottom() - t * plot_rect.height()
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
pen = QPen(self._grid_color) pen = QPen(self._grid_color)
pen.setWidthF(1.0) pen.setWidthF(1.0)
pen.setCosmetic(True) pen.setCosmetic(True)
painter.setPen(pen) painter.setPen(pen)
rows = 4 rows = len(Y_TICKS) - 1
for i in range(rows + 1): for i in range(rows + 1):
y = plot_rect.top() + (i / rows) * plot_rect.height() y = plot_rect.top() + (i / rows) * plot_rect.height()
painter.drawLine(int(plot_rect.left()), int(y), painter.drawLine(int(plot_rect.left()), int(y),
@@ -213,31 +222,69 @@ class TrendChartItem(QQuickPaintedItem):
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
painter.setPen(self._text_color) painter.setPen(self._text_color)
font = QFont(painter.font()) font = QFont(painter.font())
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
painter.setFont(font) painter.setFont(font)
rows = 4
for i in range(rows + 1): title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
t = i / rows label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
y_val = y_max - t * (y_max - y_min) for tick in reversed(Y_TICKS):
y_px = plot_rect.top() + t * plot_rect.height() y_px = self._y_to_px(tick, plot_rect)
label = f"{y_val:.1f}" painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label) str(tick))
# X-axis: elapsed seconds within the current rolling window.
cols = 4
y_px = plot_rect.bottom() + 4
for i in range(cols + 1):
t_val = window_start + (i / cols) * (window_end - window_start)
x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
label = f"{t_val:.0f}"
if i == 0:
rect = QRectF(x_px, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignLeft
elif i == cols:
# Right-anchor inside the plot so the label never clips off-widget
rect = QRectF(x_px - 40, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignRight
else:
rect = QRectF(x_px - 20, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignHCenter
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
def _draw_axis_titles(
self,
painter: QPainter,
plot_rect: QRectF,
title_w: float,
x_label_bottom: float,
) -> None:
# Y axis: "Temp", rotated upright, centered along the plot's height.
painter.save()
painter.translate(title_w / 2, plot_rect.center().y())
painter.rotate(-90)
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
painter.restore()
# X axis: "Time", centered under the elapsed-seconds tick row.
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
def _draw_line( def _draw_line(
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
n = len(self._data)
pen = QPen(self._line_color) pen = QPen(self._line_color)
pen.setWidthF(2.0) pen.setWidthF(2.0)
pen.setCosmetic(True) pen.setCosmetic(True)
@@ -246,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
painter.setPen(pen) painter.setPen(pen)
poly = QPolygon() poly = QPolygon()
for i, v in enumerate(self._data): for elapsed, value in self._points:
px = self._x_to_px(i, n, plot_rect) px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
py = self._y_to_px(v, y_min, y_max, plot_rect) py = self._y_to_px(value, plot_rect)
poly.append(QPoint(int(px), int(py))) poly.append(QPoint(int(px), int(py)))
painter.drawPolyline(poly) painter.drawPolyline(poly)
# Trailing dot at the last data point # Trailing dot at the last data point
last_x = int(self._x_to_px(n - 1, n, plot_rect)) last_elapsed, last_value = self._points[-1]
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect)) last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
last_y = int(self._y_to_px(last_value, plot_rect))
painter.setBrush(self._line_color) painter.setBrush(self._line_color)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
radius = 3 radius = 3
+304 -83
View File
@@ -14,7 +14,7 @@ import logging
import math import math
import numpy as np import numpy as np
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import ( from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
@@ -27,11 +27,69 @@ from PySide6.QtGui import (
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.rbf_heatmap import interpolate_field from pygui.backend.models.frame_stats import compute_stats
from pygui.backend.visualization.rbf_heatmap import (
ellipse_alpha,
interpolate_field,
refine_field,
)
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
Returns (points, error) — error is "" on success.
"""
import csv
points: list[list[float]] = []
slot: str | None = None
try:
with open(file_path, newline="") as fh:
reader = csv.reader(fh)
header = next(reader, None)
if header is None:
return [], "Unable to read header row from CSV file."
if header and header[-1] == "": # trailing comma
header = header[:-1]
nfields = len(header)
if nfields != 8:
return [], "Incorrect field count in CSV header row."
for lno, row in enumerate(reader, start=2):
if row and row[-1] == "":
row = row[:-1]
if len(row) != nfields:
return [], f"Incorrect field count in CSV row {lno}"
if slot is None:
slot = row[3]
elif row[3] != slot:
break
try:
t2, x, y = float(row[4]), float(row[6]), float(row[7])
except ValueError:
return [], f"Malformed value in CSV row {lno}"
points.append([x, y, t2])
except OSError as exc:
return [], f"Unable to read CSV file: {exc}"
if not points:
return [], "No data found in CSV file."
return points, ""
def readout_text(values: list[float]) -> str:
"""Full READOUT stats line for the export footer; '' when no data."""
if not values:
return ""
s = compute_stats(values)
return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) "
f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} "
f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}")
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
@@ -47,11 +105,14 @@ class WaferMapItem(QQuickPaintedItem):
marginChanged = Signal() marginChanged = Signal()
blendChanged = Signal() blendChanged = Signal()
showLabelsChanged = Signal() showLabelsChanged = Signal()
showExtremesChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
shapeChanged = Signal() shapeChanged = Signal()
sizeChanged = Signal() sizeChanged = Signal()
thicknessChanged = Signal() thicknessChanged = Signal()
showThicknessChanged = Signal() showThicknessChanged = Signal()
hoveredIndexChanged = Signal()
hoverScaleChanged = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -63,11 +124,16 @@ class WaferMapItem(QQuickPaintedItem):
self._margin: float = 1.0 self._margin: float = 1.0
self._blend: float = 0.0 self._blend: float = 0.0
self._show_labels: bool = True self._show_labels: bool = True
self._show_extremes: bool = True
self._shape: str = "round" self._shape: str = "round"
self._size: float = 300.0 self._size: float = 300.0
self._thickness_data: list[float] = [] self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
self._show_thickness: bool = False self._show_thickness: bool = False
self._thickness_heatmap:QImage | None = None self._thickness_heatmap:QImage | None = None
# Hover highlight: index of the marker under the pointer (-1 = none)
# and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
self._hovered_index: int = -1
self._hover_scale: float = 1.0
# Dark-theme color defaults (match Theme.qml tokens) # Dark-theme color defaults (match Theme.qml tokens)
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder) self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
@@ -81,6 +147,12 @@ class WaferMapItem(QQuickPaintedItem):
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py) self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
self._marker_r: int = 4 self._marker_r: int = 4
self._heatmap: QImage | None = None self._heatmap: QImage | None = None
# Size the markers/heatmap were last computed against. On first activation
# (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
# when `sensors` is first set, computing degenerate marker positions that
# never get retried. paint() re-checks this every frame so geometry that
# settles after property assignment still gets picked up.
self._last_draw_size: int = -1
self.widthChanged.connect(self._on_resize) self.widthChanged.connect(self._on_resize)
self.heightChanged.connect(self._on_resize) self.heightChanged.connect(self._on_resize)
@@ -91,7 +163,7 @@ class WaferMapItem(QQuickPaintedItem):
def sensors(self) -> list: def sensors(self) -> list:
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors] return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
@sensors.setter @sensors.setter # type: ignore[no-redef]
def sensors(self, val: list) -> None: def sensors(self, val: list) -> None:
self._sensors = [ self._sensors = [
Sensor( Sensor(
@@ -111,7 +183,7 @@ class WaferMapItem(QQuickPaintedItem):
def values(self) -> list: def values(self) -> list:
return self._values return self._values
@values.setter @values.setter # type: ignore[no-redef]
def values(self, val: list) -> None: def values(self, val: list) -> None:
self._values = list(val or []) self._values = list(val or [])
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -122,7 +194,7 @@ class WaferMapItem(QQuickPaintedItem):
def bands(self) -> list: def bands(self) -> list:
return self._bands return self._bands
@bands.setter @bands.setter # type: ignore[no-redef]
def bands(self, val: list) -> None: def bands(self, val: list) -> None:
self._bands = list(val or []) self._bands = list(val or [])
self.bandsChanged.emit() self.bandsChanged.emit()
@@ -132,7 +204,7 @@ class WaferMapItem(QQuickPaintedItem):
def target(self) -> float: def target(self) -> float:
return self._target return self._target
@target.setter @target.setter # type: ignore[no-redef]
def target(self, val: float) -> None: def target(self, val: float) -> None:
self._target = float(val) self._target = float(val)
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -143,7 +215,7 @@ class WaferMapItem(QQuickPaintedItem):
def margin(self) -> float: def margin(self) -> float:
return self._margin return self._margin
@margin.setter @margin.setter # type: ignore[no-redef]
def margin(self, val: float) -> None: def margin(self, val: float) -> None:
self._margin = float(val) self._margin = float(val)
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -154,7 +226,7 @@ class WaferMapItem(QQuickPaintedItem):
def blend(self) -> float: def blend(self) -> float:
return self._blend return self._blend
@blend.setter @blend.setter # type: ignore[no-redef]
def blend(self, val: float) -> None: def blend(self, val: float) -> None:
self._blend = max(0.0, min(1.0, float(val))) self._blend = max(0.0, min(1.0, float(val)))
if self._blend > 0 and self._heatmap is None: if self._blend > 0 and self._heatmap is None:
@@ -166,17 +238,27 @@ class WaferMapItem(QQuickPaintedItem):
def showLabels(self) -> bool: def showLabels(self) -> bool:
return self._show_labels return self._show_labels
@showLabels.setter @showLabels.setter # type: ignore[no-redef]
def showLabels(self, val: bool) -> None: def showLabels(self, val: bool) -> None:
self._show_labels = bool(val) self._show_labels = bool(val)
self.showLabelsChanged.emit() self.showLabelsChanged.emit()
self.update() self.update()
@Property(bool, notify=showExtremesChanged)
def showExtremes(self) -> bool:
return self._show_extremes
@showExtremes.setter # type: ignore[no-redef]
def showExtremes(self, val: bool) -> None:
self._show_extremes = bool(val)
self.showExtremesChanged.emit()
self.update()
@Property(str, notify=shapeChanged) @Property(str, notify=shapeChanged)
def shape(self) -> str: def shape(self) -> str:
return self._shape return self._shape
@shape.setter @shape.setter # type: ignore[no-redef]
def shape(self, val: str) -> None: def shape(self, val: str) -> None:
self._shape = str(val).lower() self._shape = str(val).lower()
self._rebuild() self._rebuild()
@@ -186,7 +268,7 @@ class WaferMapItem(QQuickPaintedItem):
def size(self) -> float: def size(self) -> float:
return self._size return self._size
@size.setter @size.setter # type: ignore[no-redef]
def size(self, val: float) -> None: def size(self, val: float) -> None:
self._size = float(val) self._size = float(val)
self._rebuild() self._rebuild()
@@ -194,54 +276,95 @@ class WaferMapItem(QQuickPaintedItem):
@Property("QVariantList", notify=thicknessChanged) @Property("QVariantList", notify=thicknessChanged)
def thicknessData(self) -> list: def thicknessData(self) -> list:
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
return self._thickness_data return self._thickness_data
@thicknessData.setter @thicknessData.setter # type: ignore[no-redef]
def thicknessData(self, val:list) -> None: def thicknessData(self, val:list) -> None:
self._thickness_data = list(val or []) self._thickness_data = [list(p) for p in (val or [])]
self._rebuild_thickness() self._rebuild_thickness()
self.thicknessChanged.emit() self.thicknessChanged.emit()
self.update()
@Property(bool, notify=thicknessChanged)
def hasThickness(self) -> bool:
return bool(self._thickness_data)
@Slot(str, result=str)
def loadThickness(self, file_path: str) -> str:
"""Load a customer thickness CSV; returns error message, '' on success."""
points, err = parse_thickness_csv(file_path)
if err:
return err
self._thickness_data = points
self._rebuild_thickness()
self.thicknessChanged.emit()
self.update()
return ""
self.update self.update
@Property(bool, notify=showThicknessChanged) @Property(bool, notify=showThicknessChanged)
def showThickness(self) -> bool: def showThickness(self) -> bool:
return self._show_thickness return self._show_thickness
@showThickness.setter @showThickness.setter # type: ignore[no-redef]
def showThickness(self, val: bool) -> None: def showThickness(self, val: bool) -> None:
self._show_thickness = bool(val) self._show_thickness = bool(val)
self.showThicknessChanged.emit() self.showThicknessChanged.emit()
self.update() self.update()
@Property(int, notify=hoveredIndexChanged)
def hoveredIndex(self) -> int:
return self._hovered_index
@hoveredIndex.setter # type: ignore[no-redef]
def hoveredIndex(self, val: int) -> None:
val = int(val)
if val == self._hovered_index:
return
self._hovered_index = val
self.hoveredIndexChanged.emit()
self.update()
@Property(float, notify=hoverScaleChanged)
def hoverScale(self) -> float:
return self._hover_scale
@hoverScale.setter # type: ignore[no-redef]
def hoverScale(self, val: float) -> None:
self._hover_scale = float(val)
self.hoverScaleChanged.emit()
self.update()
# Colour properties — QML can bind these to Theme tokens # Colour properties — QML can bind these to Theme tokens
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color def ringColor(self) -> QColor: return self._ring_color
@ringColor.setter @ringColor.setter # type: ignore[no-redef]
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update() def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor: return self._axis_color def axisColor(self) -> QColor: return self._axis_color
@axisColor.setter @axisColor.setter # type: ignore[no-redef]
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update() def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def lowColor(self) -> QColor: return self._low_color def lowColor(self) -> QColor: return self._low_color
@lowColor.setter @lowColor.setter # type: ignore[no-redef]
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update() def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def inRangeColor(self) -> QColor: return self._in_range_color def inRangeColor(self) -> QColor: return self._in_range_color
@inRangeColor.setter @inRangeColor.setter # type: ignore[no-redef]
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update() def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def highColor(self) -> QColor: return self._high_color def highColor(self) -> QColor: return self._high_color
@highColor.setter @highColor.setter # type: ignore[no-redef]
def highColor(self, c: QColor) -> None: self._high_color = c; self.update() def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor: return self._text_color def textColor(self) -> QColor: return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: self._text_color = c; self.update() def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
# ── slots ───────────────────────────────────────────────────────────── # ── slots ─────────────────────────────────────────────────────────────
@@ -255,70 +378,125 @@ class WaferMapItem(QQuickPaintedItem):
return idx return idx
return -1 return -1
# TODO P6.1: build batch export on top of this single-frame export
# THINKING: export_image() already does the hard part (grabToImage → PNG);
# batch export is just a loop over file_browser.files that loads each CSV
# through the existing wafer-map pipeline and calls this per file into a
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
@Slot(str, result=bool) @Slot(str, result=bool)
def export_image(self, file_path: str) -> bool: @Slot(str, str, result=bool)
def export_image(self, file_path: str, extra: str = "") -> bool:
"""Export the current wafer map rendering to a PNG file """Export the current wafer map rendering to a PNG file
Args: Args:
file_pat: Absolute path for the output PNG. file_path: Absolute path for the output PNG.
extra: Optional second footer line (live stream metrics).
Returns: Returns:
True on success, False on failure True on success, False on failure
""" """
if not file_path: if not file_path or not self._values:
# No file loaded / no stream played yet — only the empty template
# would render, which isn't a meaningful export.
return False return False
try: try:
result = self.grabToImage() # Render synchronously via paint() into our own image —
img = result.image() # grabToImage() resolves on a later render frame, so its result
img.save(file_path, "PNG") # is null when read immediately (the old silent failure).
return True w, h = int(self.width()), int(self.height())
if w <= 0 or h <= 0:
return False
lines = [ln for ln in (readout_text(self._values), extra) if ln]
footer_h = 28 * len(lines)
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
img.fill(0) # transparent background
painter = QPainter(img)
self.paint(painter)
if lines:
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
painter.setPen(QColor("#CBD5E1"))
for i, line in enumerate(lines):
painter.drawText(QRectF(0, h + 28 * i, w, 28),
Qt.AlignmentFlag.AlignCenter, line)
painter.end()
return bool(img.save(file_path))
except Exception as e: except Exception as e:
log.error("Export failed: %s", e) log.error("Export failed: %s", e)
return False return False
# ── internal ───────────────────────────────────────────────────────── # ── internal ─────────────────────────────────────────────────────────
def _paint_extremes(self, painter: QPainter) -> None:
"""Ring the hottest (high color) and coldest (low color) markers."""
if not self._show_extremes or not self._values or not self._markers:
return
s = compute_stats(self._values)
ring_r = self._marker_r + 5
painter.setBrush(Qt.BrushStyle.NoBrush)
for idx, color in ((s.max_index, self._high_color),
(s.min_index, self._low_color)):
if idx not in self._markers:
continue
px, py = self._markers[idx]
painter.setPen(QPen(color, 2))
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
def _rebuild_thickness(self) -> None: def _rebuild_thickness(self) -> None:
"""Interpolate thickness data into a gray/orange heatmap QImage.""" """Interpolate thickness points into a gray/orange heatmap QImage."""
if not self._sensors or not self._thickness_data: if not self._thickness_data:
self._thickness_heatmap = None self._thickness_heatmap = None
return return
ds = self._draw_size() ds = self._draw_size()
r_mm = self._wafer_radius_mm() r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors]) pts = np.array(self._thickness_data, dtype=float)
ys = np.array([s.y for s in self._sensors]) xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._thickness_heatmap = None
return
try: try:
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
field = interpolate_field( field = interpolate_field(
xs, ys, vs, xs, ys, vs,
width=ds, height=ds, width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm), extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
) )
except Exception: except Exception:
self._thickness_heatmap = None self._thickness_heatmap = None
return return
# Gray/orange colormap: map field range 0→1 to gray→orange field = refine_field(field)
vmin, vmax = np.nanmin(field), np.nanmax(field) # ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
alpha = np.ones(field.shape)
# Color-range bounds from inside the wafer only — the unclipped field
# extrapolates wildly toward the square corners, which alpha hides but
# a corner-driven vmin/vmax would flatten the visible gradient.
inside = field[alpha >= 0.5]
vmin, vmax = inside.min(), inside.max()
span = vmax - vmin or 1.0 span = vmax - vmin or 1.0
# Gray/orange colormap: map field range 0→1 to gray→orange
t = np.clip((field - vmin) / span, 0.0, 1.0) t = np.clip((field - vmin) / span, 0.0, 1.0)
gh, gw = field.shape
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0) # Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
rgb = np.zeros((ds, ds, 3), dtype=np.float32) rgb = np.zeros((gh, gw, 3), dtype=np.float32)
rgb[:, :, 0] = 0.5 + 0.5 * t rgb[:, :, 0] = 0.5 + 0.5 * t
rgb[:, :, 1] = 0.5 + 0.15 * t rgb[:, :, 1] = 0.5 + 0.15 * t
rgb[:, :, 2] = 0.5 - 0.5 * t rgb[:, :, 2] = 0.5 - 0.5 * t
rgb = np.nan_to_num(rgb, nan=0.0) rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8) rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8) rgba[:, :, 3] = (alpha * 180).astype(np.uint8)
self._thickness_heatmap = ( grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() self._thickness_heatmap = grid_img.scaled(
ds, ds,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
) )
def _on_resize(self) -> None: def _on_resize(self) -> None:
@@ -342,6 +520,13 @@ class WaferMapItem(QQuickPaintedItem):
r = max(math.hypot(s.x, s.y) for s in self._sensors) r = max(math.hypot(s.x, s.y) for s in self._sensors)
return r * 1.05 return r * 1.05
# TODO P6.2: reuse this for the edge-to-center delta metric
# THINKING: radial_metrics.py (new) needs the same "which sensors are near
# the edge vs. near the center" bucketing this function already computes
# for ring-line drawing — do NOT re-derive ring boundaries separately, this
# already handles round vs. square wafer shapes correctly (see family_spec.py
# square-wafer work). Outermost group ≈ edge sensors, innermost ≈ center.
# See docs/pending/alpha-release-polish-plan.md §6.2.
def _sensor_ring_radii_mm(self) -> list[float]: def _sensor_ring_radii_mm(self) -> list[float]:
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary.""" """Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
if not self._sensors: if not self._sensors:
@@ -391,19 +576,34 @@ class WaferMapItem(QQuickPaintedItem):
self._heatmap = None self._heatmap = None
return return
try: try:
# No round_clip: refine the smooth unclipped field, then mask with an
# analytic anti-aliased ellipse (a coarse-grid clip mask upscales jagged).
field = interpolate_field( field = interpolate_field(
xs, ys, vs, xs, ys, vs,
width=ds, height=ds, width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm), extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
) )
except Exception: except Exception:
self._heatmap = None self._heatmap = None
return return
self._heatmap = self._field_to_qimage(field, ds) field = refine_field(field)
# ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
alpha = np.ones(field.shape)
grid_img = self._field_to_qimage(field, alpha)
self._heatmap = grid_img.scaled(
ds, ds,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage: def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
"""Apply a band-aware tri-color gradient → RGBA QImage.""" """Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
lo_b = self._target - self._margin lo_b = self._target - self._margin
hi_b = self._target + self._margin hi_b = self._target + self._margin
span = hi_b - lo_b or 1.0 span = hi_b - lo_b or 1.0
@@ -428,19 +628,28 @@ class WaferMapItem(QQuickPaintedItem):
lo_c * (1 - t_lo) + mid_c * t_lo, lo_c * (1 - t_lo) + mid_c * t_lo,
mid_c * (1 - t_hi) + hi_c * t_hi, mid_c * (1 - t_hi) + hi_c * t_hi,
) )
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
rgb = np.nan_to_num(rgb, nan=0.0) rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8) gh, gw = field.shape
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8) # alpha is a continuous 0..1 coverage mask (not a hard isfinite cutoff) so the
# round-clip boundary anti-aliases instead of showing coarse-grid steps.
rgba[:, :, 3] = (alpha * 210).astype(np.uint8)
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
# ── paint ───────────────────────────────────────────────────────────── # ── paint ─────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None: def paint(self, painter: QPainter) -> None:
ds = self._draw_size() ds = self._draw_size()
if ds != self._last_draw_size:
# Geometry settled (or changed) since markers/heatmap were last built
# against a stale size — recompute now, at actual paint time.
self._last_draw_size = ds
self._compute_markers()
self._rebuild_heatmap()
self._rebuild_thickness()
r_px = int(ds / 2 - 4) r_px = int(ds / 2 - 4)
cx, cy = self._center() cx, cy = self._center()
@@ -461,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
painter.setOpacity(1.0) painter.setOpacity(1.0)
self._paint_markers(painter) self._paint_markers(painter)
self._paint_extremes(painter)
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None: def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
ds = self._draw_size() ds = self._draw_size()
@@ -524,11 +734,11 @@ class WaferMapItem(QQuickPaintedItem):
font_scale = 0.85 font_scale = 0.85
id_font = QFont() id_font = QFont()
id_font.setPointSize(max(4, int(r * font_scale))) id_font.setPointSize(max(9, int(r * font_scale)))
id_font.setBold(True) id_font.setBold(True)
temp_font = QFont() temp_font = QFont()
temp_font.setPointSize(max(3, int((r - 1) * font_scale))) temp_font.setPointSize(max(9, int(r * font_scale)))
band_color = { band_color = {
"in_range": self._in_range_color, "in_range": self._in_range_color,
@@ -536,28 +746,6 @@ class WaferMapItem(QQuickPaintedItem):
"low": self._low_color, "low": self._low_color,
} }
for i, s in enumerate(self._sensors):
if i not in self._markers:
continue
px, py = self._markers[i]
color = band_color.get(
self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color,
)
# Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color))
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
if self._show_labels:
has_temp = i < len(self._values)
# Fetch text alignment side and offsets
side = getattr(s, "side", "right").lower()
ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r
# Pre-compute metrics using current scaled fonts
painter.setFont(id_font) painter.setFont(id_font)
id_fm = painter.fontMetrics() id_fm = painter.fontMetrics()
id_line_h = id_fm.height() id_line_h = id_fm.height()
@@ -568,6 +756,39 @@ class WaferMapItem(QQuickPaintedItem):
temp_line_h = temp_fm.height() temp_line_h = temp_fm.height()
temp_ascent = temp_fm.ascent() temp_ascent = temp_fm.ascent()
for i, s in enumerate(self._sensors):
if i not in self._markers:
continue
px, py = self._markers[i]
color = band_color.get(
self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color,
)
is_hovered = i == self._hovered_index
marker_r = int(round(r * self._hover_scale)) if is_hovered else r
if is_hovered:
# Soft glow ring behind the marker, growing with hoverScale.
glow_r = int(round(marker_r * 1.9))
glow_color = QColor(color)
glow_color.setAlpha(70)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(glow_color))
painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)
# Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color))
painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)
if self._show_labels:
has_temp = i < len(self._values)
# Fetch text alignment side and offsets
side = getattr(s, "side", "right").lower()
ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r
id_text = s.label id_text = s.label
temp_text = f"{self._values[i]:.2f}" if has_temp else "" temp_text = f"{self._values[i]:.2f}" if has_temp else ""
@@ -598,11 +819,11 @@ class WaferMapItem(QQuickPaintedItem):
painter.setPen(QPen(self._text_color)) painter.setPen(QPen(self._text_color))
y1 = ly + id_ascent y1 = ly + id_ascent
if side in ("top", "bottom"): if side in ("top", "bottom"):
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
elif side == "left": elif side == "left":
painter.drawText(lx + (text_w - id_w), y1, id_text) painter.drawText(lx + (text_w - id_w), y1, id_text) # type: ignore[arg-type]
else: else:
painter.drawText(lx, y1, id_text) painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
# Draw Temperature (second line) # Draw Temperature (second line)
if has_temp: if has_temp:
@@ -610,8 +831,8 @@ class WaferMapItem(QQuickPaintedItem):
painter.setPen(QPen(color)) painter.setPen(QPen(color))
y2 = ly + id_line_h + temp_ascent y2 = ly + id_line_h + temp_ascent
if side in ("top", "bottom"): if side in ("top", "bottom"):
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
elif side == "left": elif side == "left":
painter.drawText(lx + (text_w - temp_w), y2, temp_text) painter.drawText(lx + (text_w - temp_w), y2, temp_text) # type: ignore[arg-type]
else: else:
painter.drawText(lx, y2, temp_text) painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
-10
View File
@@ -1,10 +0,0 @@
# ===== Wafer Sub-package =====
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",
]
+9
View File
@@ -0,0 +1,9 @@
"""Single source of truth for the wafer sensor-count partition."""
def sensor_count_for(family_code: str) -> int:
"""Return the number of valid sensor readings for a wafer family code.
Returns 80 for the "X" family. Returns 244 for every other family
code."""
return 80 if family_code == "X" else 244
+31 -1
View File
@@ -66,9 +66,39 @@ def _family_name(raw_name: str) -> str:
def _load_yaml(path: Path) -> dict: def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f: with path.open(encoding="utf-8") as f:
return yaml.safe_load(f) loaded: dict = yaml.safe_load(f)
return loaded
# Edge→center sensor pair tables, ported verbatim from the C# original
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
"BCD": tuple((e, 28) for e in range(12)),
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
"Z": tuple((e, 0) for e in range(49, 65)),
}
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
Unknown families deliberately get no pairs (C# fell through to Z).
"""
prefix = wafer_id[0].upper() if wafer_id else ""
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
("X", "X"), ("Z", "Z")):
if prefix and prefix in families:
return _EC_MAPS[key]
return ()
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
def available_families() -> list[str]: def available_families() -> list[str]:
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")] return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
+1 -4
View File
@@ -104,9 +104,6 @@ class ZWaferParser:
elif key.lower() == "wafer id": elif key.lower() == "wafer id":
wafer_data.serial = value wafer_data.serial = value
if found_kv and wafer_data.date == datetime.min:
wafer_data.date = datetime.min
return found_kv return found_kv
# ===== Sensor Layout Parsing ===== # ===== Sensor Layout Parsing =====
@@ -118,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):

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