Compare commits

...

77 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
jack 831457941a test: add comprehensive test suite for visualization and comparison modules 2026-06-25 13:49:25 -07:00
jack cfadbdf1c3 feat(ui): decouple QML page modules and update tab layouts
Extract ReadoutPanel, WaferMapView, and TransportBar into reusable components and bind tabs to the new session controllers.
2026-06-25 13:48:20 -07:00
jack f4621f1faf feat(viz): implement RBF heatmap and custom QPainter trend chart item
Introduce Radial Basis Function interpolation for fine wafer thermal gradients and GraphQuickItem for high-performance trend line rendering.
2026-06-25 13:47:48 -07:00
jack 2cbc143c20 feat(backend): introduce session controller and status models
Implement DeviceController and SessionController to bridge QML requests with backend state, supporting playback replay tracking, cluster averaging, and slot error boundaries.
2026-06-25 13:47:21 -07:00
jack 470f8acba3 feat(core): migrate to src/ package layout and refactor serial transport
Bootstrap PySide6 using a clean src/ layout and refactor stream_reader to parse binary data packets asynchronously.
2026-06-25 13:46:54 -07:00
jack 62ce5a9c37 feat: implement DTW comparison, add data segmentation and persistence, and update QML UI for file imports and graphing. 2026-06-23 21:38:45 -07:00
jack 20a34e22ee feat: wire ReplayStatsTracker into SessionController + complete GraphView.updateTrend 2026-06-20 18:28:55 -07:00
jack 7206334a27 feat: implement slot_error_boundary decorator and add unit tests for session and device controllers 2026-06-18 21:01:37 -07:00
jack 5514653b94 refactor: prune orphaned attic modules and standardize project configurations
- Move orphaned modules (data_segment.py, contour_models.py, marching_squares.py) to attic/.
- Standardize local settings logic, serial port parameters, and graph view plots.
- Update pyproject.toml pyside6-project dependencies and configure gitignore.
2026-06-18 17:09:24 -07:00
jack 93868bcde4 feat(ui): implement save directory browse button and pre-detection check
- Add a 'Browse...' button to the Connection Status panel in StatusTab.qml.
- Check if save directory is set before wafer detection in HomePage.qml, prompting the user if empty.
- Resolve QML Connections warning by renaming onLogMessageEmitted to onLogMessage.
- Remove temporary debug stack traces from device_controller.py.
2026-06-18 17:08:58 -07:00
jack b417211476 update readme 2026-06-18 13:16:54 -07:00
jack 66942d250d refactor: standardize codebase formatting, optimize import orders, and update GUI resolution to 1080p 2026-06-18 13:13:47 -07:00
jack 8ad2a9b972 docs: update setup instructions for uv, clarify Windows compatibility, and add simulator connection guide with supporting screenshots 2026-06-18 12:13:33 -07:00
jack 1119733929 feat: add interactive folder selection for CSV saves and stabilize cross-platform dependency locking 2026-06-18 10:30:51 -07:00
jack e545c245d7 feat: implement cluster averaging pipeline, UI controls 2026-06-15 15:02:55 -07:00
jack 7d32fb4b65 refactor: normalize data directory naming, and consolidate serial connection handling 2026-06-15 12:39:16 -07:00
jack 5b1c924d35 feat(stream_reader): optimize serial stream protocol detection. reduce CPU busy-spins 2026-06-15 11:56:09 -07:00
jack b34b920759 refactor: standardize timing with monotonic clocks, remove redundant stream parsing 2026-06-15 11:37:51 -07:00
jack a70764e08c chore: update package lock 2026-06-15 11:28:49 -07:00
jack 2e4763510d feat(ui): improve device session management, update data directory paths, and add session clearing functionality. 2026-06-15 11:28:28 -07:00
jack 7e584e08e8 feat(visual): implement square wafer (X) template, inward label alignment, and density scaling 2026-06-15 11:20:55 -07:00
jack cea4fb782e feat(wafer): introduce layout metadata modeling and shape resolution helpers
- Define WaferLayout subclass to wrap sensor lists with shape and size attributes.
- Add resolve_shape_and_size resolver to infer wafer shape/size from filename prefix or sensor count.
- Update Sensor dataclass to support dynamic side alignments and coordinates offsets.
- Expose waferShape and waferSize properties from SessionController for QML data-bindings.
2026-06-15 11:19:32 -07:00
jack 5b186df888 feat(serialcomm): implement stream auto-detection, fallback baudrate, and virtual port discovery
- Add automatic format detection (binary, ASCII hex dump, or text line) in StreamReader based on stream preambles.
- Implement fallback to 115200 baudrate when 888888 ioctl fails (critical for macOS PTYs).
- Parse binary streams (X family) with little-endian sequence/length headers and Modbus CRC-16 checks.
- Discover and prioritize virtual serial ports (from running socat instances) in enumerate_ports using lsof.
- Update FakeTransport to mock read() and readline() calls for unit test compatibility.
2026-06-15 11:11:00 -07:00
152 changed files with 13332 additions and 1967 deletions
+17
View File
@@ -22,6 +22,7 @@ env/
*.swo *.swo
*~ *~
docs docs
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -39,5 +40,21 @@ dist/
*.spec *.spec
*.icns *.icns
*.ico *.ico
# Runtime scratch data
tmp/
# Superpowers brainstorming visual companion # Superpowers brainstorming visual companion
.superpowers/ .superpowers/
.llm-wiki/
.agents/
.pi/
# Planning / personal docs (root level)
CLAUDE.md
LEARNINGS.md
MIGRATION.md
AGENTS.md
CONVENTIONS.md
specs/
graphify-out/
+23
View File
@@ -0,0 +1,23 @@
.PHONY: install test lint fix typecheck run clean
install:
uv sync
test: lint
uv run pytest tests/
lint:
uv run ruff check src/ tests/
fix:
uv run ruff check src/ tests/ --fix
typecheck:
uv run mypy src/
run:
uv run python -m pygui
clean:
rm -rf .pytest_cache .ruff_cache .mypy_cache
find . -type d -name "__pycache__" -exec rm -rf {} +
+152 -8
View File
@@ -2,18 +2,60 @@
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.10+ (tested with Python 3.14 in this workspace) - Python 3.11+ (tested with Python 3.14 in this workspace)
- macOS, Linux, or Windows with GUI support - macOS, Linux, or Windows with GUI support
## Setup ## Setup
From the project root: From the project root:
### Option A: Using `uv` (Recommended)
If you have [uv](https://docs.astral.sh/uv/) installed:
```bash
uv sync
```
### Option B: Using `pip`
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate # or .venv\Scripts\activate on Windows
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install -e . # install the `pygui` package (src/ layout) in editable mode pip install -e . # install the `pygui` package (src/ layout) in editable mode
@@ -21,20 +63,51 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
## Run ## Run
### Launch PySide6 QML App
```bash ```bash
source .venv/bin/activate make run
python -m pygui # or run the `isc` console script
``` ```
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`. This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
## Development
The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests.
A `Makefile` is provided to simplify common development and verification tasks:
| Command | Description |
| --------- | ------------- |
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
| `make run` | Launch the ISenseCloud application |
| `make test` | Run the `pytest` test suite |
| `make lint` | Check code style and quality with the `Ruff` linter |
| `make fix` | Automatically fix fixable `Ruff` style violations |
| `make typecheck` | Run the `Mypy` static type checker |
| `make clean` | Clean build and tool caches (`.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `__pycache__`) |
### Ruff Configuration
Ruff is configured in `pyproject.toml` to enforce:
- **E/W**: Pycodestyle errors and warnings
- **F**: Pyflakes linter rules
- **I**: Import sorting (isort parity)
- **N**: PEP 8 naming conventions
## Project Structure ## Project Structure
The application lives under a `src/` layout as the `pygui` package: The application lives under a `src/` layout as the `pygui` package:
- `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`. - `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`.
- `src/pygui/backend/`: Qt-facing models and controllers (device, settings, file browser, wafer parsing). - `src/pygui/backend/`: Qt-facing models, controllers, and visualizers organized into subdirectories:
- `src/pygui/serialcomm/`: serial port, device service, and data-parser layer. - `controllers/`: QML-facing controllers (Session & Device controllers).
- `data/`: local settings, file browser, and CSV recorder utilities.
- `models/`: data models (Session, Thresholds, Frame Player, Frame representation).
- `visualization/`: QML wafer map item integration.
- `wafer/`: layout definitions, coordinate mappings, and files parser.
- `src/pygui/serialcomm/`: serial port transport, device service scanning, and protocol data-parser layer.
- `src/pygui/ISC/`: the `ISC` QML module (UI). - `src/pygui/ISC/`: the `ISC` QML module (UI).
- `Main.qml`: top-level window definition. - `Main.qml`: top-level window definition.
- `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs). - `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
@@ -43,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`:
@@ -71,11 +179,47 @@ Window {
## Troubleshooting ## Troubleshooting
- If the app does not start, verify the venv is active and dependencies are installed: - If the app does not start, verify the virtual environment is active and dependencies are installed:
*Using `uv`:*
```bash ```bash
source .venv/bin/activate uv sync
```
*Using `pip`:*
```bash
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt pip install -r requirements.txt
``` ```
- 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:**
- 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 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`**.
![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png)
#### Step 2: Configure and Start the Wafer Simulator
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
1. Set the **Serial Port** to **`COM5`**.
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
4. Click **Start Simulator**. Once the client connects, you will see `Streaming (D2)` status and active data transfer logs.
![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png)
#### 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).
![ISenseCloud UI displaying successful connection on COM6](image-1.png)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

+47 -3
View File
@@ -7,11 +7,18 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pygui" name = "pygui"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.11"
dependencies = [ dependencies = [
"pyyaml>=6.0.3", "PySide6>=6.6.0",
"scipy>=1.17.1", "pyqtgraph>=0.13.0",
"numpy>=1.24.0",
"pyserial>=3.5",
"pandas>=2.0.0", "pandas>=2.0.0",
"matplotlib>=3.7.0", "matplotlib>=3.7.0",
"scipy>=1.17.1",
"pyyaml>=6.0.3",
"dtw-python",
"cryptography>=41.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -43,7 +50,7 @@ files = [
"src/pygui/ISC/Tabs/qmldir", "src/pygui/ISC/Tabs/qmldir",
"src/pygui/__main__.py", "src/pygui/__main__.py",
"src/pygui/backend/contour_models.py", "src/pygui/backend/contour_models.py",
"src/pygui/backend/crypto_helper.py", "src/pygui/backend/crypto/crypto_helper.py",
"src/pygui/backend/csv_file_metadata.py", "src/pygui/backend/csv_file_metadata.py",
"src/pygui/backend/data_model.py", "src/pygui/backend/data_model.py",
"src/pygui/backend/data_segment.py", "src/pygui/backend/data_segment.py",
@@ -83,4 +90,41 @@ files = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=9.0.3", "pytest>=9.0.3",
"ruff",
"mypy",
] ]
[tool.uv]
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'win32'",
"sys_platform == 'linux'",
]
# ===== Ruff Linter and Formatter =====
[tool.ruff]
target-version = "py311"
line-length = 125
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"N", # pep8-naming
]
ignore = [
"N802", # Function name should be lowercase (standard for Qt Slots and Properties)
"N815", # Class attribute should not be mixedCase (standard for Qt Properties and Signals)
"E702", # Multiple statements on one line (semicolon, standard for inline Qt property setters with self.update())
]
# ===== Mypy Static Type Checker =====
[tool.mypy]
python_version = "3.11"
check_untyped_defs = true
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
+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
}
}
}
}
}
+613 -177
View File
@@ -1,9 +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
// ===== 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
@@ -12,217 +17,648 @@ Rectangle {
border.color: Theme.outerFrameBorder border.color: Theme.outerFrameBorder
border.width: Theme.borderStrong border.width: Theme.borderStrong
// ===== Navigation Model =====
// Primary navigation shown in the left rail.
property var sideActions: ["DETECT WAFER", "READ MEMORY", "OPEN CSV IN EXCEL", "ERASE MEMORY", "IMPORT DATA", "STORED DATA"]
// ===== Footer Tab Model =====
// Footer tabs drive the active workspace section.
property var bottomTabs: ["Status", "Graph", "Data", "Wafer Map", "Compare", "Split", "Settings", "About"]
// ===== View State ===== // ===== View State =====
property int selectedTabIndex: 0 property int selectedTabIndex: 0
property int selectedSideActionIndex: -1 // nothing active on startup 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")
}
}
// ===== Main Two-Column Layout =====
RowLayout {
anchors.fill: parent
spacing: 0
// ===== Left Action Rail =====
// Left control rail.
Rectangle {
id: sideRail
Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true
color: Theme.sideRailBackground
border.color: Theme.workspaceBorder
border.width: Theme.borderThin
readonly property int actionCount: root.sideActions.length property bool memoryRead: false
readonly property real computedButtonHeight: Math.min(Theme.sideButtonHeight, (height - (Theme.panelPadding * 2) - (Theme.sideRailSpacing * Math.max(0, actionCount - 1))) / Math.max(1, actionCount)) 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.
ColumnLayout { Connections {
anchors.fill: parent target: deviceController
anchors.margins: Theme.panelPadding function onDetectResult(result){
spacing: Theme.sideRailSpacing // detectResult payload is Signal(object): a dict may cross into QML
// as an opaque wrapper whose fields read as undefined, so only
Repeater { // test truthiness (None on failure, dict on success).
model: root.sideActions root.waferDetected = !!result
}
Button { function onParsedDataReady(result) {
id: control if (result.success && result.csv_path) {
text: modelData streamController.loadFile(result.csv_path)
property bool isActive: index === root.selectedSideActionIndex // TODO P3.1: only auto-jump to Map when unlocked:
Layout.fillWidth: true // `&& licenseModel.licenses.length > 0` — otherwise a
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight) // successful read walks straight into the locked tab.
hoverEnabled: true // See plan §3.1.
onClicked: { if (root.selectedTabIndex === 0) {
root.selectedSideActionIndex = index root.selectedTabIndex = 2
root.selectedTabIndex = 0 // always jump to Status tab
if (index === 0) deviceController.detectWafer()
else if (index === 1) deviceController.readMemoryAsync()
else if (index === 2) deviceController.openCsvFile()
}
background: Rectangle {
color: {
if (control.down) {
return Theme.buttonPressed;
}
if (control.isActive) {
return Theme.sideActiveBackground;
}
return "transparent";
}
border.color: control.isActive ? Theme.cardBorder : "transparent"
border.width: 1
radius: Theme.radiusSm
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 3
visible: control.isActive
color: Theme.primaryAccent
radius: Theme.radiusXs
}
}
contentItem: Text {
text: control.text
color: control.isActive ? Theme.headingColor : Theme.bodyColor
font.bold: control.isActive
font.pixelSize: 18
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
} }
} }
} }
}
Connections {
target: deviceController
function onReadResult(result) {
root.memoryRead = (result !== null &&
result !== undefined &&
result.success === true)
if (root.memoryRead) {
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
} else {
console.log("[P1.1] Read failed:", result.error || "unknown")
}
}
}
// Main workspace and footer navigation. function cleanFolderUrl(url) {
Rectangle { return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
Layout.fillWidth: true }
Layout.fillHeight: true
color: Theme.workspaceBackground function _doDetect() {
border.color: Theme.workspaceBorder root.memoryRead = false
border.width: Theme.borderThin root.waferDetected = false
streamController.setMode("review")
streamController.stopStream()
deviceController.detectWafer()
}
FolderDialog {
id: saveDirDialog
title: "Choose a folder to save Data"
onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder)
deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir)
root._doDetect()
}
}
// ===== Split Dialog (Threshold Segmentation) =====
SplitDialog {
id: splitDialog
}
// ===== Settings Popup =====
Popup {
id: settingsPopup
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: Math.min(root.width - 80, 800)
height: Math.min(root.height - 80, 600)
anchors.centerIn: Overlay.overlay
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
contentItem: Rectangle {
color: Theme.pageBackground
radius: Theme.radiusMd
clip: true
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.mainAreaPadding spacing: 0
spacing: Theme.rightPaneGap
// ===== Active Tab Content Area =====
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.preferredHeight: 40
Layout.minimumHeight: 220 color: Theme.panelBackground
color: Theme.responseBackground border.color: Theme.cardBorder
border.color: Theme.responseBorder
border.width: 1 border.width: 1
radius: Theme.radiusMd radius: Theme.radiusMd
StackLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
currentIndex: root.selectedTabIndex anchors.leftMargin: 14
anchors.rightMargin: 4
// ===== Tab Content Routing ===== Label {
Repeater { text: "Settings"
model: root.bottomTabs font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Item { Button {
property string tabName: modelData text: "\u2715"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: settingsPopup.close()
Loader { background: Rectangle {
anchors.fill: parent radius: Theme.radiusXs
active: parent.tabName === "Settings" color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
source: parent.tabName === "Settings" ? "Tabs/SettingsTab.qml" : "" }
} contentItem: Label {
text: parent.text
Loader { color: Theme.bodyColor
anchors.fill: parent horizontalAlignment: Text.AlignHCenter
active: parent.tabName === "Status" verticalAlignment: Text.AlignVCenter
source: parent.tabName === "Status" ? "Tabs/StatusTab.qml" : ""
}
Loader {
anchors.fill: parent
active: parent.tabName === "Data"
source: parent.tabName === "Data" ? "Tabs/DataTab.qml" : ""
}
Label {
anchors.centerIn: parent
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map"
text: parent.tabName + " content"
color: Theme.bodyColor
font.pixelSize: 20
}
Loader{
anchors.fill: parent
active: parent.tabName === "Wafer Map"
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
}
} }
} }
} }
} }
// Keep the tab strip evenly distributed across the footer. Item {
// ===== Footer Tab Strip =====
Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.tabBarHeight + (Theme.tabBarPadding * 2) Layout.fillHeight: true
color: Theme.tabBarBackground Layout.rightMargin: 6
border.color: Theme.workspaceBorder clip: true
border.width: Theme.borderThin
RowLayout { Loader {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.tabBarPadding source: "Tabs/SettingsTab.qml"
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
}
}
}
} }
} }
} }
} }
} }
// ===== About Dialog =====
AboutDialog {
id: aboutDialog
}
// ===== Main Layout: Rail + Workspace =====
RowLayout {
anchors.fill: parent
spacing: 0
// ── LEFT RAIL ──────────────────────────────────────────────────────
Rectangle {
id: rail
Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true
color: Theme.sideRailBackground
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.sideRailMargin
spacing: Theme.sideRailSpacing
// ── BOX 1: Pill Tab Bar ─────────────────────────────────────
Rectangle {
id: pillBar
Layout.fillWidth: true
Layout.preferredHeight: Theme.sidePillHeight
radius: 999
border.color: Theme.sideBorder
border.width: 1
color: "transparent"
ListModel {
id: pillModel
ListElement {
label: "STATUS"
icon: "Tabs/icons/status.svg"
expandW: 86
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
}
ListElement {
label: "DATA"
icon: "Tabs/icons/data.svg"
expandW: 66
desc: "Manage, review, compare (DTW), and split historical sensor data records."
}
ListElement {
label: "MAP"
icon: "Tabs/icons/map.svg"
expandW: 58
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
}
ListElement {
label: "GRAPH"
icon: "Tabs/icons/bar-chart.svg"
expandW: 70
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
}
}
RowLayout {
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 {
radius: 50
color: pillBtn.checked ? Theme.sidePanelBackground : "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
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 }
}
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
color: Theme.sidePanelBackground
readonly property var dataTab: dataTabLoader.item
component RunSlot: Rectangle {
id: slotBox
property string title
property string filePath: ""
property color accent: Theme.primaryAccent
property bool active: false
property bool locked: false
property string placeholderText: "Click to select"
signal cleared()
signal armed()
Layout.fillWidth: true
implicitHeight: 58
radius: Theme.radiusSm
color: Theme.fieldBackground
opacity: slotBox.locked ? 0.65 : 1.0
border.width: slotBox.active ? 2 : 1
border.color: slotBox.active ? accent : Theme.sideBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent
enabled: !slotBox.locked
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: slotBox.armed()
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Rectangle {
width: 8; height: 8; radius: 4
color: slotBox.accent
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: slotBox.title
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.0
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
}
Label {
text: slotBox.filePath !== ""
? slotBox.filePath.split("/").pop()
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
font.pixelSize: Theme.fontSm
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
Button {
visible: slotBox.filePath !== "" && !slotBox.locked
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: slotBox.cleared()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
anchors.centerIn: parent
}
}
}
}
ColumnLayout {
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
}
}
}
}
}
// ── 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 {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.workspaceBackground
StackLayout {
anchors.fill: parent
anchors.margins: Theme.mainAreaPadding
currentIndex: root.selectedTabIndex
Loader {
source: "Tabs/StatusTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
Loader {
id: dataTabLoader
source: "Tabs/DataTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
Loader {
source: "Tabs/WaferMapTab.qml"
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
// so the tab never instantiates while locked. See plan §3.1.
active: StackLayout.index === root.selectedTabIndex
}
Loader {
source: "Tabs/GraphTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
}
}
}
} }
+2 -2
View File
@@ -4,8 +4,8 @@ import ISC
// ===== App Window Shell ===== // ===== App Window Shell =====
Window { Window {
// ===== Window Dimensions ===== // ===== Window Dimensions =====
width: 1400 width: 1920
height: 820 height: 1080
minimumWidth: 1100 minimumWidth: 1100
minimumHeight: 700 minimumHeight: 700
visible: true visible: true
+330 -108
View File
@@ -1,151 +1,373 @@
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 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 {
// ── Error Banner ───────────────────────────────────────────
Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.rightPaneGap Layout.preferredHeight: errorRow.implicitHeight + 16
visible: root.errorMessage !== ""
color: Theme.errorSurface
border.color: Theme.statusErrorColor
border.width: 1
radius: Theme.radiusSm
Label { RowLayout {
text: "Temperature Data" id: errorRow
font.pixelSize: 18 anchors.fill: parent
font.bold: true anchors.margins: 8
color: Theme.headingColor spacing: 8
}
Item { Layout.fillWidth: true } IconImage {
source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
}
Label { Label {
text: deviceController.dataRowCount + " rows × " + id: errorLabel
deviceController.dataColCount + " sensors" text: root.errorMessage
font.pixelSize: 13 color: Theme.errorTextSoft
color: Theme.bodyColor font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
} }
} }
// --- Table container --- // ── Body: main column | side panel ─────────────────────────
Rectangle { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.panelBackground spacing: 16
border.color: Theme.cardBorder
border.width: Theme.borderThin
radius: Theme.radiusSm
clip: true
// --- Left Column: chart + scrubber, wafer overlap map ---
ColumnLayout { ColumnLayout {
anchors.fill: parent Layout.fillWidth: true
anchors.margins: 1 Layout.fillHeight: true
spacing: 0 Layout.minimumWidth: 350
spacing: 12
// Column header strip // Wafer Map Overlap View card
HorizontalHeaderView { OverlapMapCard {
id: headerView
Layout.fillWidth: true
syncView: dataTable
clip: true
delegate: Rectangle {
implicitHeight: 28
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
Text {
anchors.centerIn: parent
// 'display' is the Qt.DisplayRole value from headerData()
text: display !== undefined ? display : ""
color: Theme.headingColor
font.pixelSize: 11
font.bold: true
elide: Text.ElideRight
}
}
}
// Data rows
TableView {
id: dataTable
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
model: deviceController.dataModel Layout.minimumHeight: 220
clip: true sensorLayout: root.compareSensorLayout
reuseItems: true diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
diffBands: root.diffBands
waferShape: root.compareWaferShape
waferSize: root.compareWaferSize
overlapEnabled: sidePanel.overlapEnabled
blendAmount: sidePanel.blendAmount
}
columnWidthProvider: function(col) { // Chart card with alignment scrubber
if (col === 0) return 52 // row index column ComparisonChartCard {
const sensorCols = Math.max(1, dataTable.columns - 1) id: chartCard
const available = dataTable.width - 52 Layout.fillWidth: true
return Math.max(52, Math.min(90, available / sensorCols)) trendDataA: root.trendDataA
} trendDataB: root.trendDataB
seriesLen: root.seriesLen
hasSeries: root.hasSeries
comparing: root.comparing
}
}
rowHeightProvider: function() { return 24 } // --- Right Column: run selection, overlap settings, readout ---
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()
}
}
}
delegate: Rectangle { // ── Frame Mismatch Banner (amber, floats over content, auto-hides) ──
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground 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
Text { RowLayout {
anchors.centerIn: parent id: mismatchRow
text: display !== undefined ? display : "" anchors.fill: parent
color: Theme.bodyColor anchors.margins: 8
font.pixelSize: 11 spacing: 8
elide: Text.ElideRight
}
}
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded } IconImage {
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } 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
} }
} }
} }
+49
View File
@@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
// ===== Graph Tab =====
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
// snapshot recomputed whenever a file is loaded — not live-updating during
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
Item {
id: root
anchors.fill: parent
function refresh() {
if (!streamController.loadedFile) {
chart.seriesData = []
chart.sensorNames = []
return
}
chart.sensorNames = streamController.graphSensorNames
? streamController.graphSensorNames.split(",") : []
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
}
Connections {
target: streamController
function onLoadedFileChanged() { root.refresh() }
}
Component.onCompleted: root.refresh()
Rectangle {
anchors.fill: parent
anchors.margins: Theme.panelPadding
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
RunChart {
id: chart
anchors.fill: parent
anchors.margins: 8
title: "Sensor Temperature Over Time"
}
}
}
+70 -25
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
} }
@@ -225,7 +244,7 @@ Item {
Layout.preferredWidth: 90 Layout.preferredWidth: 90
Layout.preferredHeight: Theme.settingsButtonHeight Layout.preferredHeight: Theme.settingsButtonHeight
onClicked: { onClicked: {
settingsModel.setChamberId(chamberField.text); settingsModel.ChamberId = chamberField.text;
settingsModel.saveSettings(); settingsModel.saveSettings();
} }
} }
@@ -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
@@ -296,7 +328,7 @@ Item {
SettingsToggle { SettingsToggle {
text: "Reverse Z Wafer" text: "Reverse Z Wafer"
checked: settingsModel.reverseZWafer checked: settingsModel.reverseZWafer
onToggled: settingsModel.setReverseZWafer(checked) onToggled: settingsModel.reverseZWafer = checked
} }
} }
} }
@@ -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
}
} }
} }
} }
+552 -203
View File
@@ -1,62 +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 ISC import ISC
// ===== Status Tab ===== // ===== Status Tab — Bento Grid Dashboard =====
// Blank on startup. Fills in after Detect Wafer fires from the left rail. // ROW 1: Connection Status (65%) | Cycles Completed (35%)
// 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
// Latches true once any operation starts (or a previous session is Layout.fillWidth: true
// restored) and never resets, so the status panel stays visible even Layout.fillHeight: true
// when a detect finds no wafer. color: Theme.cardBackground
property bool statusActive: false 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
// ===== Empty state — nothing shown until detect fires ===== readonly property bool isConnected: deviceController.connectionStatus === "Connected"
Item { readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
Layout.fillWidth: true readonly property string portName: deviceController.selectedPort || "—"
Layout.fillHeight: true
visible: !root.statusActive function cleanFolderUrl(url) {
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
}
function currentFamilyCode() {
var info = deviceController.lastWaferInfo;
return info && info.length > 0 ? (info[0] || "") : "";
}
function parseAndSavePendingRead() {
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "");
} }
// ===== Results area (shown once detect/operation runs) ===== FolderDialog {
ColumnLayout { id: saveDirDialog
Layout.fillWidth: true title: "Choose a folder to save."
Layout.fillHeight: true onAccepted: {
visible: root.statusActive var dir = root.cleanFolderUrl(selectedFolder);
spacing: Theme.rightPaneGap deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
root.parseAndSavePendingRead();
}
}
// --- Connection Status --- // 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 {
anchors.fill: parent
spacing: 6
Text {
text: root.csvPath.split("/").pop()
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Label { text: "Wafer" }
TextField { id: metaWafer; Layout.fillWidth: true }
Label { text: "Date" }
TextField { id: metaDate; Layout.fillWidth: true }
Label { text: "Chamber" }
TextField { id: metaChamber; Layout.fillWidth: true }
Label { text: "Notes" }
TextField { id: metaNotes; Layout.fillWidth: true }
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROWS 1-2 (3-column bento):
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
// [Directory (span 2) ] [ (rowspan) ]
// ═══════════════════════════════════════════════════════════════════════
GridLayout {
Layout.fillWidth: true
Layout.preferredHeight: 188
Layout.maximumHeight: 188
columns: 3
rowSpacing: 8
columnSpacing: 8
// ── Connection Status: two cells wide, top-left ──
Rectangle { Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 86 Layout.fillHeight: true
color: { color: Theme.cardBackground
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor + "22" border.color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor + "22" border.width: 1
return Theme.cardBackground radius: Theme.radiusSm
}
border.color: { ColumnLayout {
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor anchors.fill: parent
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor anchors.margins: Theme.panelPadding
return Theme.cardBorder 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 {
@@ -65,212 +309,310 @@ 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
Layout.bottomMargin: 2
}
GridLayout {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 6
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
} }
font.bold: true
font.pixelSize: 16
Layout.fillWidth: true
}
Text { Rectangle {
text: "Port: " + (deviceController.selectedPort || "None selected") Layout.columnSpan: 2
color: Theme.bodyColor Layout.fillWidth: true
font.pixelSize: 14 Layout.preferredHeight: 1
Layout.fillWidth: true color: Theme.cardBorder
} opacity: 0.5
}
Text { GridCell {
text: "Save dir: " + deviceController.saveDataDir id: waferCyclesCell
color: Theme.bodyColor label: "Cycles Completed"
font.pixelSize: 12 value: {
opacity: 0.7 var info = deviceController.lastWaferInfo;
elide: Text.ElideMiddle return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
Layout.fillWidth: true }
active: root.waferDetected
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
}
} }
} }
} }
// --- Wafer Info --- // ── Directory: bottom-left cell ──
GroupBox { Rectangle {
title: "Wafer Information"
visible: root.waferDetected
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: 8 spacing: 6
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.rightPaneGap spacing: 8
ColumnLayout { Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
Layout.fillWidth: true 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 { Rectangle {
Layout.fillWidth: true visible: root.csvPath !== ""
spacing: 4 implicitWidth: sessionSavedLabel.implicitWidth + 12
Text { text: "Serial Number"; color: Theme.subheadingColor; font.pixelSize: 12 } implicitHeight: 18
Text { id: waferSerial; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true } radius: 4
} color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { Text {
Layout.fillWidth: true id: sessionSavedLabel
spacing: 4 anchors.centerIn: parent
Text { text: "Sensor Count"; color: Theme.subheadingColor; font.pixelSize: 12 } text: "SESSION SAVED"
Text { id: waferSensors; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true } color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
}
} }
} }
Item { Layout.fillHeight: true }
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.rightPaneGap spacing: 12
ColumnLayout { // Path pinned bottom-left
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true 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 { Button {
Layout.fillWidth: true id: browseBtn
spacing: 4 text: "Select Folder"
Text { text: "Cycles"; color: Theme.subheadingColor; font.pixelSize: 12 } font.pixelSize: Theme.fontSm
Text { id: waferCycles; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true } font.weight: Font.Medium
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: browseBtn.text
color: Theme.bodyColor
font: browseBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: dirOnlyDialog.open()
} }
} }
} }
} }
// --- Data Summary (shown after parse) --- // ── Total Runtime: bottom center ──
GroupBox { StatCard {
title: "Data Summary" id: runtimeCard
visible: root.dataParsed label: "TOTAL RUNTIME"
Layout.fillWidth: true value: {
var info = deviceController.lastWaferInfo;
RowLayout { 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: Theme.rightPaneGap var m = Math.floor(secs / 60);
var s = Math.round(secs % 60);
ColumnLayout { return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
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 ---
GroupBox {
title: "Activity Log"
Layout.fillWidth: true
Layout.fillHeight: true
ScrollView {
anchors.fill: parent
clip: true
TextArea {
id: activityLog
width: parent.width
text: ""
readOnly: true
font.family: "monospace"
font.pixelSize: 12
wrapMode: TextArea.WordWrap
leftPadding: 4
rightPadding: 4
color: Theme.bodyColor
}
}
Connections {
target: deviceController
function onActivityLogUpdated(newLog) {
activityLog.text = newLog
}
} }
} }
} }
// --- Signal Handlers --- // ═══════════════════════════════════════════════════════════════════════
Connections { // ROW 3: Activity Log — ALWAYS visible (fills remaining height)
target: deviceController // ═══════════════════════════════════════════════════════════════════════
// Any operation start (detect/read/erase/debug) latches the panel on. Rectangle {
function onPortsUpdated() { Layout.fillWidth: true
if (deviceController.operationInProgress) Layout.fillHeight: true
root.statusActive = true color: Theme.cardBackground
} border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
function onDetectResult(result) { ColumnLayout {
root.statusActive = true anchors.fill: parent
if (result && result.familyCode) { spacing: 0
root.waferDetected = true
waferInfoFamily.text = result.familyCode
waferSerial.text = result.serialNumber
waferSensors.text = String(result.sensorCount)
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() { Rectangle {
// Show restored wafer info if available Layout.fillWidth: true
var info = deviceController.lastWaferInfo Layout.preferredHeight: 36
if (info && info.length > 0) { color: Theme.subtleSectionBackground
root.statusActive = true radius: Theme.radiusMd
root.waferDetected = true
waferInfoFamily.text = info[0] || "—" // Cover bottom corners so only top-left and top-right are rounded
waferSerial.text = info[1] || "—" Rectangle {
waferSensors.text = String(info[2] || 0) anchors.bottom: parent.bottom
waferRuntime.text = (info[3] || 0) + "s" anchors.left: parent.left
waferCycles.text = String(info[4] || 0) anchors.right: parent.right
} height: parent.radius
// Show data summary if data was previously parsed color: parent.color
if (deviceController.dataRowCount > 0) { }
root.statusActive = true
root.dataParsed = true RowLayout {
root.dataRows = deviceController.dataRowCount anchors.fill: parent
root.dataCols = deviceController.dataColCount anchors.leftMargin: Theme.panelPadding
root.csvPath = "" // CSV path not persisted as full path, just show it was parsed anchors.rightMargin: Theme.panelPadding
} spacing: 8
}
} IconImage {
source: "icons/file-text.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.sideMutedText
visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter
}
Label {
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
font.family: Theme.uiFontFamily
elide: Text.ElideMiddle
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: Theme.cardBorder
}
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: Theme.panelPadding
clip: true
TextArea {
id: activityLog
width: parent.width
// 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
font.family: "monospace"
font.pixelSize: Theme.fontSm
wrapMode: TextArea.WordWrap
color: Theme.bodyColor
background: null
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// SIGNAL HANDLERS
// ═══════════════════════════════════════════════════════════════════════
Connections {
target: deviceController
function onLogMessage(message) {
if (message == "CHOOSE_SAVE_DIR") {
saveDirDialog.open();
}
}
function onPortsUpdated() {}
function onDetectResult(result) {
if (!result) {
root.dataParsed = false;
root.dataRows = 0;
root.dataCols = 0;
root.csvPath = "";
}
}
function onStatusRestored() {
if (deviceController.dataRowCount > 0) {
root.dataParsed = true;
root.dataRows = deviceController.dataRowCount;
root.dataCols = deviceController.dataColCount;
root.csvPath = "";
}
}
}
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)
saveDirDialog.open();
} }
} }
@@ -278,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;
} }
} }
} }
+126 -160
View File
@@ -1,27 +1,36 @@
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 ISC.Tabs.components
import ISC.Wafer
Item { 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++ Connections {
onRunningChanged: if (!running) root._liveSecs = 0 target: streamController
} function onTrendDelta(deltaJson) {
function fmtTime(s) { trendChart.appendDelta(deltaJson);
var m = Math.floor(s / 60) }
var ss = s % 60 function onTrendReset() {
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss trendChart.clearTrend();
}
function onLoadedFileChanged() {
root.loadErrorMessage = "";
}
function onLoadFileError(message) {
root.loadErrorMessage = message;
}
} }
ColumnLayout { ColumnLayout {
@@ -34,91 +43,15 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10 spacing: 10
// Mode toggle Item {
TabBar { Layout.fillWidth: true
id: modeBar
currentIndex: 1 // 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: "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
}
}
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
}
}
onCurrentIndexChanged:
streamController.setMode(currentIndex === 0 ? "live" : "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
@@ -126,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
} }
@@ -140,116 +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
} }
} }
}
// ── Load error banner ───────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorRow.implicitHeight + 16
visible: root.loadErrorMessage !== ""
color: Theme.errorSurface
border.color: Theme.statusErrorColor
border.width: 1
radius: Theme.radiusSm
// LIVE timer
RowLayout { RowLayout {
spacing: 5 id: errorRow
visible: streamController.mode === "live" && streamController.state !== "idle" anchors.fill: parent
Rectangle { anchors.margins: 8
width: 7; height: 7; radius: 4 spacing: 8
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) IconImage {
Label { source: "icons/triangle-alert.svg"
visible: streamController.mode === "review" || streamController.state === "idle" width: 16; height: 16
text: streamController.state.toUpperCase() sourceSize.width: 16
color: Theme.bodyColor sourceSize.height: 16
font.pixelSize: 11 color: Theme.errorTextSoft
font.weight: Font.Medium Layout.alignment: Qt.AlignTop
font.letterSpacing: 1.2 }
Label {
text: root.loadErrorMessage
color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
} }
} }
// ── Body: 3 zones ───────────────────────────────────────────────── // ── Body: 2 columns (wafer + trend + transport | readout) ──────────
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 {
// Horizontal separation line with vertical padding id: trendPane
Item {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 32 Layout.fillHeight: false
Rectangle { Layout.preferredHeight: 220
anchors.centerIn: parent Layout.minimumHeight: 120
width: parent.width visible: trendChart.hasData && streamController.mode === "live"
height: 1 color: Theme.cardBackground
color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 10
TrendChartItem {
id: trendChart
Layout.fillWidth: true
Layout.fillHeight: true
}
} }
} }
TransportBar { Layout.fillWidth: true } Item {
Layout.fillWidth: true
visible: transportBar.hasContent
implicitHeight: trendPane.visible ? 52 : 16
}
TransportBar {
id: transportBar
Layout.fillWidth: true
}
} }
// Readout panel — bordered card // Readout panel — scrolls instead of clipping so the last card
Rectangle { // (Thresholds) is always reachable even if content outgrows
Layout.preferredWidth: 220 // the available height (e.g. once E-C delta rows appear).
ScrollView {
Layout.preferredWidth: Theme.rightRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: 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()
}
}
}
+257 -246
View File
@@ -1,287 +1,298 @@
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 {
Layout.fillWidth: true ReadoutStat {
Label { text: "Min Temp"; color: Theme.bodyColor; font.pixelSize: 11 } label: "Min Temp"
Item { Layout.fillWidth: true } value: s.min !== undefined
RowLayout { ? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
spacing: 4 : "—"
Label { valueColor: Theme.sensorLow
text: s.min !== undefined ? s.min : "—" }
color: Theme.sensorLow ReadoutStat {
font.pixelSize: 13; font.weight: Font.Bold label: "Max Temp"
} value: s.max !== undefined
Label { ? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
text: s.minIndex !== undefined ? "#" + s.minIndex : "" : ""
color: Theme.bodyColor valueColor: Theme.sensorHigh
font.pixelSize: 10 }
} 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.preferredHeight: displayCard.implicitHeight + 24
ColumnLayout {
id: displayCard
anchors.fill: parent
anchors.margins: 12
spacing: 8
SectionTitle { text: "DISPLAY" }
PanelCheckBox {
id: thicknessToggle
text: "Show Thickness"
checked: false
font.pixelSize: Theme.fontSm
// First check with no data prompts for the customer CSV;
// unchecking only hides the overlay, data stays loaded.
onToggled: {
if (checked && !root.hasThicknessData)
thicknessDialog.open();
} }
} }
// Max Temp Row FileDialog {
RowLayout { id: thicknessDialog
Layout.fillWidth: true title: "Select Thickness Data File"
Label { text: "Max Temp"; color: Theme.bodyColor; font.pixelSize: 11 } nameFilters: ["CSV files (*.csv)"]
Item { Layout.fillWidth: true } onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
RowLayout { onRejected: thicknessToggle.checked = false
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 } PanelCheckBox {
id: labelsToggle
text: "Labels"
checked: true
font.pixelSize: Theme.fontSm
}
PanelCheckBox {
id: extremesToggle
text: "Highlight Min/Max"
checked: true
font.pixelSize: Theme.fontSm
}
PanelCheckBox {
id: clusterAverageToggle
text: "Average Clusters"
checked: streamController.clusterAveragingEnabled
font.pixelSize: Theme.fontSm
onCheckedChanged: streamController.clusterAveragingEnabled = checked
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Differential Row
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Label { text: "Differential"; color: Theme.bodyColor; font.pixelSize: 11 } spacing: 6
Item { Layout.fillWidth: true }
Label { Label {
text: s.diff !== undefined ? s.diff : "—" text: "Heatmap"
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 color: Theme.bodyColor
font.pixelSize: 12; font.weight: Font.Medium font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52
}
PanelSlider {
id: heatmapSlider
from: 0
to: 1
value: 0
Layout.fillWidth: true
AppToolTip {
visible: heatmapSlider.hovered
text: Math.round(heatmapSlider.value * 100) + "%"
}
}
Label {
text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight
} }
} }
} }
} }
Item { height: 12 } // ── THRESHOLDS ──────────────────────────────────────────────────
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder } PanelBox {
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 {
id: labelsToggle
text: "Labels"
checked: true
font.pixelSize: 11
}
RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 6 Layout.preferredHeight: thresholdCard.implicitHeight + 24
Label {
text: "Heatmap" ColumnLayout {
color: Theme.bodyColor id: thresholdCard
font.pixelSize: 11 anchors.fill: parent
Layout.preferredWidth: 52 anchors.margins: 12
} spacing: 6
Slider {
id: heatmapSlider SectionTitle { text: "THRESHOLDS" }
from: 0; to: 1; value: 0
Layout.fillWidth: true Label {
ToolTip.visible: hovered text: "Set Point (°C)"
ToolTip.text: Math.round(value * 100) + "%" color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
TextField {
id: spField
Layout.fillWidth: true
text: "149.0"
font.pixelSize: Theme.fontSm
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
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()
}
Label {
text: "Margin (±°C)"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
TextField {
id: mgField
Layout.fillWidth: true
text: "1.0"
font.pixelSize: Theme.fontSm
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
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()
}
PanelCheckBox {
id: autoCheck
text: "Auto range (mean ± 1σ)"
checked: true
font.pixelSize: Theme.fontSm
onCheckedChanged: pushThresholds()
}
} }
} }
Item { height: 12 } Item {
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder } Layout.fillHeight: true
Item { height: 12 }
// ── THRESHOLDS ────────────────────────────────────────────────────────
Label {
text: "THRESHOLDS"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
topPadding: 6
bottomPadding: 8
} }
Label {
text: "Set Point (°C)"
color: Theme.bodyColor
font.pixelSize: 11
bottomPadding: 3
}
TextField {
id: spField
Layout.fillWidth: true
text: "149.0"
font.pixelSize: 12
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
onEditingFinished: pushThresholds()
}
Item { height: 10 }
Label {
text: "Margin (±°C)"
color: Theme.bodyColor
font.pixelSize: 11
bottomPadding: 3
}
TextField {
id: mgField
Layout.fillWidth: true
text: "1.0"
font.pixelSize: 12
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
onEditingFinished: pushThresholds()
}
Item { height: 10 }
PanelCheckBox {
id: autoCheck
text: "Auto range (mean ± 1σ)"
checked: true
font.pixelSize: 11
onCheckedChanged: pushThresholds()
}
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,20 +21,22 @@ 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 {
color: Theme.cardBackground color: Theme.cardBackground
radius: 8 radius: Theme.radiusLg
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: Theme.borderThin
} }
ColumnLayout { ColumnLayout {
@@ -41,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 {
@@ -59,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
} }
@@ -70,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
@@ -91,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
@@ -110,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 {
@@ -139,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 {
@@ -174,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 {
@@ -197,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 ?? "";
} }
@@ -42,7 +45,7 @@ Dialog {
chamberField.text = rec.chamber ?? ""; chamberField.text = rec.chamber ?? "";
if (!chamberField.text && settingsModel.chamberId) if (!chamberField.text && settingsModel.chamberId)
chamberField.text = settingsModel.chamberId; chamberField.text = settingsModel.chamberId;
notesField.text = rec.notes ?? ""; notesField.text = rec.notes ?? "";
filePathLabel.text = rec.fileName ?? ""; filePathLabel.text = rec.fileName ?? "";
metadataEditDialog.open(); metadataEditDialog.open();
} }
@@ -76,7 +79,7 @@ Dialog {
readonly property var normalizedRows: { readonly property var normalizedRows: {
// Touch settingsModel.masters so QML tracks this dependency. // Touch settingsModel.masters so QML tracks this dependency.
const _ = settingsModel.masters; const _ = settingsModel.masters;
return (tableModel || []).map(function(row) { return (tableModel || []).map(function (row) {
row = row || {}; row = row || {};
// settingsModel is the authoritative source; fall back to sidecar masterType. // settingsModel is the authoritative source; fall back to sidecar masterType.
const masterType = root.masterFamilyForPath(row.fileName ?? "") || row.masterType || ""; const masterType = root.masterFamilyForPath(row.fileName ?? "") || row.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,12 +425,12 @@ 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
} }
} }
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: column !== 6 enabled: column !== 6
onClicked: { onClicked: {
@@ -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 {
+213 -87
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,14 +300,57 @@ 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
Label { RowLayout {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor
font.pixelSize: 13
font.weight: Font.Bold
elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
spacing: 6
Label {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
elide: Text.ElideRight
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
@@ -235,24 +359,24 @@ ColumnLayout {
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
}
}
}
}
+268 -87
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"
component TBtn: Button { // card so this bar can collapse to zero height and free vertical space for
implicitWidth: 44 // the trend chart while streaming.
implicitHeight: 32 Item {
hoverEnabled: true id: bar
font.pixelSize: 13 Layout.fillWidth: true
background: Rectangle { implicitHeight: hasContent ? 80 : 0
color: parent.pressed ? Theme.transportButtonHover Behavior on implicitHeight {
: parent.hovered ? Theme.transportButtonBg NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
: Theme.transportBackground
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
} }
clip: true
// Frame counter badge on the left // Only shown in review mode, and only once a file is loaded
Rectangle { readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
id: frameCounter
implicitWidth: frameCounterText.implicitWidth + 16
implicitHeight: 32
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: Theme.borderThin
radius: Theme.radiusSm
Text { // ── REVIEW MODE ──────────────────────────────────────────────
id: frameCounterText Item {
id: reviewContent
anchors.fill: parent
// Single centered pill for frame counter + transport + speed
Rectangle {
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
font.weight: Font.Medium color: Theme.sidePanelBackground
} border.color: Theme.sideBorder
} border.width: 1
// Left fill — centers the button cluster Row {
Item { Layout.fillWidth: true } id: reviewRow
anchors.centerIn: parent
spacing: 24
TBtn { text: "⏮"; onClicked: streamController.step(-1) } // Frame counter
TBtn { text: "⏹"; onClicked: streamController.stop() } Row {
TBtn { text: "⏸"; onClicked: streamController.pause() } spacing: 6
// Play: slightly wider; leftPadding nudge compensates for ▶ optical offset anchors.verticalCenter: parent.verticalCenter
TBtn {
text: "▶"
implicitWidth: 52
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: 2
}
onClicked: streamController.play()
}
TBtn { text: "⏭"; onClicked: streamController.step(1) }
// Right fill — balances centering Label {
Item { Layout.fillWidth: true } text: "Frame"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
// Speed cycle TextField {
Button { id: frameInput
id: speedBtn text: String(streamController.frameIndex + 1)
property var speeds: [1, 2, 5] font.pixelSize: Theme.fontMd
property int idx: 0 font.weight: Font.Medium
implicitWidth: 44 font.family: Theme.codeFontFamily
implicitHeight: 32 color: Theme.headingColor
hoverEnabled: true verticalAlignment: TextInput.AlignVCenter
text: speeds[idx] + "×" horizontalAlignment: TextInput.AlignHCenter
font.pixelSize: 11 selectByMouse: true
font.weight: Font.Medium inputMethodHints: Qt.ImhDigitsOnly
background: Rectangle { padding: 0
color: parent.pressed ? Theme.transportButtonHover width: Math.max(44, contentWidth + 16)
: parent.hovered ? Theme.transportButtonBg height: 28
: Theme.transportBackground anchors.verticalCenter: parent.verticalCenter
radius: Theme.radiusSm
} background: Rectangle {
contentItem: Text { color: Theme.fieldBackground
text: parent.text radius: Theme.radiusXs
color: Theme.bodyColor border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
font: parent.font border.width: 1
horizontalAlignment: Text.AlignHCenter }
verticalAlignment: Text.AlignVCenter
} onEditingFinished: {
onClicked: { var val = parseInt(text)
idx = (idx + 1) % speeds.length if (!isNaN(val)) {
streamController.setSpeed(speeds[idx]) 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 {
id: speedBtn
anchors.verticalCenter: parent.verticalCenter
property var speeds: [1, 2, 4]
property int idx: 0
implicitWidth: 72
implicitHeight: 48
hoverEnabled: true
text: speeds[idx] + "x"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
background: Rectangle {
radius: 8
color: speedBtn.pressed ? Theme.transportButtonHover
: Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: {
idx = (idx + 1) % speeds.length;
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
}
}
}
}
}
+42 -11
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
@@ -15,29 +17,58 @@ Item {
sensors: streamController.sensorLayout // [{label,x,y}] sensors: streamController.sensorLayout // [{label,x,y}]
values: streamController.sensorValues // [float] values: streamController.sensorValues // [float]
bands: streamController.sensorBands // ["in_range"|"high"|"low"] bands: streamController.sensorBands // ["in_range"|"high"|"low"]
shape: streamController.waferShape
size: streamController.waferSize
target: streamController.target target: streamController.target
margin: streamController.margin margin: streamController.margin
blend: root.blend blend: root.blend
showLabels: root.showLabels showLabels: root.showLabels
// Bind to Theme so colors update on dark/light mode switch // Bind to Theme so colors update on dark/light mode switch
ringColor: Theme.waferRingColor ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange inRangeColor: Theme.sensorInRange
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, 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
+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

-1
View File
@@ -5,4 +5,3 @@ WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml SettingsTab 1.0 SettingsTab.qml
StatusTab 1.0 StatusTab.qml StatusTab 1.0 StatusTab.qml
SelectFileDialog 1.0 SelectFileDialog.qml
+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
+52 -8
View File
@@ -1,4 +1,6 @@
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
@@ -6,15 +8,31 @@ from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtWidgets import QApplication 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.data.constants import default_data_dir
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.data.file_browser import FileBrowser from pygui.backend.license.license_model import LicenseModel
from pygui.backend.controllers.session_controller import SessionController
# Importing wafer_map_item registers the @QmlElement WaferMapItem (QML: import ISC.Wafer). # Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
from pygui.backend.visualization.wafer_map_item import WaferMapItem 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 =====
def main() -> int: def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
# ===== UI Style Setup ===== # ===== UI Style Setup =====
# Use a non-native controls style so our custom QML button backgrounds are supported. # Use a non-native controls style so our custom QML button backgrounds are supported.
QQuickStyle.setStyle("Basic") QQuickStyle.setStyle("Basic")
@@ -24,19 +42,45 @@ 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) =====
stream_controller = SessionController() stream_controller = SessionController()
engine.rootContext().setContextProperty("streamController", stream_controller) engine.rootContext().setContextProperty("streamController", stream_controller)
-32
View File
@@ -1,32 +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.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.session_model import SessionModel # noqa: F401
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
from pygui.backend.data.data_records import read_data_records # noqa: F401
from pygui.backend.data.data_segment import DataSegment # 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.visualization.wafer_map_item import WaferMapItem # 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.contour_models import ContourLine, ContourSegment # noqa: F401
from pygui.backend.wafer.zwafer_models import ZWaferData, Sensor, DataRecord # noqa: F401
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
from pygui.backend.wafer.wafer_layouts import load_layout, available_families # noqa: F401
@@ -1,8 +1,8 @@
from typing import List, Tuple, Optional from typing import List, Optional, Tuple
import numpy as np import numpy as np
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment from pygui.backend.attic.contour_models import ContourLine, ContourSegment
# ===== Contour Generation ===== # ===== Contour Generation =====
+62
View File
@@ -0,0 +1,62 @@
"""Cluster Average utility for wafer sensor values."""
from __future__ import annotations
import math
from pygui.backend.wafer.zwafer_models import Sensor
def average_clusters(values: list[float], clusters: list[list[int]]) -> list[float]:
"""Return a new list of values where members of each cluster are replaced by their mean.
NaN values are filtered out before computing the mean.
If all values in a cluster are NaN, they remain NaN.
"""
result = list(values)
for cluster in clusters:
valid_vals = []
for idx in cluster:
if idx < len(values):
val = values[idx]
if not math.isnan(val):
valid_vals.append(val)
if valid_vals:
mean_val = sum(valid_vals) / len(valid_vals)
for idx in cluster:
if idx < len(result):
result[idx] = mean_val
return result
def group_sensors_by_radius(sensors: list[Sensor], tolerance: float = 2.0) -> list[list[int]]:
"""Group sensor indices into clusters if their radial distances are within tolerance."""
# Compute radii for all sensors
radii = [(i, math.hypot(s.x, s.y)) for i, s in enumerate(sensors)]
# Filter out center sensors (r < 1.0)
non_center = [item for item in radii if item[1] >= 1.0]
if not non_center:
return []
# Sort by radius
non_center.sort(key=lambda x: x[1])
clusters: list[list[int]] = []
current_cluster: list[int] = [non_center[0][0]]
last_r = non_center[0][1]
for idx, r in non_center[1:]:
if r - last_r <= tolerance:
current_cluster.append(idx)
else:
if len(current_cluster) > 1:
clusters.append(current_cluster)
current_cluster = [idx]
last_r = r
if len(current_cluster) > 1:
clusters.append(current_cluster)
return clusters
+35
View File
@@ -0,0 +1,35 @@
"""Dynamic Time Warping comparision between two temperature runs."""
import numpy as np
from dtw import dtw
def compare_runs(series_a: list[float], series_b: list[float]) -> dict:
"""Compute DTW alignment between 2 average-temperature series.
Returns:
dict with keys:
- distance: float - warping distance
- index_a: list[int] - warping path indices into series_a
- index_b: list[int] - warping path indices into series_b
- warping_path: list[tuple[int,int]] - paired indices
"""
x = np.array(series_a, dtype=float)
y = np.array(series_b, dtype=float)
# Filter out NaN/Inf values so dtw doesn't choke on them.
mask_x = np.isfinite(x)
mask_y = np.isfinite(y)
if not mask_x.any() or not mask_y.any():
return {"distance": float('inf'), "index_a": [], "index_b": [], "warping_path": []}
x = x[mask_x]
y = y[mask_y]
alignment = dtw(x, y, keep_internals=True)
return {
"distance": float(alignment.distance),
"index_a": alignment.index1.tolist(),
"index_b": alignment.index2.tolist(),
"warping_path": list(zip(alignment.index1.tolist(), alignment.index2.tolist()))
}
@@ -1,6 +0,0 @@
# ===== Controllers Sub-package =====
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
__all__ = ["DeviceController", "SessionController"]
+345 -130
View File
@@ -1,36 +1,31 @@
"""QML-exposed controller for wafer device communication. """QML-exposed controller for wafer device communication."""
Bridges DeviceService (serialcomm) to QML via @Slot methods and signals.
All hardware operations run synchronously on the main thread (matching
C# Form1.cs per-operation open/close pattern). For long operations
(erase ~15s, read up to 120s) the caller should show a loading state.
"""
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 QObject, Property, Qt, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.visualization.graph_view import GraphView
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.utils import slot_error_boundary
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
from pygui.backend.controllers.session_controller import SessionController
# import pygui.backend.wafer_map_item
stream_controller = SessionController()
# engine.rootContext().setContextProperty("streamController", stream_controller)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -43,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)
@@ -55,46 +54,52 @@ 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)
# Initialize status from persisted settings (with defaults for new installations) # Always start fresh — never restore connection status or logs from a prior session
self._connection_status = getattr(settings, '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
# Default save dir lives next to the settings file (~/Documents/isc_data/csv) self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
from pathlib import Path # TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
self._save_data_dir: str = getattr(settings, 'save_data_dir', str(Path.home() / "Documents" / "isc_data" / "csv")) # (default lambda s: "") and add:
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {}) # def _wafer_license_level(self) -> str:
self._last_csv_path: str = getattr(settings, 'last_csv_path', "") # return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
self._selected_port: str = getattr(settings, 'selected_port', "") # plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
self._data_row_count: int = getattr(settings, 'data_row_count', 0) # THINKING: single shared guard — per-call-site checks are the
self._data_col_count: int = getattr(settings, 'data_col_count', 0) # 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 # Reset connection status on fresh start
if self._activity_log: self._connection_status = "Disconnected"
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()
# 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.addHandler(QmlActivityLogHandler(self))
root_logger.setLevel(logging.INFO)
# ---- Properties ---- # ---- Properties ----
@Property(list, notify=portsUpdated) @Property(list, notify=portsUpdated)
@@ -102,38 +107,89 @@ class DeviceController(QObject):
"""Currently available serial port names.""" """Currently available serial port names."""
return self._service.enumerate_ports() return self._service.enumerate_ports()
@Property(str, notify=portsUpdated) def _set_connection_status(self, status: str) -> None:
"""Update connection status and notify QML."""
if self._connection_status != status:
self._connection_status = status
self.connectionStatusChanged.emit()
@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."""
return self._save_data_dir return self._save_data_dir
@Slot(str) @Slot(str)
@slot_error_boundary
def setSaveDataDir(self, path: str) -> None: def setSaveDataDir(self, path: str) -> None:
"""Set the directory for saving parsed CSV data files.""" """Set the directory for saving parsed CSV data files."""
self._save_data_dir = path self._save_data_dir = path
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
@Slot(str) @Slot(str)
@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."""
self._selected_port = port if self._selected_port != port:
self._save_status() self._selected_port = port
self.selectedPortChanged.emit()
self._save_status()
@Property(int, notify=parsedDataReady) @Property(int, notify=parsedDataReady)
def dataRowCount(self) -> int: def dataRowCount(self) -> int:
@@ -152,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."""
@@ -172,6 +246,7 @@ class DeviceController(QObject):
return self._graph_view return self._graph_view
@Slot(result=object) @Slot(result=object)
@slot_error_boundary
def getChartData(self) -> dict[str, Any]: def getChartData(self) -> dict[str, Any]:
"""Extract chart-ready data from the current model. """Extract chart-ready data from the current model.
@@ -209,28 +284,57 @@ class DeviceController(QObject):
self._activity_log = self._activity_log[-200:] self._activity_log = self._activity_log[-200:]
self.activityLogUpdated.emit("\n".join(self._activity_log)) self.activityLogUpdated.emit("\n".join(self._activity_log))
def _set_operation_progress(self, in_progress: bool) -> None: @Slot()
"""Set operation progress state and notify QML. @slot_error_boundary
def clearActivityLog(self) -> None:
"""Clear the activity log."""
self._activity_log.clear()
self.activityLogUpdated.emit("")
self._save_status()
portsUpdated is the shared notify signal for availablePorts, @Slot()
connectionStatus, and operationInProgress — emitting it forces @slot_error_boundary
QML to re-evaluate all three bindings. def clearSession(self) -> None:
""" """Clear the current session state, allowing a clean detect."""
self._operation_in_progress = in_progress self._set_connection_status("Disconnected")
self.portsUpdated.emit(self._service.enumerate_ports()) self._selected_port = ""
self._last_wafer_info = {}
self._set_wafer_detected(False)
self._data_row_count = 0
self._data_col_count = 0
self._raw_bytes = None
self._operation_in_progress = False
self._data_model.reset()
self._graph_view.resetChart()
self._activity_log.clear()
self.detectResult.emit(None)
self.parsedDataReady.emit({"success": False})
self.portsUpdated.emit(self.availablePorts)
self.activityLogUpdated.emit("")
self._save_status()
def _set_operation_progress(self, in_progress: bool) -> None:
"""Set operation progress state and notify QML."""
if self._operation_in_progress != in_progress:
self._operation_in_progress = in_progress
self.operationInProgressChanged.emit()
# ---- Public Slots ---- # ---- Public Slots ----
@Slot() @Slot()
@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._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self.portsUpdated.emit(ports) self.portsUpdated.emit(ports)
self._append_log(f"Scanned {len(ports)} serial port(s)") self._append_log(f"Scanned {len(ports)} serial port(s)")
self._save_status() self._save_status()
@Slot() @Slot()
@slot_error_boundary
def detectWafer(self) -> None: def detectWafer(self) -> None:
"""Scan all serial ports for a wafer (runs in background thread). """Scan all serial ports for a wafer (runs in background thread).
@@ -242,8 +346,21 @@ 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._connection_status = "Detecting..." self._set_connection_status("Detecting...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log("Scanning all ports for wafer ...") self._append_log("Scanning all ports for wafer ...")
@@ -260,40 +377,58 @@ class DeviceController(QObject):
self._detectFinished.emit(port, info) self._detectFinished.emit(port, info)
@Slot(object, object) @Slot(object, object)
@slot_error_boundary
def _handle_detect_finished( def _handle_detect_finished(
self, port: Optional[str], info: Optional[WaferInfo] self, port: Optional[str], info: Optional[WaferInfo]
) -> None: ) -> None:
"""Main-thread completion handler for detectWafer.""" """Main-thread completion handler for detectWafer."""
if info is not None: try:
self._selected_port = port or "" if info is not None:
self._connection_status = "Connected" # Build the dict (can raise) before declaring success, so a
self._append_log( # bad payload can't leave status="Connected" while
f"Detected: {info.serial_number} (family={info.family_code}, " # waferDetected stays false and Read/Erase stay greyed.
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})" wafer_dict = self._wafer_info_to_dict(info)
) self._selected_port = port or ""
wafer_dict = self._wafer_info_to_dict(info) self._last_wafer_info = wafer_dict
self._last_wafer_info = wafer_dict self._set_wafer_detected(True)
self.detectResult.emit(wafer_dict) self._set_connection_status("Connected")
self._save_status() self.detectResult.emit(wafer_dict)
else: self.selectedPortChanged.emit()
self._selected_port = "" self._save_status()
self._connection_status = "Disconnected" else:
self._append_log("No wafer detected on any port") self._selected_port = ""
self._last_wafer_info = {} self._set_connection_status("Disconnected")
self.detectResult.emit(None) self._append_log("No wafer detected on any port")
self._set_operation_progress(False) self._last_wafer_info = {}
self._set_wafer_detected(False)
self.detectResult.emit(None)
self.selectedPortChanged.emit()
finally:
self._set_operation_progress(False)
@Slot() @Slot()
@slot_error_boundary
def readMemoryAsync(self) -> None: def readMemoryAsync(self) -> None:
"""Read wafer memory in a background thread. """Read wafer memory in a background thread.
Uses the family code and port from the last detect. Uses the family code and port from the last detect.
Emits readResult on completion, then auto-chains to parseAndSaveData. Emits readResult on completion; QML prompts for the save directory
before calling parseAndSaveData.
""" """
if self._operation_in_progress: if self._operation_in_progress:
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"})
@@ -303,7 +438,7 @@ class DeviceController(QObject):
family_code = self._last_wafer_info.get("familyCode", "") family_code = self._last_wafer_info.get("familyCode", "")
self._set_operation_progress(True) self._set_operation_progress(True)
self._connection_status = "Reading..." self._set_connection_status("Reading...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log( self._append_log(
f"Reading memory on {port} (family={family_code or 'auto'}) ..." f"Reading memory on {port} (family={family_code or 'auto'}) ..."
@@ -325,22 +460,21 @@ class DeviceController(QObject):
self._readFinished.emit(port, family_code, data) self._readFinished.emit(port, family_code, data)
@Slot(str, str, object) @Slot(str, str, object)
@slot_error_boundary
def _handle_read_finished( def _handle_read_finished(
self, port: str, family_code: str, data: Optional[bytes] self, port: str, family_code: str, data: Optional[bytes]
) -> None: ) -> None:
"""Main-thread completion handler for readMemoryAsync.""" """Main-thread completion handler for readMemoryAsync."""
if data is not None: if data is not None:
self._connection_status = "Connected" self._set_connection_status("Connected")
self._append_log(f"Read {len(data)} bytes") self._append_log(f"Read {len(data)} bytes")
self._raw_bytes = data self._raw_bytes = data
self.readResult.emit({"success": True, "bytes": len(data)}) self.readResult.emit({"success": True, "bytes": len(data)})
self._set_operation_progress(False) self._set_operation_progress(False)
# Auto-chain: read → parse → save (matches C# behavior). self._append_log("Memory read complete - choose save directory to save CSV")
# Parse runs on main thread — it's CPU-bound but bounded (~1s).
self.parseAndSaveData(family_code, port)
self._save_status() self._save_status()
return return
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("Read returned no data") self._append_log("Read returned no data")
self._raw_bytes = None self._raw_bytes = None
self.readResult.emit({"error": "Read returned no data"}) self.readResult.emit({"error": "Read returned no data"})
@@ -348,67 +482,132 @@ class DeviceController(QObject):
self._save_status() self._save_status()
@Slot(str) @Slot(str)
@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._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) ...")
ok = self._service.erase_wafer(port) 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)
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._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")
else: else:
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("Erase command failed") self._append_log("Erase command failed")
self.eraseResult.emit({"success": ok}) self.eraseResult.emit({"success": ok})
self._set_operation_progress(False) self._set_operation_progress(False)
@Slot(str) @Slot(str)
@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._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} ...")
# Step 1: D1 sensor data threading.Thread(
sensor_data = self._service.read_wafer_data(port, family_code="") target=self._debug_worker,
if sensor_data is None: args=(port,),
self._connection_status = "Disconnected" daemon=True,
self._append_log("D1 read failed — aborting debug read") ).start()
self.debugResult.emit({"error": "D1 read failed"})
self._set_operation_progress(False)
return
self._append_log(f"D1 read: {len(sensor_data)} bytes") def _debug_worker(self, port: str) -> None:
"""Background thread worker for readDebug."""
try:
# Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None:
self._debugFinished.emit({"error": "D1 read failed"})
return
# 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._connection_status = "Disconnected" self._debugFinished.emit({"error": "F1 read failed"})
self._append_log("F1 read failed") return
self.debugResult.emit({"error": "F1 read failed"})
self._set_operation_progress(False)
return
self._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)
"success": True, debug_hex = parse_binary_data(debug_data, fc)
"sensor_bytes": len(sensor_data), if not sensor_hex or not debug_hex:
"debug_bytes": len(debug_data), 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,
"sensor_bytes": len(sensor_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)
@slot_error_boundary
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None: def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
"""Parse raw bytes into temperatures and save to CSV. """Parse raw bytes into temperatures and save to CSV.
@@ -424,12 +623,9 @@ class DeviceController(QObject):
return return
if not self._save_data_dir: if not self._save_data_dir:
self._append_log("No save data directory set") self._save_data_dir = str(Path(self._data_dir) / "csv")
self.parsedDataReady.emit({ self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
"success": False, self._save_status()
"error": "No save data directory set",
})
return
fc = family_code or ( fc = family_code or (
self._last_wafer_info.get("familyCode", "") self._last_wafer_info.get("familyCode", "")
@@ -476,6 +672,7 @@ class DeviceController(QObject):
return return
self._append_log(f"Saved CSV: {csv_path}") self._append_log(f"Saved CSV: {csv_path}")
self._last_csv_path = csv_path
# Load data into the QAbstractTableModel # Load data into the QAbstractTableModel
self._data_model.load_data(temp_data, cols) self._data_model.load_data(temp_data, cols)
@@ -509,18 +706,36 @@ class DeviceController(QObject):
self._settings.last_csv_path = self._last_csv_path self._settings.last_csv_path = self._last_csv_path
# Save to disk # Save to disk
LocalSettings.save_settings(str(LocalSettings._settings_path(self._data_dir)), self._settings) LocalSettings.save_settings(self._data_dir, self._settings)
# ---- Helpers ---- # ---- Helpers ----
@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,
} }
class QmlActivityLogHandler(logging.Handler):
def __init__(self, controller: DeviceController) -> None:
super().__init__()
self._controller = controller
def emit(self, record: logging.LogRecord) -> None:
message = record.getMessage()
if "found wafer on" in message:
return
level = record.levelname
msg = f"{level}: {message}"
self._controller._append_log(msg)
@@ -1,19 +1,30 @@
"""QML-facing controller for the live/review wafer dashboard.""" """QML-facing controller for the live/review wafer dashboard."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from typing import Any, Optional import time
from typing import Optional
from PySide6.QtCore import QObject, Property, QTimer, Qt, Signal, Slot 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.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.session_model import SessionModel 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.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.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
from pygui.backend.models.sensor_editor import SensorEditor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -23,14 +34,26 @@ MODE_REVIEW = "review"
class SessionController(QObject): class SessionController(QObject):
# public signals # public signals
frameUpdated = Signal() # any property below may have changed frameUpdated = Signal()
modeChanged = Signal() modeChanged = Signal()
stateChanged = Signal() stateChanged = Signal()
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal()
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph
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) -> None: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -40,8 +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._stream_start_time: float = 0.0
self._play_timer = QTimer(self) self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance) self._play_timer.timeout.connect(self._advance)
@@ -54,10 +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._active_clusters: list[list[int]] = []
self._received_count: int = 0
self._error_count: int = 0
# Comparison cache for dynamic overlap mapping
self._compare_recs_a: list = []
self._compare_recs_b: list = []
self._compare_alignment_map: dict[int, int] = {}
# ---- properties QML binds to ---- # ---- properties QML binds to ----
@Property(str, notify=modeChanged) @Property(str, notify=modeChanged)
@@ -69,8 +104,18 @@ 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: return self._last.state if self._last else "idle" def state(self) -> str:
if self._last:
return self._last.state
# IF we dont have data yet, but the reader is running -> Streaming
if self._mode == MODE_LIVE and self._reader is not None:
return "streaming"
return "idle"
@Property(bool, notify=recordingChanged) @Property(bool, notify=recordingChanged)
def recording(self) -> bool: return self._recorder.is_recording def recording(self) -> bool: return self._recorder.is_recording
@@ -88,20 +133,39 @@ class SessionController(QObject):
"value": round(v, 2), "band": band, "index": i}) "value": round(v, 2), "band": band, "index": i})
return out return out
# ---- WaferMapItem bindings (split view of sensorDots) ---- @Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type]
@Property("QVariantList", notify=sensorsChanged)
def sensorLayout(self) -> list: def sensorLayout(self) -> list:
"""[{label, x, y}] for WaferMapItem.sensors.""" """[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
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,
"side": getattr(s, "side", "right"),
"offset_x": getattr(s, "offset_x", 0.0),
"offset_y": getattr(s, "offset_y", 0.0),
}
for s in self._sensors
]
@Property("QVariantList", notify=frameUpdated) @Property(str, notify=sensorsChanged)
def waferShape(self) -> str:
"""Wafer shape: 'round' or 'square'."""
return getattr(self._sensors, "shape", "round")
@Property(float, notify=sensorsChanged)
def waferSize(self) -> float:
"""Wafer size in mm."""
return getattr(self._sensors, "size", 300.0)
@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:
@@ -123,30 +187,91 @@ 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()
@Property(bool, notify=clusterAveragingEnabledChanged)
def clusterAveragingEnabled(self) -> bool:
return self._cluster_averaging_enabled
@clusterAveragingEnabled.setter # type: ignore[no-redef]
def clusterAveragingEnabled(self, value: bool) -> None:
if self._cluster_averaging_enabled == value:
return
self._cluster_averaging_enabled = value
self.clusterAveragingEnabledChanged.emit()
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
def setMode(self, mode: str) -> None: def setMode(self, mode: str) -> None:
if mode == self._mode: if mode == self._mode:
return return
self.stopStream() if mode != "live":
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()
@Slot(float, float, bool) @Slot(float, float, bool)
@slot_error_boundary
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None: def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto)) self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
if self._last: # re-band current frame in place if self._last: # re-band current frame in place
@@ -154,33 +279,53 @@ class SessionController(QObject):
# ---- review: file load + playback ---- # ---- review: file load + playback ----
@Slot(str) @Slot(str)
@slot_error_boundary
def loadFile(self, file_path: str) -> None: def loadFile(self, file_path: str) -> None:
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv from pathlib import Path
from pygui.backend.data.data_records import (
is_official_csv,
read_data_records,
read_official_csv,
)
from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size
sensors: list[Sensor] = []
frames = [] frames = []
if is_official_csv(file_path): try:
sensors, records = read_official_csv(file_path) if is_official_csv(file_path):
frames = frames_from_wafer_data(None, records) sensors, records = read_official_csv(file_path)
else: frames = frames_from_wafer_data(None, records)
try: else:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc: if data is None or not data.sensors:
log.warning("Could not parse %s: %s", file_path, exc) self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
return return
if data is None or not data.sensors: records = read_data_records(file_path)
log.warning("Could not parse %s", file_path) sensors = data.sensors
return frames = frames_from_wafer_data(data, records)
records = read_data_records(file_path)
sensors = data.sensors
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
self._sensors = sensors wafer_id = ""
if not is_official_csv(file_path):
wafer_id = data.serial if (data and data.serial) else ""
else:
stem = Path(file_path).stem
wafer_id = stem.split("-")[0] if "-" in stem else stem
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._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors)
self._player.load(frames) self._player.load(frames)
self._model.reset() self._model.reset()
self._loaded_file = file_path self._loaded_file = file_path
@@ -189,30 +334,294 @@ class SessionController(QObject):
self._emit_current() self._emit_current()
@Slot() @Slot()
@slot_error_boundary
def unloadFile(self) -> None:
"""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_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()
def pause(self) -> None: self._play_timer.stop() @slot_error_boundary
def pause(self) -> None:
self._play_timer.stop()
self.stateChanged.emit()
@Slot() @Slot()
@slot_error_boundary
def stop(self) -> None: def stop(self) -> None:
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
def step(self, delta: int) -> None: def step(self, delta: int) -> None:
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
def seek(self, i: int) -> None: def seek(self, i: int) -> None:
self._player.seek(i) self._player.seek(i)
self._emit_current() self._emit_current()
@Slot(float) @Slot(float)
@slot_error_boundary
def setSpeed(self, x: float) -> None: def setSpeed(self, x: float) -> None:
self._speed = max(0.1, x) self._speed = max(0.1, x)
if self._play_timer.isActive(): if self._play_timer.isActive():
@@ -228,18 +637,26 @@ 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()
self._play_timer.start(self._next_interval_ms()) self._play_timer.start(self._next_interval_ms())
def _apply_pipeline(self, raw_values: list[float]) -> list[float]:
"""Apply override first, then optional cluster averaging."""
edited = self._sensor_editor.apply(raw_values)
if getattr(self, "_cluster_averaging_enabled", False):
return average_clusters(edited, getattr(self, "_active_clusters", []))
return edited
def _emit_current(self) -> None: def _emit_current(self) -> None:
frame = self._player.current() frame = self._player.current()
if frame is None: if frame is None:
return return
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,
values=self._sensor_editor.apply(frame.values)) values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited) self._last = self._model.process(edited)
self.frameUpdated.emit() self.frameUpdated.emit()
self.stateChanged.emit() self.stateChanged.emit()
@@ -250,47 +667,142 @@ class SessionController(QObject):
if frame is None: if frame is None:
return return
edited = Frame(seq=frame.seq, time=frame.time, edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values)) values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited) self._last = self._model.process(edited)
self.frameUpdated.emit() self.frameUpdated.emit()
self.stateChanged.emit() self.stateChanged.emit()
# ---- live: stream start/stop ---- # ---- live: stream start/stop ----
@Slot(str) @Slot(str, str)
def startStream(self, port: str) -> None: @slot_error_boundary
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.serialcomm.serial_port import SerialPort # transport open from pygui.serialcomm.serial_port import SerialPort # transport open
import serial as pyserial
def parse_line(raw: str, seq: int) -> Frame: if family_code:
parts = [float(x) for x in raw.split(",")] try:
return Frame(seq=seq, time=parts[0], values=parts[1:]) self._sensors = load_layout_for_wafer_id(family_code)
self.sensorsChanged.emit()
except Exception as 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', [])
if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors)
# The new binary protocol sends payload of (sensorCount * 2) bytes
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
expected_sensors = 80 if (family_code or "") == "X" else 244
def parse_binary_frame(payload: bytes, seq: int) -> Frame:
values = []
num_sensors = min(expected_sensors, len(payload) // 2)
for i in range(num_sensors):
high_byte = payload[i * 2]
low_byte = payload[i * 2 + 1]
val16 = (high_byte << 8) | low_byte
is_negative = (val16 & 0x8000) != 0
integer_part = (val16 >> 7) & 0xFF
fractional_bits = val16 & 0x7F
fractional_part = fractional_bits / 128.0
result = integer_part + fractional_part
if is_negative:
result = -result
values.append(result)
return Frame(seq=seq, time=time.monotonic(), values=values)
# Clear out any old data from the prev sessions
self._model.reset()
self._reset_live_trend()
self._last = None
self._last_raw_frame = None
self._received_count = 0
self._error_count = 0
self.liveStatsChanged.emit()
transport = SerialPort.open_port(port, timeout=1)
# Send 'D2' command padded to 512 bytes to start the stream
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
transport.write(cmd)
import weakref
weak_self = weakref.ref(self)
def on_error(exc: Exception):
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)
transport = pyserial.Serial(port, SerialPort.BAUDRATE, timeout=1)
self._reader = StreamReader( self._reader = StreamReader(
transport, parse_line, transport, parse_binary_frame,
on_frame=lambda f: self._liveFrame.emit(f)) on_frame=on_frame,
on_error=on_error,
family_code=family_code or "A")
self._reader.start() self._reader.start()
self._repaint_timer.start() # Q1: begin ~20 Hz repaints self._repaint_timer.start()
self.setMode("live")
self.stateChanged.emit()
@Slot() @Slot()
@slot_error_boundary
def stopStream(self) -> None: def stopStream(self) -> None:
self._repaint_timer.stop() self._repaint_timer.stop()
if self._reader is not None: self._received_count = 0
self._error_count = 0
self.liveStatsChanged.emit()
if self._reader:
transport = self._reader._transport
# 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:
try:
cmd = b"D2S" + (b"F" * 509)
transport.write(cmd)
transport.flush()
except Exception as exc:
log.error("Error sending stop command: %s", exc)
self._reader.stop() self._reader.stop()
self._reader = None self._reader = None
self.stateChanged.emit()
self.stopRecording() self.stopRecording()
@Slot(object) @Slot(object)
@slot_error_boundary
def _on_live_frame(self, frame: Frame) -> None: def _on_live_frame(self, frame: Frame) -> None:
self._received_count += 1
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,
values=self._sensor_editor.apply(frame.values)) values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited) self._last = self._model.process(edited)
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
if self._last and self._last.stats:
avg = self._last.stats.avg
elapsed = time.monotonic() - self._stream_start_time
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
def _flush_repaint(self) -> None: def _flush_repaint(self) -> None:
if not self._dirty: if not self._dirty:
return return
@@ -298,41 +810,55 @@ 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
def startRecording(self, path: str, serial: str = "") -> None: def startRecording(self, path: str, serial: str = "") -> None:
self._recorder.start(path, self._sensors, serial) self._recorder.start(path, self._sensors, serial)
self.recordingChanged.emit() self.recordingChanged.emit()
@Slot() @Slot()
@slot_error_boundary
def stopRecording(self) -> None: def stopRecording(self) -> None:
if self._recorder.is_recording: if self._recorder.is_recording:
self._recorder.stop() self._recorder.stop()
self.recordingChanged.emit() self.recordingChanged.emit()
@Slot(int, float) @Slot(int, float)
@slot_error_boundary
def replaceSensor(self, index: int, value: float) -> None: def replaceSensor(self, index: int, value: float) -> None:
"""Override sensor `index` to display `value` every frame.""" """Override sensor `index` to display `value` every frame."""
self._sensor_editor.set_replacement(index, value) self._sensor_editor.set_replacement(index, value)
self._reprocess_current() self._reprocess_current()
@Slot(int, float) @Slot(int, float)
@slot_error_boundary
def offsetSensor(self, index: int, delta: float) -> None: def offsetSensor(self, index: int, delta: float) -> None:
"""Shift sensor `index` by `delta` every frame.""" """Shift sensor `index` by `delta` every frame."""
self._sensor_editor.set_offset(index, delta) self._sensor_editor.set_offset(index, delta)
self._reprocess_current() self._reprocess_current()
@Slot(int) @Slot(int)
@slot_error_boundary
def clearSensorEdit(self, index: int) -> None: def clearSensorEdit(self, index: int) -> None:
"""Remove all overrides for sensor `index`.""" """Remove all overrides for sensor `index`."""
self._sensor_editor.clear(index) self._sensor_editor.clear(index)
self._reprocess_current() self._reprocess_current()
@Slot() @Slot()
@slot_error_boundary
def clearSensorEdits(self) -> None: def clearSensorEdits(self) -> None:
"""Remove all sensor overrides.""" """Remove all sensor overrides."""
self._sensor_editor.clear() self._sensor_editor.clear()
self._reprocess_current() self._reprocess_current()
def _reset_live_trend(self) -> None:
"""Mark a fresh stream start for elapsed-time trend deltas."""
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
+1 -1
View File
@@ -2,8 +2,8 @@ import secrets
from pathlib import Path from pathlib import Path
from typing import Union from typing import Union
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# ===== Crypto Utilities ===== # ===== Crypto Utilities =====
-16
View File
@@ -1,16 +0,0 @@
# ===== Data Sub-package =====
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.data.data_records import read_data_records, is_official_csv, read_official_csv
from pygui.backend.data.data_segment import DataSegment
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",
"DataSegment",
"FileBrowser",
"LocalSettings", "LocalSettingsModel",
]
+14
View File
@@ -0,0 +1,14 @@
# ===== Backend Data Constants =====
from pathlib import Path
from PySide6.QtCore import QStandardPaths
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
+6 -5
View File
@@ -19,14 +19,14 @@ 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)
except (ValueError, TypeError): except (ValueError, TypeError):
# If format is wrong or date is None, return current time # If format is wrong or date is None, return None
return datetime.now() return None
# ===== Formatting ===== # ===== Formatting =====
@staticmethod @staticmethod
@@ -48,4 +48,5 @@ class CSVFileMetadata:
def string_date_format(self) -> str: def string_date_format(self) -> str:
"""Helper to ensure the date is always a string for JSON.""" """Helper to ensure the date is always a string for JSON."""
return self.format_date(self.get_date()) dt = self.get_date()
return self.format_date(dt) if dt else self.date
+65 -8
View File
@@ -1,27 +1,27 @@
"""Append live frames""" """Append live frames"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, TextIO from typing import Optional, TextIO
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None self._path: Optional[str] = None
@property @property
def is_recording(self) -> bool: def is_recording(self) -> bool:
return self._file_handle is not None return self._file_handle is not None
def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None:
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).parent.mkdir(parents=True, exist_ok=True)
file_handle = open(path, "w", encoding="utf-8", newline ="") file_handle = open(path, "w", encoding="utf-8", newline="")
file_handle.write(f"Wafer ID={serial}\n") file_handle.write(f"Wafer ID={serial}\n")
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n") file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n") file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
@@ -31,7 +31,7 @@ class CsvRecorder:
file_handle.flush() file_handle.flush()
self._file_handle, self._path = file_handle, path self._file_handle, self._path = file_handle, path
def write(self, frame: Frame) -> None: def write(self, frame: Frame) -> None:
if self._file_handle is None: if self._file_handle is None:
return return
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values] row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
@@ -44,3 +44,60 @@ class CsvRecorder:
self._file_handle.close() self._file_handle.close()
path, self._file_handle, self._path = self._path, None, None path, self._file_handle, self._path = self._path, None, None
return path return path
@staticmethod
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.
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:
file_path: Output CSV path.
source_path: Source CSV path with full wafer data
start_frame: First frame index (inclusive).
end_frame: Last frame index (inclusive)
Returns:
True on success (at least one frame written).
"""
from pygui.backend.data.data_records import is_data_row, is_official_csv
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
+20 -6
View File
@@ -1,9 +1,26 @@
"""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
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
@@ -16,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
+36 -29
View File
@@ -5,9 +5,10 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Property, 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
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
@@ -27,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]
@@ -36,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
@@ -70,17 +81,16 @@ class FileBrowser(QObject):
self._show_error(f"CSV file was not found:\n{file_path}") self._show_error(f"CSV file was not found:\n{file_path}")
return return
# ponytail: row is absent when saving metadata for a CSV just read from
# the wafer (not in the browser listing) — still write the sidecar.
row = self._find_row(file_path) row = self._find_row(file_path)
if row is None: if row is not None:
self._show_error("The selected CSV row is no longer available.") row["wafer"] = wafer
return row["date"] = date
row["chamber"] = chamber
row["wafer"] = wafer row["notes"] = notes
row["date"] = date row["selected"] = selected
row["chamber"] = chamber row["masterType"] = master_type
row["notes"] = notes
row["selected"] = selected
row["masterType"] = master_type
payload = { payload = {
"wafer": wafer, "wafer": wafer,
@@ -118,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 "")
@@ -145,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:
@@ -168,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,
} }
) )
@@ -193,21 +210,15 @@ class FileBrowser(QObject):
# Fall back to CSV/header parsing if sidecar is malformed. # Fall back to CSV/header parsing if sidecar is malformed.
pass pass
meta = self._metadata_from_filename(csv_path)
parser_data, _ = self._parser.parse(str(csv_path)) parser_data, _ = self._parser.parse(str(csv_path))
if parser_data is not None: if parser_data is not None:
wafer = parser_data.serial or "" if parser_data.serial:
date_text = "" meta.wafer = parser_data.serial
if parser_data.date != datetime.min: if parser_data.date != datetime.min:
date_text = CSVFileMetadata.format_date(parser_data.date) meta.date = CSVFileMetadata.format_date(parser_data.date)
return CSVFileMetadata(
wafer=wafer,
date=date_text,
chamber="",
notes="",
filename=str(csv_path),
)
return self._metadata_from_filename(csv_path) return meta
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata: def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
match = re.match( match = re.match(
@@ -234,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 / "isc_data"
def _set_current_directory(self, directory: Path) -> None: def _set_current_directory(self, directory: Path) -> None:
normalized = Path(directory) normalized = Path(directory)
+8 -4
View File
@@ -1,7 +1,10 @@
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
log = logging.getLogger(__name__)
# ===== Settings Persistence Model ===== # ===== Settings Persistence Model =====
class LocalSettings: class LocalSettings:
@@ -10,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)
@@ -51,7 +54,8 @@ class LocalSettings:
if hasattr(settings, key): if hasattr(settings, key):
setattr(settings, key, value) setattr(settings, key, value)
except Exception as e: except Exception as e:
print(f"Error reading setting file: {e}") log = logging.getLogger(__name__)
log.warning("Error reading setting file: %s",e)
return settings return settings
@@ -66,7 +70,7 @@ class LocalSettings:
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
except Exception as e: except Exception as e:
print(f"Error saving settings: {e}") log.exception("Error saving settings: %s",e)
# ===== Master File Helpers ===== # ===== Master File Helpers =====
@classmethod @classmethod
+78 -106
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 QObject, Property, QDateTime, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
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 / "isc_data"
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()
@@ -334,27 +330,3 @@ class LocalSettingsModel(QObject):
@Slot(str) @Slot(str)
def clearMaster(self, family: str) -> None: def clearMaster(self, family: str) -> None:
self.setMaster(family, "") self.setMaster(family, "")
@Slot(str)
def setChamberId(self, value: str) -> None:
self.chamberId = value
@Slot(bool)
def setReverseZWafer(self, value: bool) -> None:
self.reverseZWafer = value
@Slot(bool)
def setDebugMode(self, value: bool) -> None:
self.debugMode = value
@Slot(int)
def setWaferReadTimeout(self, value: int) -> None:
self.waferReadTimeout = value
@Slot(int)
def setWaferDetectTimeout(self, value: int) -> None:
self.waferDetectTimeout = value
@Slot(int)
def setWaferRetries(self, value: int) -> None:
self.waferRetries = value
@@ -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.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.session_model import SessionModel, SessionUpdate
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
from pygui.backend.models.stability_detector import StabilityDetector
__all__ = [
"Frame", "FramePlayer", "frames_from_wafer_data",
"Stats", "compute_stats",
"SessionModel", "SessionUpdate",
"TemperatureTableModel",
"SensorEditor",
"ThresholdConfig", "classify", "resolve_bounds",
"StabilityDetector",
]
+2 -2
View File
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
# Column 0 = row index, columns 1..N = sensor values # Column 0 = row index, columns 1..N = sensor values
return self._col_count + 1 return self._col_count + 1
def data(self, index: Any, role: int = ...) -> Any: def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
if not index.isValid(): if not index.isValid():
return None return None
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
return None return None
def headerData( def headerData(
self, section: int, orientation: int, role: int = ... self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
) -> Any: ) -> Any:
if role != Qt.ItemDataRole.DisplayRole: if role != Qt.ItemDataRole.DisplayRole:
return None return None
+1
View File
@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
+20 -1
View File
@@ -1,11 +1,26 @@
from __future__ import annotations from __future__ import annotations
from pygui.backend.models.frame import Frame 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] =[]
@@ -20,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
+39
View File
@@ -1,6 +1,7 @@
"""Per-frame descriptive statistics""" """Per-frame descriptive statistics"""
from __future__ import annotations from __future__ import annotations
import math import math
from dataclasses import dataclass from dataclasses import dataclass
@@ -13,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:
+36
View File
@@ -0,0 +1,36 @@
import numpy as np
class ReplayStatsTracker:
"""Accumulates per-frame statistics across a replay session."""
def __init__(self) -> None:
self.history: list[dict] = []
self.running_avg: float = 0.0
def track(self, frame_index: int, temperatures: list[float]) -> dict:
arr = np.array(temperatures, dtype=np.float32)
clean_arr = arr[np.isfinite(arr)]
if clean_arr.size == 0:
stats = {"frame": frame_index, "min":0.0, "max":0.0, "avg":0.0, "std":0.0}
else:
stats = {
"frame": frame_index,
"min": float(np.min(clean_arr)),
"max": float(np.max(clean_arr)),
"avg": float(np.mean(clean_arr)),
"std": float(np.std(clean_arr)),
}
self.history.append(stats)
self.running_avg = float(np.mean([s["avg"] for s in self.history]))
return stats
def get_trend(self) -> list[float]:
"""Return list of per-frame averages for the trend chart."""
return [s["avg"] for s in self.history]
def reset(self) -> None:
self.history.clear()
self.running_avg = 0.0
@@ -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))

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