Compare commits

..

48 Commits

Author SHA1 Message Date
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
jack f89a735d51 refactor: update TabBar and live indicator for improved connection status handling and visual feedback 2026-06-11 13:16:37 -07:00
jack 1cd54e81fc refactor: enhance UI components and improve theme properties for better visual consistency 2026-06-11 13:09:04 -07:00
jack 12bd778f13 refactor: modernize WaferMapTab layout and redesign ReadoutPanel statistics display 2026-06-11 12:57:52 -07:00
jack 72334795da refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers 2026-06-11 12:15:00 -07:00
jack b9f8032203 Refactor code structure for improved readability and maintainability 2026-06-11 11:57:46 -07:00
jack 97ca58bfc2 feat: Add Wafer Map tab and associated components
- Introduced a new Wafer Map tab in the HomePage.qml, which loads WaferMapTab.qml when selected.
- Created WaferMapTab.qml to display wafer map with live data and controls.
- Added ReadoutPanel.qml for displaying sensor statistics and thresholds.
- Implemented SourcePanel.qml for file selection and filtering.
- Developed TransportBar.qml for playback controls.
- Added WaferMapView.qml to visualize wafer data with interactive features.
- Created ReplaceSensorDialog.qml for sensor value overrides.
- Updated Theme.qml for new color schemes and UI adjustments.
- Modified qmldir files to include new components in the ISC.Tabs and ISC.Tabs.components modules.
2026-06-11 11:57:24 -07:00
jack b52b983bb2 Add wafer layouts and RBF heatmap functionality
- Introduced new YAML layout files for wafers B, C, D, F, X, Z, and their reversed versions.
- Implemented RBF heatmap interpolation using CuPy and NumPy for GPU acceleration.
- Created a backend module to load wafer layouts from YAML files, mirroring the existing schema.
- Developed a QQuickPaintedItem for rendering wafer maps, including sensor markers, heatmaps, and labels.
- Enhanced the drawing capabilities with concentric rings, crosshair axes, and orientation markers.
2026-06-11 11:56:55 -07:00
jack ca1a514f23 feat: Wafer Map integration
## Backend — Session & playback
- Add SessionController (QML-facing live/review controller) with
  loadedFile property for active-file highlight binding
- Add SessionModel, FramePlayer, and CsvRecorder for state machine,
  frame seek/step/speed, and CSV recording
- Add SensorEditor for per-sensor value overrides (replace + offset)
2026-06-11 11:55:34 -07:00
jack 59528eb6c9 Add layout files for various wafers: aepwafer, bcdwafer, fwafer, xwafer, zwafer, and zwafer_rev 2026-06-08 15:10:13 -07:00
jack 9cd3170e8a Replay Tabs:
- Add stability detection and threshold classification features with corresponding tests
2026-06-04 13:25:11 -07:00
jack 9779baa468 Restructure into src/ layout under pygui package
Move all application source under src/pygui/ and rewire imports,
build config, and QML module path to match.
- Relocate backend/, serialcomm/, and the ISC QML module into
  src/pygui/; convert main.py into pygui/__main__.py with a main()
  entry point (run via `python -m pygui` or the new `isc` script)
- Rewrite absolute imports: backend.* -> pygui.backend.*,
  serialcomm.* -> pygui.serialcomm.* (source + tests)
- Move app icons (isc.ico/icns) into packaging/
- Update README and ISC.qmlproject to the new paths
2026-06-03 11:41:45 -07:00
jack af170666e8 add sensor-band theme tokens 2026-06-03 11:15:21 -07:00
jack 16d8bf48af Merge pull request 'Add SettingsTab, StatusTab, DataTab and wire up DeviceController' (#2) from SettingTab into main
Reviewed-on: #2
2026-05-28 22:54:03 +00:00
132 changed files with 10759 additions and 952 deletions
+5
View File
@@ -5,6 +5,9 @@ __pycache__/
*.pyd
*.so
*.a
*.egg-info/
.pytest_cache/
.ruff_cache/
# Virtual environment
.venv/
@@ -14,9 +17,11 @@ env/
# IDE
.vscode/
.idea/
.qtcreator/
*.swp
*.swo
*~
docs
# OS
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
import type QmlProjectModel 1.0
QmlProject {
name: "ISC"
version: "1.0"
mainFile: "src/pygui/ISC/Main.qml"
dependencies {
module: "QtQuick"
minimumVersion: "6.0.0"
}
importPaths: [
"src/pygui"
]
}
-222
View File
@@ -1,222 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// ===== Home Workspace Shell =====
Rectangle {
id: root
anchors.fill: parent
color: Theme.pageBackground
clip: true
border.color: Theme.outerFrameBorder
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 =====
property int selectedTabIndex: 0
property int selectedSideActionIndex: -1 // nothing active on startup
// ===== 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
readonly property real computedButtonHeight: Math.min(Theme.sideButtonHeight, (height - (Theme.panelPadding * 2) - (Theme.sideRailSpacing * Math.max(0, actionCount - 1))) / Math.max(1, actionCount))
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: Theme.sideRailSpacing
Repeater {
model: root.sideActions
Button {
id: control
text: modelData
property bool isActive: index === root.selectedSideActionIndex
Layout.fillWidth: true
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight)
hoverEnabled: true
onClicked: {
root.selectedSideActionIndex = index
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
}
}
}
}
}
// Main workspace and footer navigation.
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.workspaceBackground
border.color: Theme.workspaceBorder
border.width: Theme.borderThin
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.mainAreaPadding
spacing: Theme.rightPaneGap
// ===== Active Tab Content Area =====
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 220
color: Theme.responseBackground
border.color: Theme.responseBorder
border.width: 1
radius: Theme.radiusMd
StackLayout {
anchors.fill: parent
currentIndex: root.selectedTabIndex
// ===== Tab Content Routing =====
Repeater {
model: root.bottomTabs
Item {
property string tabName: modelData
Loader {
anchors.fill: parent
active: parent.tabName === "Settings"
source: parent.tabName === "Settings" ? "Tabs/SettingsTab.qml" : ""
}
Loader {
anchors.fill: parent
active: parent.tabName === "Status"
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"
text: parent.tabName + " content"
color: Theme.bodyColor
font.pixelSize: 20
}
}
}
}
}
// Keep the tab strip evenly distributed across the footer.
// ===== Footer Tab Strip =====
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Theme.tabBarHeight + (Theme.tabBarPadding * 2)
color: Theme.tabBarBackground
border.color: Theme.workspaceBorder
border.width: Theme.borderThin
RowLayout {
anchors.fill: parent
anchors.margins: Theme.tabBarPadding
spacing: Theme.tabSpacing
Repeater {
model: root.bottomTabs
Button {
id: botTabBtn
property bool isActive: index === root.selectedTabIndex
text: modelData
Layout.fillWidth: true
Layout.preferredHeight: Theme.tabBarHeight
Layout.minimumWidth: Theme.tabButtonMinWidth
onClicked: root.selectedTabIndex = index
hoverEnabled: true
background: Rectangle {
color: botTabBtn.isActive ? Theme.tabActiveBackground : (botTabBtn.hovered ? Theme.tabHoverBackground : Theme.tabBackground)
border.color: Theme.tabBorder
border.width: 1
radius: Theme.tabRadius
}
contentItem: Text {
text: botTabBtn.text
color: botTabBtn.isActive ? Theme.tabActiveText : Theme.tabText
font.pixelSize: Theme.tabFontSize
font.bold: botTabBtn.isActive
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
}
}
}
}
}
-290
View File
@@ -1,290 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import ISC
// ===== Status Tab =====
// Blank on startup. Fills in after Detect Wafer fires from the left rail.
ColumnLayout {
id: root
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap
property alias waferFamilyCode: waferInfoFamily.text
property alias waferSerialNumber: waferSerial.text
property alias waferSensorCount: waferSensors.text
property alias waferRuntime: waferRuntime.text
property alias waferCycles: waferCycles.text
property bool waferDetected: false
// Latches true once any operation starts (or a previous session is
// restored) and never resets, so the status panel stays visible even
// when a detect finds no wafer.
property bool statusActive: false
// Data summary after parse
property int dataRows: 0
property int dataCols: 0
property string csvPath: ""
property bool dataParsed: false
// ===== Empty state — nothing shown until detect fires =====
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: !root.statusActive
}
// ===== Results area (shown once detect/operation runs) =====
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
visible: root.statusActive
spacing: Theme.rightPaneGap
// --- Connection Status ---
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 86
color: {
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor + "22"
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor + "22"
return Theme.cardBackground
}
border.color: {
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
return Theme.cardBorder
}
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
text: deviceController.connectionStatus
color: {
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
return Theme.bodyColor
}
font.bold: true
font.pixelSize: 16
Layout.fillWidth: true
}
Text {
text: "Port: " + (deviceController.selectedPort || "None selected")
color: Theme.bodyColor
font.pixelSize: 14
Layout.fillWidth: true
}
Text {
text: "Save dir: " + deviceController.saveDataDir
color: Theme.bodyColor
font.pixelSize: 12
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
}
// --- Wafer Info ---
GroupBox {
title: "Wafer Information"
visible: root.waferDetected
Layout.fillWidth: true
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: Theme.rightPaneGap
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Family Code"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferInfoFamily; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Serial Number"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferSerial; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Sensor Count"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferSensors; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.rightPaneGap
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Runtime"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferRuntime; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Cycles"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { id: waferCycles; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
}
}
}
// --- Data Summary (shown after parse) ---
GroupBox {
title: "Data Summary"
visible: root.dataParsed
Layout.fillWidth: true
RowLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Rows"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { text: String(root.dataRows); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Sensors"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { text: String(root.dataCols); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "CSV"; color: Theme.subheadingColor; font.pixelSize: 12 }
Text { text: root.csvPath; color: Theme.bodyColor; font.pixelSize: 12; elide: Text.ElideMiddle }
}
}
}
// --- Activity Log ---
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 {
target: deviceController
// Any operation start (detect/read/erase/debug) latches the panel on.
function onPortsUpdated() {
if (deviceController.operationInProgress)
root.statusActive = true
}
function onDetectResult(result) {
root.statusActive = true
if (result && result.familyCode) {
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() {
// Show restored wafer info if available
var info = deviceController.lastWaferInfo
if (info && info.length > 0) {
root.statusActive = true
root.waferDetected = true
waferInfoFamily.text = info[0] || "—"
waferSerial.text = info[1] || "—"
waferSensors.text = String(info[2] || 0)
waferRuntime.text = (info[3] || 0) + "s"
waferCycles.text = String(info[4] || 0)
}
// Show data summary if data was previously parsed
if (deviceController.dataRowCount > 0) {
root.statusActive = true
root.dataParsed = true
root.dataRows = deviceController.dataRowCount
root.dataCols = deviceController.dataColCount
root.csvPath = "" // CSV path not persisted as full path, just show it was parsed
}
}
}
Connections {
target: deviceController
function onReadResult(result) {
root.dataParsed = false
root.dataRows = 0
root.dataCols = 0
root.csvPath = ""
}
}
Connections {
target: deviceController
function onParsedDataReady(result) {
if (result && result.success) {
root.dataParsed = true
root.dataRows = result.rows
root.dataCols = result.cols
root.csvPath = result.csv_path || ""
} else {
root.dataParsed = false
}
}
}
}
-5
View File
@@ -1,5 +0,0 @@
# ===== ISC Tabs Module =====
module ISC.Tabs
# ===== Tab Components =====
SettingsTab 1.0 SettingsTab.qml
-129
View File
@@ -1,129 +0,0 @@
pragma Singleton
import QtQuick
// =============================================================================
// Theme — single source of truth for colors and sizing
// =============================================================================
// Sections:
// 1. Mode flag
// 2. Tone palette (raw tones; everything else derives from these)
// 3. Surfaces (page / card / panel / workspace backgrounds)
// 4. Borders
// 5. Text (headings, body, panel titles)
// 6. Controls (fields, buttons, checkbox)
// 7. Tracks (pill toggle + scrollbar)
// 8. Tabs (footer tab bar)
// 9. Side rail
// 10. Status (success / warning / error)
// 11. Geometry (radius / border width / spacing / sizing)
// =============================================================================
QtObject {
// ── 1. Mode ──────────────────────────────────────────────────────────────
property bool isDarkMode: false
// ── 2. Tone palette (base values; consume via semantic tokens below) ─────
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F2F2F2"
readonly property color tone300: isDarkMode ? "#242424" : "#E8E8E8"
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
readonly property color toneBorder: isDarkMode ? "#2B2B2B" : "#D8D8D8"
// ── 3. Surfaces ──────────────────────────────────────────────────────────
readonly property color pageBackground: tone100
readonly property color cardBackground: tone200
readonly property color panelBackground: tone200
readonly property color workspaceBackground: tone200
readonly property color responseBackground: tone200
readonly property color subtleSectionBackground: tone300
// ── 4. Borders ───────────────────────────────────────────────────────────
readonly property color cardBorder: toneBorder
readonly property color responseBorder: toneBorder
readonly property color workspaceBorder: toneBorder
readonly property color innerFrameBorder: toneBorder
readonly property color outerFrameBorder: isDarkMode ? "#343434" : "#CECECE"
readonly property color softBorder: isDarkMode ? "#222222" : "#E2E2E2"
// ── 5. Text ──────────────────────────────────────────────────────────────
readonly property color headingColor: toneText
readonly property color bodyColor: toneMute
readonly property color subheadingColor: toneMute
readonly property color panelTitleText: toneMute
readonly property color disabledText: toneMute
// ── 6. Controls ──────────────────────────────────────────────────────────
// Fields
readonly property color fieldBackground: tone100
readonly property color fieldText: toneText
readonly property color fieldPlaceholder: toneMute
readonly property color fieldBorder: toneBorder
readonly property color fieldBorderFocus: toneText
// Neutral buttons
readonly property color buttonNeutralBackground: tone300
readonly property color buttonNeutralHover: isDarkMode ? "#303030" : "#DDDDDD"
readonly property color buttonNeutralPressed: tone100
readonly property color buttonNeutralText: toneText
readonly property color buttonPressed: tone300
readonly property color primaryAccent: toneText
// Checkbox
readonly property color checkboxText: toneText
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
readonly property color trackBackground: isDarkMode ? "#25252B" : "#D1D5DB"
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
readonly property color tabBarBackground: tone200
readonly property color tabBackground: "transparent"
readonly property color tabActiveBackground: "transparent"
readonly property color tabHoverBackground: tone300
readonly property color tabBorder: "transparent"
readonly property color tabText: toneMute
readonly property color tabActiveText: toneText
// ── 9. Side rail ─────────────────────────────────────────────────────────
readonly property color sideRailBackground: tone200
readonly property color sideActiveBackground: tone300
// ── 10. Status ───────────────────────────────────────────────────────────
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545"
// ── 11. Geometry ─────────────────────────────────────────────────────────
// Radius
readonly property int radiusXs: 4 // fields, tight elements
readonly property int radiusSm: 6 // buttons
readonly property int radiusMd: 10 // cards, group boxes
readonly property int radiusLg: 14 // large panels / dialogs
// Border width
readonly property int borderThin: 1
readonly property int borderStrong: 2
// Main panel layout
readonly property int mainAreaPadding: 10
readonly property int rightPaneGap: 6
readonly property int panelPadding: 12
// Side rail layout
readonly property int sideRailWidth: 190
readonly property int sideRailSpacing: 14
readonly property int sideButtonHeight: 146
readonly property int sideButtonMinHeight: 88
// Tab bar layout
readonly property int tabBarHeight: 34
readonly property int tabBarPadding: 6
readonly property int tabSpacing: 2
readonly property int tabRadius: 2
readonly property int tabFontSize: 12
readonly property int tabButtonMinWidth: 80
// Settings page layout
readonly property int settingsPanelMaxWidth: 760
readonly property int settingsOuterMargin: 40
readonly property int settingsSectionSpacing: 20
readonly property int settingsRowSpacing: 16
readonly property int settingsGridSpacing: 12
readonly property int settingsButtonHeight: 40
readonly property int settingsActionWidth: 200
readonly property int settingsFieldMinWidth: 160
}
+23
View File
@@ -0,0 +1,23 @@
.PHONY: install test lint fix typecheck run clean
install:
uv sync
test:
uv run pytest tests/
lint:
uv run ruff check src/
fix:
uv run ruff check src/ --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 {} +
+154 -16
View File
@@ -2,48 +2,156 @@
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
- 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
## Setup
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
python3 -m venv .venv
source .venv/bin/activate
source .venv/bin/activate # or .venv\Scripts\activate on Windows
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e . # install the `pygui` package (src/ layout) in editable mode
```
## Run
### Launch PySide6 QML App
```bash
source .venv/bin/activate
python main.py
make run
```
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
This launches the Qt window and loads the `ISC` QML module from `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
- `main.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`.
- `ISC/Main.qml`: top-level window definition.
- `ISC/HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
- `ISC/Theme.qml`: shared theme constants and dark/light mode tokens.
- `ISC/qmldir`: QML module registration.
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/backend/`: Qt-facing models, controllers, and visualizers organized into subdirectories:
- `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).
- `Main.qml`: top-level window definition.
- `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
- `Theme.qml`: shared theme constants and dark/light mode tokens.
- `qmldir`: QML module registration.
- `tests/`: pytest suite.
- `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 dimensions and constraints are defined in `ISC/Main.qml`:
Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
- **Default size**: 1400 × 820 pixels
- **Minimum size**: 1100 × 700 pixels
- **Title bar**: "ISenseCloud"
To adjust the window, edit the `Window` block in `ISC/Main.qml`:
To adjust the window, edit the `Window` block in `src/pygui/ISC/Main.qml`:
```qml
Window {
@@ -58,16 +166,46 @@ Window {
## Customization
- Toggle dark/light mode in `ISC/Theme.qml` via `isDarkMode`.
- Update sidebar and footer labels in `ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
- Toggle dark/light mode in `src/pygui/ISC/Theme.qml` via `isDarkMode`.
- Update sidebar and footer labels in `src/pygui/ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
## 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
source .venv/bin/activate
uv sync
```
*Using `pip`:*
```bash
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt
```
- 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)
+44
View File
@@ -0,0 +1,44 @@
# This will be the name of the output files
name: aepwafer
wafers: ["A", "E", "P"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
4: { top: [0, 0] },
5: { top: [0, 0] },
6: { top: [0, 0] },
}
+46
View File
@@ -0,0 +1,46 @@
# This will be the name of the output files
name: bcdwafer
wafers: ["B", "C", "D"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
6: { left: [1, 0] },
}
+43
View File
@@ -0,0 +1,43 @@
# This will be the name of the output files
name: fwafer
wafers: ["F"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 200
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
}
+69
View File
@@ -0,0 +1,69 @@
# This will be the name of the output files
name: xwafer
wafers: ["X"]
# round or square wafer
shape: square
# diameter/edge size of the wafer
size: 310
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
133.29, 176.72, 176.72]
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
176.79, 133.36 ]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
21: { left: [0, 0] },
22: { left: [0, 0] },
23: { left: [0, 0] },
24: { left: [0, 0] },
25: { left: [0, 0] },
26: { left: [0, 0] },
27: { left: [0, 0] },
28: { left: [0, 0] },
29: { left: [0, 0] },
30: { left: [0, 0] },
31: { left: [0, 0] },
32: { left: [0, 0] },
33: { left: [0, 0] },
34: { left: [0, 0] },
35: { left: [0, 0] },
36: { left: [0, 0] }
}
+68
View File
@@ -0,0 +1,68 @@
# This will be the name of the output files
name: zwafer
wafers: ["Z"]
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
33: { left: [0, 0] },
34: { left: [0, 0] },
41: { top: [0, 0] },
42: { bottom: [0, 0] },
45: { top: [1, 0] },
46: { bottom: [0.5, 0] },
49: { left: [0, 0] },
50: { bottom: [-1.75, -0.5] },
51: { top: [-1, 0] },
52: { top: [-1, 0] },
53: { bottom: [0, 0] },
54: { left: [0, -0.75] },
55: { right: [0, 0.5] },
58: { bottom: [-0.75, 0] },
60: { top: [0, 0] },
62: { right: [0, 1] },
63: { right: [0, 0] },
64: { left: [0, 0.25] }
}
+71
View File
@@ -0,0 +1,71 @@
# This will be the name of the output files
name: zwafer_rev
wafers: [] # the reversed version doesn't represent any real wafer
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: true
reverse_y: true
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
37: { left: [0, 0] },
38: { left: [0, 0] },
39: { left: [0, 0] },
41: { top: [1.5, 0] },
42: { bottom: [1, 0] },
43: { left: [0, -1] },
45: { bottom: [-1, -1] },
46: { bottom: [0, -1] },
47: { right: [0, 1] },
48: { left: [0, 0] },
50: { bottom: [-1, 0] },
52: { top: [0, 0] },
54: { right: [0.5, 1] },
56: { left: [0, 0.5] },
57: { left: [0, 0] },
58: { top: [0.5, 0] },
59: { left: [0, 0] },
60: { top: [-1, 0] },
61: { left: [0, 0] },
62: { left: [0, -1] },
63: { right: [0, 1] },
}
-1
View File
@@ -1 +0,0 @@
# ===== Backend Package Marker =====
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

-45
View File
@@ -1,45 +0,0 @@
import sys
from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtWidgets import QApplication
from backend.device_controller import DeviceController
from backend.local_settings import LocalSettings
from backend.local_settings_model import LocalSettingsModel
from backend.file_browser import FileBrowser
# ===== Application Entry Point =====
if __name__ == "__main__":
# ===== UI Style Setup =====
# Use a non-native controls style so our custom QML button backgrounds are supported.
QQuickStyle.setStyle("Basic")
# ===== Qt Bootstrap =====
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
app = QApplication(sys.argv)
engine = QQmlApplicationEngine()
# ===== Shared Models =====
settings_model = LocalSettingsModel()
select_file_dialog_model = FileBrowser()
settings_model.loadSettings()
engine.rootContext().setContextProperty("settingsModel", settings_model)
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
# ===== Device Controller (serial comm) =====
data_dir = str(settings_model._data_dir)
raw_settings = LocalSettings.read_settings(data_dir)
device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== QML Startup =====
engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("ISC", "Main")
# ===== Exit Handling =====
if not engine.rootObjects():
sys.exit(-1)
sys.exit(app.exec())
+114 -1
View File
@@ -1,16 +1,129 @@
# ===== Build System =====
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
# ===== Project Metadata =====
[project]
name = "pygui"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"PySide6>=6.6.0",
"pyqtgraph>=0.13.0",
"numpy>=1.24.0",
"pyserial>=3.5",
"pandas>=2.0.0",
"matplotlib>=3.7.0",
"scipy>=1.17.1",
"pyyaml>=6.0.3",
"dtw-python",
]
[project.optional-dependencies]
dev = ["pytest"]
# ===== Console Entry Point =====
[project.scripts]
isc = "pygui.__main__:main"
# ===== Package Discovery (src layout) =====
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
# Ship the QML module alongside the Python package.
pygui = ["ISC/**/*.qml", "ISC/**/qmldir"]
# ===== PySide Build Inputs =====
[tool.pyside6-project]
files = ["ISC/HomePage.qml", "ISC/Main.qml", "ISC/Tabs/DataTab.qml", "ISC/Tabs/GraphTab.qml", "ISC/Tabs/SelectFileDialog.qml", "ISC/Tabs/SettingsTab.qml", "ISC/Tabs/StatusTab.qml", "ISC/Tabs/qmldir", "ISC/Theme.qml", "ISC/qmldir", "backend/data_model.py", "backend/device_controller.py", "backend/graph_view.py", "backend/file_browser.py", "backend/local_settings.py", "backend/local_settings_model.py", "main.py", "serialcomm/__init__.py", "serialcomm/data_parser.py", "serialcomm/serial_port.py", "serialcomm/device_service.py"]
files = [
"src/pygui/ISC/HomePage.qml",
"src/pygui/ISC/Main.qml",
"src/pygui/ISC/Theme.qml",
"src/pygui/ISC/qmldir",
"src/pygui/ISC/Tabs/DataTab.qml",
"src/pygui/ISC/Tabs/SelectFileDialog.qml",
"src/pygui/ISC/Tabs/SettingsTab.qml",
"src/pygui/ISC/Tabs/StatusTab.qml",
"src/pygui/ISC/Tabs/qmldir",
"src/pygui/__main__.py",
"src/pygui/backend/contour_models.py",
"src/pygui/backend/crypto/crypto_helper.py",
"src/pygui/backend/csv_file_metadata.py",
"src/pygui/backend/data_model.py",
"src/pygui/backend/data_segment.py",
"src/pygui/backend/device_controller.py",
"src/pygui/backend/file_browser.py",
"src/pygui/backend/frame.py",
"src/pygui/backend/graph_view.py",
"src/pygui/backend/local_settings.py",
"src/pygui/backend/local_settings_model.py",
"src/pygui/backend/marching_squares.py",
"src/pygui/backend/zwafer_models.py",
"src/pygui/backend/zwafer_parser.py",
"src/pygui/backend/frame_stats.py",
"src/pygui/backend/threshold_classifier.py",
"src/pygui/backend/stability_detector.py",
"src/pygui/backend/csv_recorder.py",
"src/pygui/backend/frame_player.py",
"src/pygui/backend/stream_reader.py",
"src/pygui/backend/session_model.py",
"src/pygui/backend/session_controller.py",
"src/pygui/backend/wafer_layouts.py",
"src/pygui/backend/rbf_heatmap.py",
"src/pygui/backend/wafer_map_item.py",
"src/pygui/serialcomm/__init__.py",
"src/pygui/serialcomm/data_parser.py",
"src/pygui/serialcomm/device_service.py",
"src/pygui/serialcomm/serial_port.py",
"src/pygui/ISC/Tabs/WaferMapTab.qml",
"src/pygui/ISC/Tabs/components/WaferMapView.qml",
"src/pygui/ISC/Tabs/components/SourcePanel.qml",
"src/pygui/ISC/Tabs/components/ReadoutPanel.qml",
"src/pygui/ISC/Tabs/components/TransportBar.qml",
"src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml",
"src/pygui/ISC/Tabs/components/qmldir",
]
[dependency-groups]
dev = [
"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
+4
View File
@@ -3,3 +3,7 @@ PySide6>=6.6.0
pyqtgraph>=0.13.0
numpy>=1.24.0
pyserial>=3.5
pandas>=2.0.0
matplotlib>=3.7.0
scipy>=1.10.0
pyyaml>=6.0
+137
View File
@@ -0,0 +1,137 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 420
height: 340
anchors.centerIn: Overlay.overlay
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 16
// ── Title ──
Label {
text: "About"
font.pixelSize: Theme.font2xl
font.bold: true
color: Theme.headingColor
}
// ── App info ──
ColumnLayout {
spacing: 4
Label {
text: "ISenseCloud (ISC)"
font.pixelSize: Theme.fontLg
color: Theme.headingColor
}
Label {
text: "Version 0.1.0"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
}
Label {
text: "Temperature-sensing wafer monitoring"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
// ── Separator ──
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// ── License grid ──
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 100
radius: Theme.radiusSm
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 2
Label {
text: "LICENSE"
color: Theme.panelTitleText
font.pixelSize: Theme.fontXs
font.letterSpacing: 0.5
}
// Grid header
RowLayout {
Layout.fillWidth: true
spacing: 4
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.softBorder
}
Label {
text: "No license loaded"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
Layout.fillWidth: true
Layout.fillHeight: true
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
}
// ── Close ──
Button {
text: "Close"
Layout.alignment: Qt.AlignRight
Layout.preferredWidth: 100
Layout.preferredHeight: 32
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
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
+645
View File
@@ -0,0 +1,645 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
import QtQuick.Dialogs
// ===== 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 {
id: root
anchors.fill: parent
color: Theme.pageBackground
clip: true
border.color: Theme.outerFrameBorder
border.width: Theme.borderStrong
// ===== View State =====
property int selectedTabIndex: 0
property bool memoryRead: false
property bool waferDetected: false
Connections {
target: deviceController
function onDetectResult(result){
root.waferDetected = (result != null && result != undefined)
}
function onParsedDataReady(result) {
if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path)
root.selectedTabIndex = 2
}
}
}
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")
}
}
}
function cleanFolderUrl(url) {
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
}
function _doDetect() {
root.memoryRead = false
streamController.setMode("review")
streamController.stopStream()
deviceController.detectWafer()
}
FolderDialog {
id: saveDirDialog
title: "Choose a folder to save Data"
onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
root._doDetect()
}
}
// ===== 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 {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 40
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
RowLayout {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 4
Label {
text: "Settings"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "\u2715"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: settingsPopup.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.rightMargin: 6
clip: true
Loader {
anchors.fill: parent
source: "Tabs/SettingsTab.qml"
}
}
}
}
}
// ===== 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
border.color: Theme.sideBorder
border.width: 1
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 }
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 }
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 }
}
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 }
}
}
}
onClicked: root.selectedTabIndex = index
}
}
}
}
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 8
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: root.selectedTabIndex
// ── Status tab: Hardware Actions ────────────────
ColumnLayout {
spacing: 6
Label {
text: "HARDWARE ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "DETECT WAFER"
iconSource: "../icons/detect.svg"
Layout.fillWidth: true
enabled: !deviceController.isDetecting
onClicked: root._doDetect()
}
RailActionButton {
label: "READ MEMORY"
iconSource: "../icons/read.svg"
Layout.fillWidth: true
enabled: root.waferDetected
onClicked: deviceController.readMemoryAsync()
}
RailActionButton {
label: "ERASE MEMORY"
iconSource: "../icons/erase.svg"
Layout.fillWidth: true
destructive: true
enabled: root.waferDetected
onClicked: deviceController.eraseMemory(
deviceController.selectedPort)
}
Item { Layout.fillHeight: true }
}
// ── Data tab: Data Operations ───────────────────
ColumnLayout {
spacing: 6
Label {
text: "DATA OPERATIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "OPEN IN EXCEL"
iconSource: "../icons/excel.svg"
Layout.fillWidth: true
onClicked: deviceController.openCsvFile()
}
RailActionButton {
label: "COMPARE CSV"
iconSource: "../icons/compare.svg"
Layout.fillWidth: true
onClicked: console.log("[P3.1] Compare dialog not yet built")
}
RailActionButton {
label: "SPLIT DATA"
iconSource: "../icons/split.svg"
Layout.fillWidth: true
onClicked: console.log("[P3.2] Split dialog not yet built")
}
Item { Layout.fillHeight: true }
}
// ── Map tab: Source file browser ──────────────
SourcePanel {
Layout.fillWidth: true
Layout.fillHeight: true
}
}
}
}
// ── BOX 3: Hardware Status Footer ──────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
// Row 1: Title + status badge (unchanged)
RowLayout {
spacing: 8
Layout.fillWidth: true
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.2
font.bold: true
}
Rectangle {
height: 18
width: badgeLabel.implicitWidth + 14
radius: 9
color: {
var s = deviceController.connectionStatus
if (s === "Connected") return Theme.statusSuccessColor
if (s === "Disconnected") return Theme.statusErrorColor
return Theme.statusWarningColor
}
Label {
id: badgeLabel
anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected"
color: "#111111"
font.pixelSize: Theme.font2xs
font.bold: true
}
}
Item { Layout.fillWidth: true }
}
// Row 2: Status dot + descriptive text (replaces dotted trail)
Row {
spacing: 8
Layout.fillWidth: true
Rectangle {
id: statusDot
anchors.verticalCenter: parent.verticalCenter
width: 8
height: 8
radius: 4
color: deviceController.connectionStatus === "Connected"
? Theme.statusSuccessColor : Theme.statusErrorColor
SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected"
loops: Animation.Infinite
NumberAnimation { to: 0.4; duration: 1200 }
NumberAnimation { to: 1.0; duration: 1200 }
}
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: {
if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected"
if (deviceController.selectedPort)
return deviceController.selectedPort
return "No port selected"
}
color: Theme.bodyColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
elide: Text.ElideRight
}
}
// Row 3: Mode chip — neutral gray tag
Row {
spacing: 6
Layout.fillWidth: true
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Label {
id: modeChipLabel
anchors.centerIn: parent
text: streamController.mode
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.8
}
}
}
}
}
// ── BOX 4: Utility Buttons ─────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterUtility
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
Button {
id: settingsBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "Tabs/icons/settings.svg"
onClicked: settingsPopup.open()
background: Rectangle {
radius: 8
color: settingsBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "Tabs/icons/settings.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "SETTINGS"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
Rectangle {
width: 1
height: 24
color: Theme.sideBorder
}
Button {
id: aboutBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "Tabs/icons/about.svg"
onClicked: aboutDialog.open()
background: Rectangle {
radius: 8
color: aboutBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "Tabs/icons/about.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "ABOUT"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
}
}
}
}
// ── WORKSPACE ─────────────────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.workspaceBackground
border.color: Theme.workspaceBorder
border.width: 1
StackLayout {
anchors.fill: parent
anchors.margins: Theme.mainAreaPadding
currentIndex: root.selectedTabIndex
Loader {
source: "Tabs/StatusTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
Loader {
source: "Tabs/DataTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
Loader {
source: "Tabs/WaferMapTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
}
}
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ import ISC
// ===== App Window Shell =====
Window {
// ===== Window Dimensions =====
width: 1400
height: 820
width: 1920
height: 1080
minimumWidth: 1100
minimumHeight: 700
visible: true
@@ -17,7 +17,7 @@ Item {
target: deviceController
function onParsedDataReady(result) {
if (result && result.success) {
dataTable.forceLayout()
dataTable.forceLayout();
}
}
}
@@ -30,7 +30,7 @@ Item {
Label {
text: "No Data"
font.pixelSize: 24
font.pixelSize: Theme.font2xl
font.bold: true
color: Theme.headingColor
anchors.horizontalCenter: parent.horizontalCenter
@@ -38,7 +38,7 @@ Item {
Label {
text: "Read and parse wafer data to see temperature readings here."
font.pixelSize: 14
font.pixelSize: Theme.fontMd
color: Theme.bodyColor
anchors.horizontalCenter: parent.horizontalCenter
wrapMode: Text.WordWrap
@@ -59,21 +59,48 @@ Item {
Label {
text: "Temperature Data"
font.pixelSize: 18
font.pixelSize: Theme.fontXl
font.bold: true
color: Theme.headingColor
}
Item { Layout.fillWidth: true }
Item {
Layout.fillWidth: true
}
Label {
text: deviceController.dataRowCount + " rows × " +
deviceController.dataColCount + " sensors"
font.pixelSize: 13
text: deviceController.dataRowCount + " rows × " + deviceController.dataColCount + " sensors"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
}
}
// --- Toolbar ---
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
text: "Open in Excel"
icon.source: "icons/excel.svg"
icon.color: Theme.headingColor
onClicked: deviceController.openCsvFile()
}
Button {
text: "Compare"
onClicked: compareDialog.open()
}
Button {
text: "Split"
onClicked: splitDialog.open()
}
Item {
Layout.fillWidth: true
}
}
// --- Table container ---
Rectangle {
Layout.fillWidth: true
@@ -107,7 +134,7 @@ Item {
// 'display' is the Qt.DisplayRole value from headerData()
text: display !== undefined ? display : ""
color: Theme.headingColor
font.pixelSize: 11
font.pixelSize: Theme.fontXs
font.bold: true
elide: Text.ElideRight
}
@@ -123,14 +150,17 @@ Item {
clip: true
reuseItems: true
columnWidthProvider: function(col) {
if (col === 0) return 52 // row index column
const sensorCols = Math.max(1, dataTable.columns - 1)
const available = dataTable.width - 52
return Math.max(52, Math.min(90, available / sensorCols))
columnWidthProvider: function (col) {
if (col === 0)
return 52; // row index column
const sensorCols = Math.max(1, dataTable.columns - 1);
const available = dataTable.width - 52;
return Math.max(52, Math.min(90, available / sensorCols));
}
rowHeightProvider: function() { return 24 }
rowHeightProvider: function () {
return 24;
}
delegate: Rectangle {
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
@@ -139,13 +169,17 @@ Item {
anchors.centerIn: parent
text: display !== undefined ? display : ""
color: Theme.bodyColor
font.pixelSize: 11
font.pixelSize: Theme.fontXs
elide: Text.ElideRight
}
}
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AsNeeded
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
}
}
}
+222
View File
@@ -0,0 +1,222 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Graph Tab =====
// Displays sensor temperature line charts using GraphQuickItem.
// Gets data from deviceController.getChartData() (parsed CSV, batch read)
// or from streamController.trendData (live streaming trend).
Item {
id: root
anchors.fill: parent
property var _chartData: null // cached result from getChartData()
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap
// ── Toolbar ───────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "Line Chart"
color: Theme.headingColor
font.pixelSize: Theme.fontSm
font.bold: true
Layout.alignment: Qt.AlignVCenter
}
Item {
Layout.fillWidth: true
}
// Refresh button — pulls data from deviceController
Button {
id: refreshBtn
text: "REFRESH"
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
implicitHeight: 26
implicitWidth: 72
hoverEnabled: true
background: Rectangle {
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: reloadData()
}
}
// ── Mode selector (Trend / Full chart) ─────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: "Source:"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
Layout.alignment: Qt.AlignVCenter
}
ComboBox {
id: sourceSelector
model: ["Parsed Data", "Live Trend"]
currentIndex: 0
implicitHeight: 26
font.pixelSize: Theme.fontXs
background: Rectangle {
color: Theme.fieldBackground
border.color: Theme.fieldBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: sourceSelector.displayText
color: Theme.fieldText
font: sourceSelector.font
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
leftPadding: 8
}
onCurrentIndexChanged: reloadData()
}
Item {
Layout.fillWidth: true
}
Label {
id: statusLabel
text: "Ready"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
font.italic: true
Layout.alignment: Qt.AlignVCenter
}
}
// ── Chart Area ─────────────────────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
GraphQuickItem {
id: graph
anchors.fill: parent
anchors.margins: 2
backgroundColor: Theme.cardBackground
gridColor: Theme.softBorder
axisColor: Theme.bodyColor
textColor: Theme.headingColor
seriesColors: [Theme.sensorLow, Theme.sensorHigh, Theme.sensorInRange, "#FFA726", "#AB47BC", "#00BCD4"]
showLegend: true
}
}
}
// ── Data Loading ────────────────────────────────────────────────────────────
function reloadData() {
if (sourceSelector.currentIndex === 0) {
// Parsed data from DeviceController
loadParsedData();
} else {
// Live trend from SessionController
loadLiveTrend();
}
}
function loadParsedData() {
statusLabel.text = "Loading...";
var result = deviceController.getChartData();
if (!result || !result.success) {
graph.seriesData = [];
graph.sensorNames = [];
graph.title = "Sensor Temperature Over Time";
statusLabel.text = "No data — read a wafer first";
return;
}
graph.seriesData = result.series || [];
graph.sensorNames = result.sensor_names || [];
graph.title = "Sensor Temperature Over Time";
statusLabel.text = (result.series ? result.series.length : 0) + " sensors loaded";
}
function loadLiveTrend() {
// Live trend: connects to streamController.trendData
// The trend data comes as a JSON array of per-frame averages
statusLabel.text = "Connecting to live trend...";
// We need at least one data point
if (streamController.stats && streamController.stats.avg !== undefined) {
// Build a single-series graph from the trend buffer
// For now show a placeholder — the trendData signal handles updates
graph.title = "Live Average Temperature";
graph.yLabel = "Avg Temperature (°C)";
graph.xLabel = "Time (s)";
statusLabel.text = "Live trend — waiting for data...";
} else {
statusLabel.text = "No live data — start a stream on the Wafer Map tab";
}
}
// ── Connections ─────────────────────────────────────────────────────────────
Connections {
target: deviceController
function onParsedDataReady(result) {
if (sourceSelector.currentIndex === 0) {
root.reloadData();
}
}
}
Connections {
target: streamController
function onTrendData(avgsJson) {
if (sourceSelector.currentIndex === 1) {
try {
var avgs = JSON.parse(avgsJson);
if (avgs && avgs.length > 0) {
graph.seriesData = [avgs];
graph.sensorNames = ["Average"];
graph.title = "Live Average Temperature";
graph.yLabel = "Avg Temperature (°C)";
graph.xLabel = "Frame";
statusLabel.text = "Live: " + avgs.length + " data points";
}
} catch (e) {
// ignore parse errors
}
}
}
}
// On startup, try to load parsed data if available
Component.onCompleted: {
if (deviceController.dataRowCount > 0) {
reloadData();
}
}
}
@@ -24,9 +24,11 @@ Dialog {
property int selectedRow: -1
property int metadataEditRow: -1
readonly property string masterTypeFieldText: {
if (metadataEditRow < 0) return "";
if (metadataEditRow < 0)
return "";
const rec = tableModel[metadataEditRow];
if (!rec) return "";
if (!rec)
return "";
return rec.masterType ?? "";
}
@@ -76,7 +78,7 @@ Dialog {
readonly property var normalizedRows: {
// Touch settingsModel.masters so QML tracks this dependency.
const _ = settingsModel.masters;
return (tableModel || []).map(function(row) {
return (tableModel || []).map(function (row) {
row = row || {};
// settingsModel is the authoritative source; fall back to sidecar masterType.
const masterType = root.masterFamilyForPath(row.fileName ?? "") || row.masterType || "";
@@ -113,7 +115,7 @@ Dialog {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
font.bold: true
font.pixelSize: 13
font.pixelSize: Theme.fontSm
}
}
@@ -129,6 +131,12 @@ Dialog {
color: Theme.fieldBackground
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
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 +171,7 @@ Dialog {
anchors.left: parent.left
anchors.leftMargin: 16
text: root.title
font.pixelSize: 15
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
}
@@ -221,7 +229,7 @@ Dialog {
text: file_browser.currentDirectory
color: Theme.fieldText
elide: Text.ElideMiddle
font.pixelSize: 13
font.pixelSize: Theme.fontSm
}
}
}
@@ -242,7 +250,7 @@ Dialog {
anchors.centerIn: parent
text: root.headerTitles[index] ?? ""
font.bold: true
font.pixelSize: 12
font.pixelSize: Theme.fontSm
color: Theme.headingColor
}
}
@@ -329,7 +337,7 @@ Dialog {
const mt = root.normalizedRows[row]?.masterType ?? "";
return mt || "—";
}
font.pixelSize: 14
font.pixelSize: Theme.fontMd
font.bold: true
color: {
const mt = root.normalizedRows[row]?.masterType ?? "";
@@ -346,7 +354,7 @@ Dialog {
sourceComponent: Button {
hoverEnabled: true
text: "Edit…"
font.pixelSize: 12
font.pixelSize: Theme.fontSm
onClicked: root.openMetadataEditor(row)
background: Rectangle {
@@ -361,7 +369,7 @@ Dialog {
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.pixelSize: 12
font.pixelSize: Theme.fontSm
}
}
}
@@ -388,7 +396,7 @@ Dialog {
return "";
}
elide: Text.ElideRight
font.pixelSize: 13
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
}
}
@@ -409,7 +417,7 @@ Dialog {
return record.notes ?? "";
}
elide: Text.ElideRight
font.pixelSize: 13
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
}
}
@@ -456,7 +464,7 @@ Dialog {
verticalAlignment: Text.AlignVCenter
text: root.selectedPath
elide: Text.ElideLeft
font.pixelSize: 13
font.pixelSize: Theme.fontSm
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
}
}
@@ -504,7 +512,7 @@ Dialog {
anchors.left: parent.left
anchors.leftMargin: 16
text: metadataEditDialog.title
font.pixelSize: 15
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
}
@@ -530,7 +538,7 @@ Dialog {
maximumLineCount: 3
elide: Text.ElideRight
color: Theme.bodyColor
font.pixelSize: 12
font.pixelSize: Theme.fontSm
}
Label {
@@ -27,8 +27,7 @@ Item {
}
// ===== 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) {
const table = settingsModel.masters;
return table && table[family] ? table[family] : "";
@@ -58,7 +57,7 @@ Item {
label: Label {
text: settingsGroup.title
color: Theme.panelTitleText
font.pixelSize: 16
font.pixelSize: Theme.fontLg
font.bold: true
}
}
@@ -75,6 +74,12 @@ Item {
color: Theme.fieldBackground
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
}
}
@@ -107,25 +112,39 @@ Item {
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : Theme.fieldBackground
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Rectangle {
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
Text {
anchors.centerIn: parent
width: 9
height: 9
radius: Theme.radiusXs
visible: toggle.checked
color: Theme.fieldBackground
text: "✓"
font.pixelSize: Theme.fontMd
font.bold: true
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
// Small nudge to center the checkmark perfectly
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
contentItem: Text {
text: toggle.text
color: Theme.checkboxText
color: Theme.headingColor
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: Theme.fontSm
}
}
@@ -155,6 +174,7 @@ Item {
anchors.top: settingsScroll.top
anchors.right: settingsScroll.right
anchors.bottom: settingsScroll.bottom
anchors.leftMargin: 6
policy: ScrollBar.AsNeeded
contentItem: Rectangle {
@@ -170,16 +190,19 @@ Item {
}
}
// Gap reserved between the centered content and the right-edge scrollbar.
readonly property int scrollGap: 100
ColumnLayout {
id: container
width: Math.min(settingsScroll.availableWidth, Theme.settingsPanelMaxWidth)
width: Math.min(settingsScroll.availableWidth - settingsScroll.scrollGap, Theme.settingsPanelMaxWidth)
x: (settingsScroll.availableWidth - width) / 2
spacing: Theme.settingsSectionSpacing
// ===== Page Title =====
Label {
text: "Settings"
font.pixelSize: 30
font.pixelSize: Theme.font3xl
font.bold: true
color: Theme.headingColor
}
@@ -221,7 +244,7 @@ Item {
Layout.preferredWidth: 90
Layout.preferredHeight: Theme.settingsButtonHeight
onClicked: {
settingsModel.setChamberId(chamberField.text);
settingsModel.ChamberId = chamberField.text;
settingsModel.saveSettings();
}
}
@@ -237,11 +260,24 @@ Item {
anchors.fill: parent
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 {
model: root.masterFamilies
model: masterModel
RowLayout {
property string family: modelData
property string family: model.letter
Layout.fillWidth: true
spacing: Theme.settingsRowSpacing
@@ -292,7 +328,7 @@ Item {
SettingsToggle {
text: "Reverse Z Wafer"
checked: settingsModel.reverseZWafer
onToggled: settingsModel.setReverseZWafer(checked)
onToggled: settingsModel.reverseZWafer = checked
}
}
}
@@ -357,7 +393,7 @@ Item {
Label {
text: Theme.isDarkMode ? "Dark" : "Light"
color: Theme.bodyColor
font.pixelSize: 13
font.pixelSize: Theme.fontSm
Layout.alignment: Qt.AlignVCenter
Behavior on color {
@@ -419,7 +455,7 @@ Item {
Label {
anchors.centerIn: parent
text: "↓ Scroll for more"
font.pixelSize: 13
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.bodyColor
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
+634
View File
@@ -0,0 +1,634 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
import ISC
// ===== Status Tab — Bento Grid Dashboard =====
// 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 {
id: root
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 8
// ── Reusable Inline Components ──
component StatCard : Rectangle {
property alias value: valueText.text
property alias label: labelText.text
property bool active: root.waferDetected
property int valSize: active ? Theme.font3xl : Theme.font2xl
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.centerIn: parent
spacing: 4
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: valSize
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
}
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
}
}
}
component GridCell : ColumnLayout {
property alias label: labelText.text
property alias value: valueText.text
property bool isRight: false
property bool active: root.waferDetected
spacing: 1
Layout.alignment: isRight ? Qt.AlignRight : Qt.AlignLeft
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
Layout.alignment: isRight ? Qt.AlignRight : Qt.AlignLeft
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: isRight ? Qt.AlignRight : Qt.AlignLeft
}
}
// ── 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] !== "";
}
property int dataRows: 0
property int dataCols: 0
property string csvPath: ""
property bool dataParsed: false
readonly property bool isConnected: deviceController.connectionStatus === "Connected"
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
readonly property string portName: deviceController.selectedPort || "—"
function cleanFolderUrl(url) {
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 || "");
}
FolderDialog {
id: saveDirDialog
title: "Choose a folder to save."
onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder));
root.parseAndSavePendingRead();
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROW 1: Connection Status (100%)
// ═══════════════════════════════════════════════════════════════════════
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
id: connStatusCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.panelPadding
anchors.rightMargin: Theme.panelPadding
spacing: 8
Text {
text: "CONNECTION STATUS"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
}
RowLayout {
Layout.fillWidth: true
spacing: 0
ColumnLayout {
spacing: 1
Text {
text: "HOST PC"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
}
Text {
text: "localhost"
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
}
}
Item {
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: "#111111"
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 }
}
}
}
}
}
ColumnLayout {
spacing: 1
Text {
text: "COM PORT"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
}
Text {
text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
}
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
// ═══════════════════════════════════════════════════════════════════════
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
RowLayout {
Layout.preferredWidth: 65
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
}
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
id: browseBtn
text: "Browse"
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.family: Theme.uiFontFamily
implicitHeight: 24
implicitWidth: 60
hoverEnabled: true
background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: browseBtn.text
color: Theme.bodyColor
font: browseBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: saveDirDialog.open()
}
Item { Layout.fillWidth: true }
Row {
spacing: 4
visible: root.isConnected
Rectangle {
width: 6; height: 6; radius: 3
color: Theme.statusSuccessColor
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
visible: root.csvPath !== ""
width: sessionSavedLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Text {
id: sessionSavedLabel
anchors.centerIn: parent
text: "SESSION SAVED"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 8
Item {
Layout.fillWidth: true
Layout.fillHeight: true
GridLayout {
anchors.fill: parent
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: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
isRight: true
active: root.waferDetected
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
isRight: true
}
}
}
}
}
}
StatCard {
id: runtimeCard
Layout.preferredWidth: 35
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—";
}
label: "TOTAL RUNTIME"
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROW 3: Activity Log — ALWAYS visible (fills remaining height)
// ═══════════════════════════════════════════════════════════════════════
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.panelPadding
anchors.rightMargin: Theme.panelPadding
spacing: 8
Text {
text: "📄"
font.pixelSize: Theme.fontSm
visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter
}
Label {
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
color: Theme.bodyColor
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
font.weight: Font.Medium
font.family: Theme.uiFontFamily
elide: Text.ElideMiddle
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
Button {
id: refreshSessionBtn
text: "Refresh"
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: refreshSessionBtn.text
color: Theme.bodyColor
font: refreshSessionBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearSession()
}
Button {
id: clearLogBtn
text: "Clear"
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: clearLogBtn.text
color: Theme.bodyColor
font: clearLogBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearActivityLog()
}
}
Rectangle {
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
text: 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
}
}
}
Connections {
target: deviceController
function onActivityLogUpdated(newLog) {
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// 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 {
target: deviceController
function onReadResult(result) {
root.dataParsed = false;
root.dataRows = 0;
root.dataCols = 0;
root.csvPath = "";
if (result && result.success === true)
saveDirDialog.open();
}
}
Connections {
target: deviceController
function onParsedDataReady(result) {
if (result && result.success) {
root.dataParsed = true;
root.dataRows = result.rows;
root.dataCols = result.cols;
root.csvPath = result.csv_path || "";
} else {
root.dataParsed = false;
}
}
}
}
+312
View File
@@ -0,0 +1,312 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
import ISC.Wafer
Item {
id: root
anchors.fill: parent
// Live elapsed-time counter
property int _liveSecs: 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;
}
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
// into the trend chart's data property. The slot parseJsonToData() is defined
// on the Python TrendChartItem; we use it so malformed payloads are logged
// there instead of crashing the QML binding.
Connections {
target: streamController
function onTrendData(avgsJson) {
trendChart.setDataFromJson(avgsJson);
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 16
// ── Toolbar ───────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
// Mode toggle
TabBar {
id: modeBar
currentIndex: 0 // Default to "Review"
spacing: 2
padding: 2
background: Rectangle {
color: Theme.subtleSectionBackground
radius: Theme.radiusSm
border.color: Theme.cardBorder
border.width: Theme.borderThin
}
TabButton {
text: "Review"
implicitWidth: 64
implicitHeight: 28
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
text: "Live"
enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
implicitWidth: 58
implicitHeight: 28
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
onCurrentIndexChanged: if (currentIndex === 1) {
// Guard: revert to Review if not connected
if (deviceController.connectionStatus !== "Connected") {
currentIndex = 0;
return;
}
// Entering Live mode
streamController.setMode("live");
var fc = "";
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
fc = deviceController.lastWaferInfo[0];
}
streamController.startStream(deviceController.selectedPort, fc);
} else {
// Entering Review Mode (automatically calls stopStream in backend)
streamController.setMode("review");
}
}
// Live source info: WiFi icon + port · serial
RowLayout {
visible: streamController.mode === "live"
spacing: 5
// 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: Theme.fontSm
SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation {
to: 0.2
duration: 600
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1.0
duration: 600
easing.type: Easing.InOutQuad
}
onRunningChanged: {
if (!running)
liveIndicator.opacity = 1.0;
}
}
}
Label {
text: "Live stream"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
}
Item {
Layout.fillWidth: true
}
// SET badge + avg temp
Rectangle {
visible: streamController.state === "set"
height: 22
radius: Theme.radiusSm
color: "transparent"
border.color: Theme.liveColor
border.width: 1
implicitWidth: setLabel.implicitWidth + 16
Label {
id: setLabel
anchors.centerIn: parent
text: "SET " + (streamController.stats.avg !== undefined ? streamController.stats.avg + " avg" : "")
color: Theme.liveColor
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 0.8
}
}
// Record indicator
RowLayout {
spacing: 5
visible: streamController.recording
Rectangle {
width: 7
height: 7
radius: 4
color: Theme.recordColor
SequentialAnimation on opacity {
running: streamController.recording
loops: Animation.Infinite
NumberAnimation {
to: 0.2
duration: 600
}
NumberAnimation {
to: 1.0
duration: 600
}
}
}
Label {
text: "REC"
color: Theme.recordColor
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 1.2
}
}
// LIVE timer
// RowLayout {
// spacing: 5
// visible: streamController.mode === "live" && streamController.state !== "idle"
// Rectangle {
// width: 7; height: 7; radius: 4
// color: Theme.liveColor
// }
// Label {
// text: "LIVE " + root.fmtTime(root._liveSecs)
// color: Theme.liveColor
// font.pixelSize: Theme.fontXs
// font.weight: Font.Medium
// font.letterSpacing: 0.8
// }
// }
// IDLE label (review / not connected)
// Label {
// visible: streamController.mode === "review" || streamController.state === "idle"
// text: streamController.state.toUpperCas`e`()
// color: Theme.bodyColor
// font.pixelSize: Theme.fontXs
// font.weight: Font.Medium
// font.letterSpacing: 1.2
// }
Button {
text: "Export PNG"
onClicked: exportDialog.open()
}
FileDialog {
id: exportDialog
title: "Export Wafer Map"
fileMode: FileDialog.SaveFile
nameFilters: ["PNG files (*.png)"]
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
}
}
// ── Body: 2 columns (wafer + trend + transport | readout) ──────────
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8
WaferMapView {
id: waferView
Layout.fillWidth: true
Layout.fillHeight: true
blend: readoutPanel.heatmapBlend
showLabels: readoutPanel.showLabels
showThickness: readoutPanel.showThickness
}
Rectangle {
id: trendPane
Layout.fillWidth: true
implicitHeight: 160
visible: trendChart.hasData && streamController.mode === "live"
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
TrendChartItem {
id: trendChart
anchors.fill: parent
anchors.margins: 8
}
}
Item {
Layout.fillWidth: true
implicitHeight: 16
}
TransportBar {
Layout.fillWidth: true
}
}
// Readout panel — floating wrapper box
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
color: "transparent"
border.color: "transparent"
border.width: 0
clip: true
ReadoutPanel {
id: readoutPanel
anchors.fill: parent
anchors.margins: 0
}
}
}
}
}
@@ -0,0 +1,70 @@
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
property color _textColor: !root.enabled ? Theme.sideMutedText
: root.destructive ? Theme.statusErrorColor
: Theme.headingColor
signal clicked()
implicitHeight: Theme.sideButtonHeight
radius: 8
color: 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()
}
}
}
@@ -0,0 +1,510 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// ===== Readout Panel =====
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS.
// Cards match the left-rail SOURCE / CONNECTION FLOW style.
ColumnLayout {
spacing: 6
property var s: streamController.stats
property alias showLabels: labelsToggle.checked
property alias heatmapBlend: heatmapSlider.value
property alias showThickness: thicknessToggle.checked
component PanelCheckBox: CheckBox {
id: toggle
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
Text {
anchors.centerIn: parent
text: "✓"
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
component BadgePill: Rectangle {
implicitWidth: badgeLabel.implicitWidth + 10
implicitHeight: 10
radius: 9
color: Theme.fieldBackground
border.color: Theme.cardBorder
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
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: readoutCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout {
id: readoutCard
anchors {
left: parent.left
right: parent.right
top: parent.top
topMargin: 12
}
spacing: 0
// Header Inside the Box
Label {
text: "READOUT"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.topMargin: 2
Layout.bottomMargin: 10
}
// Min Temp Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MIN TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
Label {
visible: (s.minIndex !== undefined) && s.minIndex >= 0
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Max Temp Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MAX TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
Label {
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Differential Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "DIFF"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.isDarkMode ? "#A78BFA" : "#8B5CF6"
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Average Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "AVERAGE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "SIGMA (Σ)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 3-Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "3Σ VALUE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: displayCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout {
id: displayCard
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 12
}
spacing: 8
Label {
text: "DISPLAY"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
PanelCheckBox {
id: thicknessToggle
text: "Show Thickness"
checked: false
font.pixelSize: Theme.fontXs
}
PanelCheckBox {
id: labelsToggle
text: "Labels"
checked: true
font.pixelSize: Theme.fontXs
}
PanelCheckBox {
id: clusterAverageToggle
text: "Average Clusters"
checked: streamController.clusterAveragingEnabled
font.pixelSize: Theme.fontXs
onCheckedChanged: streamController.clusterAveragingEnabled = checked
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Heatmap"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
Layout.preferredWidth: 52
}
Slider {
id: heatmapSlider
from: 0
to: 1
value: 0
Layout.fillWidth: true
ToolTip.visible: hovered
ToolTip.text: Math.round(value * 100) + "%"
handle: Rectangle {
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: heatmapSlider.pressed ? 1.15 : 1.0
Behavior on scale {
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
}
}
}
Label {
text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: thresholdCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout {
id: thresholdCard
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 12
}
spacing: 6
Label {
text: "THRESHOLDS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
Label {
text: "Set Point (°C)"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
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.fontXs
}
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.fontXs
onCheckedChanged: pushThresholds()
}
}
}
Item {
Layout.fillHeight: true
}
function pushThresholds() {
var sp = parseFloat(spField.text) || 149.0;
var mg = parseFloat(mgField.text) || 1.0;
streamController.setThresholds(sp, mg, autoCheck.checked);
}
}
@@ -0,0 +1,235 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
Dialog {
id: root
title: "Sensor Override"
modal: true
standardButtons: Dialog.NoButton
width: 300
// called by WaferMapView's TapHandler
property int sensorIndex: -1
property string sensorLabel: ""
property real currentValue: 0.0
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
function openFor(index) {
sensorIndex = index;
var dot = streamController.sensorDots[index];
if (!dot)
return;
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1);
currentValue = dot.value;
valueField.text = "";
offsetField.text = "";
open();
}
background: Rectangle {
color: Theme.cardBackground
radius: Theme.radiusLg
border.color: Theme.cardBorder
border.width: Theme.borderThin
}
ColumnLayout {
anchors.fill: parent
spacing: Theme.rightPaneGap
// ── header ───────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
Label {
text: "Sensor #" + (root.sensorIndex + 1) + (root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
color: Theme.headingColor
font.bold: true
font.pixelSize: Theme.fontSm
Layout.fillWidth: true
}
Rectangle {
visible: root.hasOverride
width: overrideTag.implicitWidth + 12
height: overrideTag.implicitHeight + 6
radius: 4
color: Theme.statusWarningColor
opacity: 0.85
Label {
id: overrideTag
anchors.centerIn: parent
text: "OVERRIDE"
color: "white"
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1
}
}
}
Label {
text: "Live: " + root.currentValue.toFixed(2)
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// ── replace field ─────────────────────────────────────────
Label {
text: "Replace with value"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
TextField {
id: valueField
Layout.fillWidth: true
placeholderText: "100"
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: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
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 ──────────────────────────────────────────
Label {
text: "Or add offset (± delta)"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
TextField {
id: offsetField
Layout.fillWidth: true
placeholderText: "e.g. +0.5 or -0.5 (blank = skip)"
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: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
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
}
// ── buttons ───────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
id: applyBtn
text: "Apply"
Layout.fillWidth: true
hoverEnabled: true
enabled: valueField.text !== "" || offsetField.text !== ""
opacity: enabled ? 1.0 : 0.4
background: Rectangle {
radius: Theme.radiusSm
color: applyBtn.down ? Theme.buttonNeutralPressed : applyBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: Text {
text: applyBtn.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.bold: true
font.pixelSize: Theme.fontSm
}
onClicked: {
if (valueField.text !== "") {
var v = parseFloat(valueField.text);
if (!isNaN(v))
streamController.replaceSensor(root.sensorIndex, v);
}
if (offsetField.text !== "") {
var d = parseFloat(offsetField.text);
if (!isNaN(d))
streamController.offsetSensor(root.sensorIndex, d);
}
root.close();
}
}
Button {
id: clearBtn
text: "Clear"
Layout.fillWidth: true
hoverEnabled: true
enabled: root.hasOverride
opacity: enabled ? 1.0 : 0.4
background: Rectangle {
radius: Theme.radiusSm
color: clearBtn.down ? Theme.buttonNeutralPressed : clearBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: Text {
text: clearBtn.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.bold: true
font.pixelSize: Theme.fontSm
}
onClicked: {
streamController.clearSensorEdit(root.sensorIndex);
root.close();
}
}
Button {
id: cancelBtn
text: "Cancel"
hoverEnabled: true
background: Rectangle {
radius: Theme.radiusSm
color: cancelBtn.down ? Theme.buttonNeutralPressed : cancelBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: Text {
text: cancelBtn.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.bold: true
font.pixelSize: Theme.fontSm
}
onClicked: root.close()
}
}
}
}
@@ -0,0 +1,341 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Controls.impl
import ISC
import ".."
ColumnLayout {
spacing: 6
// ── Section label & Refresh ───────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "SOURCE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
topPadding: 6
bottomPadding: 8
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
Button {
id: editBtn
implicitWidth: 28
implicitHeight: 28
hoverEnabled: true
background: Rectangle {
color: editBtn.pressed ? Theme.buttonPressed : editBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: IconImage {
source: "../icons/edit.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.bodyColor
}
onClicked: {
file_browser.refreshFiles();
csvEditorDialog.open();
}
ToolTip {
id: editTooltip
visible: editBtn.hovered
text: "Edit"
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: editTooltip.text
color: Theme.headingColor
font: editTooltip.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
}
}
Button {
id: refreshBtn
implicitWidth: 28
implicitHeight: 28
hoverEnabled: true
background: Rectangle {
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: IconImage {
source: "../icons/refresh.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.bodyColor
}
onClicked: file_browser.refreshFiles()
ToolTip {
id: refreshTooltip
visible: refreshBtn.hovered
text: "Refresh"
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: refreshTooltip.text
color: Theme.headingColor
font: refreshTooltip.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
}
}
}
// ── Directory button ──────────────────────────────────────────────────
Button {
id: dirBtn
Layout.fillWidth: true
hoverEnabled: true
implicitHeight: 36
leftPadding: 8
rightPadding: 8
background: Rectangle {
radius: Theme.radiusSm
color: dirBtn.pressed ? Theme.buttonNeutralPressed : dirBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: RowLayout {
spacing: 6
Label {
text: "▤"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
color: Theme.headingColor
font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
onClicked: file_browser.chooseDirectory()
}
// ── Filter ────────────────────────────────────────────────────────────
TextField {
id: filter
Layout.fillWidth: true
placeholderText: "Filter…"
font.pixelSize: Theme.fontSm
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
}
}
// ── File list ─────────────────────────────────────────────────────────
ListView {
id: fileList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 2
highlight: null
highlightFollowsCurrentItem: false
model: file_browser.files
delegate: ItemDelegate {
id: fileItem
width: ListView.view.width
padding: 8
highlighted: false
focusPolicy: Qt.NoFocus
readonly property bool matchesFilter: {
if (filter.text === "")
return true;
var q = filter.text.toLowerCase();
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
}
// Active only in review mode; no highlight during live streaming
readonly property bool isActive: streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile
visible: matchesFilter
height: matchesFilter ? implicitHeight : 0
onClicked: streamController.loadFile(modelData.fileName)
background: Rectangle {
color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
radius: Theme.radiusSm
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
// Left accent bar — shown only when active
Rectangle {
visible: fileItem.isActive
width: 3
radius: 1.5
anchors {
top: parent.top
bottom: parent.bottom
left: parent.left
topMargin: 5
bottomMargin: 5
}
color: {
var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P")
return "#3B82F6";
if (t === "B" || t === "C" || t === "D")
return "#10B981";
if (t === "Z")
return "#8B5CF6";
return Theme.headingColor;
}
}
}
contentItem: RowLayout {
spacing: 8
// Wafer-type avatar (circle)
Rectangle {
implicitWidth: 28
implicitHeight: 28
Layout.preferredWidth: 28
Layout.preferredHeight: 28
Layout.alignment: Qt.AlignVCenter
radius: 14
color: {
var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P")
return "#1D4ED8";
if (t === "B" || t === "C" || t === "D")
return "#065F46";
if (t === "Z")
return "#7C3AED";
return "#374151";
}
Label {
anchors.centerIn: parent
text: modelData.waferType || "?"
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
color: "#FFFFFF"
}
}
// Typography hierarchy
ColumnLayout {
spacing: 1
Layout.fillWidth: true
// Primary: wafer type + serial number
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
}
// Secondary: date · time
RowLayout {
spacing: 3
Layout.fillWidth: true
Label {
text: {
var d = modelData.date || "";
return d.length >= 10 ? d.substring(0, 10) : (d || "—");
}
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
visible: (modelData.timeStr || "") !== ""
text: "·"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
opacity: 0.45
}
Label {
visible: (modelData.timeStr || "") !== ""
text: modelData.timeStr || ""
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
opacity: 0.65
}
}
// Tertiary: edited metadata (chamber · notes) if any
Label {
visible: {
var c = modelData.chamber || "";
var n = modelData.notes || "";
return c !== "" || n !== "";
}
text: {
var parts = [];
if (modelData.chamber)
parts.push(modelData.chamber);
if (modelData.notes)
parts.push(modelData.notes);
return parts.join(" · ");
}
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
opacity: 0.7
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
}
}
// ── Footer ────────────────────────────────────────────────────────────
Label {
text: file_browser.files.length + " files · CSV only"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
SelectFileDialog {
id: csvEditorDialog
tableModel: file_browser.files
}
}
@@ -0,0 +1,339 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// ===== Control Bar =====
// Mode-aware footer: cross-fades between Live and Review layouts.
// Both variants render as a single centered pill — no full-width bar.
Item {
id: bar
Layout.fillWidth: true
implicitHeight: hasContent ? 80 : 0
Behavior on implicitHeight {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
clip: true
// Hide entirely when in review mode with no file loaded
readonly property bool hasContent: {
if (streamController.mode === "live") return true;
return streamController.loadedFile !== "";
}
readonly property bool isLive: streamController.mode === "live"
// ── LIVE MODE ─────────────────────────────────────────────────
Item {
id: liveContent
anchors.fill: parent
opacity: bar.isLive ? 1.0 : 0.0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
// Single centered pill for Received + Errors + Stop
Rectangle {
anchors.centerIn: parent
implicitWidth: liveRow.implicitWidth + 48
implicitHeight: 68
radius: Theme.radiusMd
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
Row {
id: liveRow
anchors.centerIn: parent
spacing: 24
// Received counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Received: " + streamController.receivedCount
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
}
// Separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// Errors counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Errors: " + streamController.errorCount
color: streamController.errorCount > 0
? Theme.statusWarningColor : Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
}
// Separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// STOP button
Button {
id: stopBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 112
implicitHeight: 48
hoverEnabled: true
text: "STOP"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.letterSpacing: 1.0
background: Rectangle {
radius: 8
color: stopBtn.pressed ? Theme.transportButtonHover
: Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.stopStream()
}
}
}
}
// ── REVIEW MODE ──────────────────────────────────────────────
Item {
id: reviewContent
anchors.fill: parent
opacity: !bar.isLive ? 1.0 : 0.0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
// Single centered pill for frame counter + transport + speed
Rectangle {
anchors.centerIn: parent
implicitWidth: reviewRow.implicitWidth + 48
implicitHeight: 68
radius: Theme.radiusMd
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
Row {
id: reviewRow
anchors.centerIn: parent
spacing: 24
// Frame counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Frame " + (streamController.frameIndex + 1)
+ " / " + streamController.frameTotal
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
leftPadding: 8
}
// 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
text: "⏮"
font.pixelSize: 24
background: Rectangle {
radius: 8
color: skipStartBtn.pressed ? Theme.transportButtonHover
: skipStartBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.step(-1)
}
// Stop
Button {
id: reviewStopBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 56
implicitHeight: 56
hoverEnabled: true
text: "■"
font.pixelSize: 22
background: Rectangle {
radius: 8
color: reviewStopBtn.pressed ? Theme.transportButtonHover
: reviewStopBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.stop()
}
// Play / Pause toggle
Button {
id: playPauseBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 64
implicitHeight: 56
hoverEnabled: true
text: streamController.playing ? "⏸" : "▶"
font.pixelSize: 24
background: Rectangle {
radius: 8
color: playPauseBtn.pressed ? Theme.transportButtonHover
: playPauseBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: parent.text === "▶" ? 4 : 0
}
onClicked: {
if (streamController.playing) {
streamController.pause();
} else {
streamController.play();
}
}
}
// Skip-end
Button {
id: skipEndBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 56
implicitHeight: 56
hoverEnabled: true
text: "⏭"
font.pixelSize: 24
background: Rectangle {
radius: 8
color: skipEndBtn.pressed ? Theme.transportButtonHover
: skipEndBtn.hovered ? Theme.transportButtonBg
: "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.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,48 @@
import QtQuick
import QtQuick.Controls
import ISC
import ISC.Wafer
Item {
id: root
property real blend: 0.0
property bool showLabels: true
property alias showThickness: map.showThickness
WaferMapItem {
id: map
anchors.fill: parent
sensors: streamController.sensorLayout // [{label,x,y}]
values: streamController.sensorValues // [float]
bands: streamController.sensorBands // ["in_range"|"high"|"low"]
shape: streamController.waferShape
size: streamController.waferSize
target: streamController.target
margin: streamController.margin
blend: root.blend
showLabels: root.showLabels
// Bind to Theme so colors update on dark/light mode switch
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
TapHandler {
onTapped: ev => {
var idx = map.which_marker(ev.position.x, ev.position.y);
if (idx >= 0)
replaceDialog.openFor(idx);
}
}
}
ReplaceSensorDialog {
id: replaceDialog
}
function exportImage(filePath) {
return map.export_image(filePath);
}
}
+8
View File
@@ -0,0 +1,8 @@
# ===== ISC Tabs Components Module =====
module ISC.Tabs.components
SourcePanel 1.0 SourcePanel.qml
ReadoutPanel 1.0 ReadoutPanel.qml
TransportBar 1.0 TransportBar.qml
WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.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

+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"
>
<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

+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="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

+9
View File
@@ -0,0 +1,9 @@
# ===== ISC Tabs Module =====
module ISC.Tabs
GraphTab 1.0 GraphTab.qml
WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml
StatusTab 1.0 StatusTab.qml
SelectFileDialog 1.0 SelectFileDialog.qml
+199
View File
@@ -0,0 +1,199 @@
pragma Singleton
import QtQuick
// =============================================================================
// Theme — single source of truth for colors and sizing
// =============================================================================
// Sections:
// 1. Mode flag
// 2. Typography
// 3. Motion
// 4. Tone palette (raw tones; everything else derives from these)
// 5. Surfaces (page / card / panel / workspace backgrounds)
// 6. Borders
// 7. Text (headings, body, panel titles)
// 8. Controls (fields, buttons, checkbox)
// 9. Tracks (pill toggle + scrollbar)
// 10. Tabs (footer tab bar)
// 11. Side rail
// 12. Status (success / warning / error)
// 13. 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 {
// ── 1. Mode ──────────────────────────────────────────────────────────────
property bool isDarkMode: true
// ── 2. Typography ──────────────────────────────────────────────────────
// 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
// Legacy shim
readonly property int tabFontSize: fontSm
// ── 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 string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
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 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"
// ── 3. Surfaces ──────────────────────────────────────────────────────────
readonly property color pageBackground: tone100
readonly property color cardBackground: tone150
readonly property color panelBackground: tone150
readonly property color workspaceBackground: tone150
readonly property color responseBackground: tone150
readonly property color subtleSectionBackground: tone250
// ── 4. Borders ───────────────────────────────────────────────────────────
readonly property color cardBorder: toneBorder
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color responseBorder: toneBorder
readonly property color workspaceBorder: toneBorder
readonly property color innerFrameBorder: toneBorder
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
// ── 5. Text ──────────────────────────────────────────────────────────────
readonly property color headingColor: toneText
readonly property color bodyColor: toneMute
readonly property color subheadingColor: toneMute
readonly property color panelTitleText: toneMute
readonly property color disabledText: toneMute
// ── 6. Controls ──────────────────────────────────────────────────────────
// Fields
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
readonly property color fieldText: toneText
readonly property color fieldPlaceholder: toneMute
readonly property color fieldBorder: toneBorder
readonly property color fieldBorderFocus: themeAccent
// Neutral buttons
readonly property color buttonNeutralBackground: tone250
readonly property color buttonNeutralHover: tone300
readonly property color buttonNeutralPressed: isDarkMode ? "#0F0F13" : "#FFFFFF"
readonly property color buttonNeutralText: toneText
readonly property color buttonPressed: tone300
readonly property color primaryAccent: themeAccent
// Checkbox
readonly property color checkboxText: toneText
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
readonly property color tabBarBackground: tone200
readonly property color tabBackground: "transparent"
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color tabHoverBackground: tone250
readonly property color tabBorder: "transparent"
readonly property color tabText: toneMute
readonly property color tabActiveText: toneText
// ── 9. Side rail ─────────────────────────────────────────────────────────
readonly property color sideRailBackground: tone150
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color sideBorder: toneBorder
readonly property color sideMutedText: toneMute
readonly property color sideAccent: themeAccent
readonly property color sideRowHover: tone250
readonly property color sideFieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
readonly property color transportBackground: isDarkMode ? "#101014" : "#EFEFED"
readonly property color transportButtonBg: tone250
readonly property color transportButtonHover: tone300
readonly property color liveColor: themeAdded
readonly property color recordColor: themeRemoved
// ── 10. Status ───────────────────────────────────────────────────────────
readonly property color statusSuccessColor: themeAdded
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
readonly property color statusErrorColor: themeRemoved
// -- 10b. Sensor bands (wafer map dots)
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 sensorLow: themeSkill
readonly property color waferRingColor: toneBorder
readonly property color waferAxisColor: softBorder
// ── 11. Geometry ─────────────────────────────────────────────────────────
// Radius
readonly property int radiusXs: 6 // fields, tight elements
readonly property int radiusSm: 8 // buttons
readonly property int radiusMd: 12 // cards, group boxes
readonly property int radiusLg: 18 // large panels / dialogs
// Border width
readonly property int borderThin: 1
readonly property int borderStrong: 2
// Main panel layout
readonly property int mainAreaPadding: 10
readonly property int rightPaneGap: 6
readonly property int panelPadding: 12
// Side rail layout
readonly property int sideRailWidth: 280
readonly property int sideRailMargin: 16
readonly property int sideRailSpacing: 16
readonly property int sideButtonHeight: 44
readonly property int sidePanelRadius: 10
readonly property int sideFooterConnection: 100
readonly property int sideFooterUtility: 46
// Tab bar layout
readonly property int tabBarHeight: 34
readonly property int sidePillHeight: 54
readonly property int sidePillButtonHeight: 36
readonly property int tabBarPadding: 6
readonly property int tabSpacing: 2
readonly property int tabRadius: 8
readonly property int tabButtonMinWidth: 80
// Settings page layout
readonly property int settingsPanelMaxWidth: 760
readonly property int settingsOuterMargin: 40
readonly property int settingsSectionSpacing: 20
readonly property int settingsRowSpacing: 16
readonly property int settingsGridSpacing: 12
readonly property int settingsButtonHeight: 40
readonly property int settingsActionWidth: 200
readonly property int settingsFieldMinWidth: 160
}
+1
View File
@@ -4,6 +4,7 @@ module ISC
# ===== Root Components =====
Main 1.0 Main.qml
HomePage 1.0 HomePage.qml
AboutDialog 1.0 AboutDialog.qml
# ===== Singleton Theme =====
singleton Theme 1.0 Theme.qml
View File
+75
View File
@@ -0,0 +1,75 @@
import logging
import sys
from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtWidgets import QApplication
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
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
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
# ===== Application Entry Point =====
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
# ===== UI Style Setup =====
# Use a non-native controls style so our custom QML button backgrounds are supported.
QQuickStyle.setStyle("Basic")
# ===== Qt Bootstrap =====
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
app = QApplication(sys.argv)
engine = QQmlApplicationEngine()
# ===== Shared Models =====
settings_model = LocalSettingsModel()
select_file_dialog_model = FileBrowser()
settings_model.loadSettings()
engine.rootContext().setContextProperty("settingsModel", settings_model)
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
# ===== Device Controller (serial comm) =====
data_dir = str(settings_model._data_dir)
raw_settings = LocalSettings.read_settings(data_dir)
device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== Session Controller (live/review wafer dashboard) =====
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
stream_controller = SessionController(settings=raw_settings_dict)
engine.rootContext().setContextProperty("streamController", stream_controller)
# Persist session state back to settings when it changes
def _persist_session_settings():
session_state = stream_controller.collect_settings()
for k, v in session_state.items():
if hasattr(raw_settings, k):
setattr(raw_settings, k, v)
LocalSettings.save_settings(data_dir, raw_settings)
stream_controller.settingsChanged.connect(_persist_session_settings)
# ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir.
engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("ISC", "Main")
# ===== Exit Handling =====
if not engine.rootObjects():
return -1
return app.exec()
if __name__ == "__main__":
sys.exit(main())
View File
+44
View File
@@ -0,0 +1,44 @@
# This will be the name of the output files
name: aepwafer
wafers: ["A", "E", "P"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
4: { top: [0, 0] },
5: { top: [0, 0] },
6: { top: [0, 0] },
}
+46
View File
@@ -0,0 +1,46 @@
# This will be the name of the output files
name: bcdwafer
wafers: ["B", "C", "D"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
6: { left: [1, 0] },
}
+43
View File
@@ -0,0 +1,43 @@
# This will be the name of the output files
name: fwafer
wafers: ["F"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 200
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
}
+69
View File
@@ -0,0 +1,69 @@
# This will be the name of the output files
name: xwafer
wafers: ["X"]
# round or square wafer
shape: square
# diameter/edge size of the wafer
size: 310
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
133.29, 176.72, 176.72]
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
176.79, 133.36 ]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
21: { left: [0, 0] },
22: { left: [0, 0] },
23: { left: [0, 0] },
24: { left: [0, 0] },
25: { left: [0, 0] },
26: { left: [0, 0] },
27: { left: [0, 0] },
28: { left: [0, 0] },
29: { left: [0, 0] },
30: { left: [0, 0] },
31: { left: [0, 0] },
32: { left: [0, 0] },
33: { left: [0, 0] },
34: { left: [0, 0] },
35: { left: [0, 0] },
36: { left: [0, 0] }
}
+68
View File
@@ -0,0 +1,68 @@
# This will be the name of the output files
name: zwafer
wafers: ["Z"]
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
33: { left: [0, 0] },
34: { left: [0, 0] },
41: { top: [0, 0] },
42: { bottom: [0, 0] },
45: { top: [1, 0] },
46: { bottom: [0.5, 0] },
49: { left: [0, 0] },
50: { bottom: [-1.75, -0.5] },
51: { top: [-1, 0] },
52: { top: [-1, 0] },
53: { bottom: [0, 0] },
54: { left: [0, -0.75] },
55: { right: [0, 0.5] },
58: { bottom: [-0.75, 0] },
60: { top: [0, 0] },
62: { right: [0, 1] },
63: { right: [0, 0] },
64: { left: [0, 0.25] }
}
+71
View File
@@ -0,0 +1,71 @@
# This will be the name of the output files
name: zwafer_rev
wafers: [] # the reversed version doesn't represent any real wafer
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: true
reverse_y: true
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
37: { left: [0, 0] },
38: { left: [0, 0] },
39: { left: [0, 0] },
41: { top: [1.5, 0] },
42: { bottom: [1, 0] },
43: { left: [0, -1] },
45: { bottom: [-1, -1] },
46: { bottom: [0, -1] },
47: { right: [0, 1] },
48: { left: [0, 0] },
50: { bottom: [-1, 0] },
52: { top: [0, 0] },
54: { right: [0.5, 1] },
56: { left: [0, 0.5] },
57: { left: [0, 0] },
58: { top: [0.5, 0] },
59: { left: [0, 0] },
60: { top: [-1, 0] },
61: { left: [0, 0] },
62: { left: [0, -1] },
63: { right: [0, 1] },
}
+26
View File
@@ -0,0 +1,26 @@
# ===== Backend Package =====
# Backward-compatible re-exports so existing callers keep working
# while imports are migrated to the new sub-package paths.
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
from pygui.backend.data.data_records import read_data_records # noqa: F401
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
from pygui.backend.models.frame import Frame # noqa: F401
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
from pygui.backend.models.session_model import SessionModel # noqa: F401
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
@@ -1,8 +1,8 @@
from typing import List, Tuple, Optional
from typing import List, Optional, Tuple
import numpy as np
from backend.contour_models import ContourLine, ContourSegment
from pygui.backend.attic.contour_models import ContourLine, ContourSegment
# ===== 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()))
}
@@ -0,0 +1,5 @@
# ===== Controllers Sub-package =====
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
__all__ = ["DeviceController", "SessionController"]
@@ -1,10 +1,4 @@
"""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.
"""
"""QML-exposed controller for wafer device communication."""
from __future__ import annotations
@@ -13,19 +7,26 @@ import threading
from datetime import datetime
from typing import Any, Optional
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from backend.data_model import TemperatureTableModel
from backend.graph_view import GraphView
from backend.local_settings import LocalSettings
from serialcomm.data_parser import (
from pygui.backend.controllers.session_controller import SessionController
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 (
convert_to_temperatures,
parse_binary_data,
remove_trailing_zeros,
save_to_csv,
)
from serialcomm.device_service import DeviceService
from serialcomm.serial_port import WaferInfo
from pygui.serialcomm.device_service import DeviceService
from pygui.serialcomm.serial_port import WaferInfo
# import pygui.backend.wafer_map_item
stream_controller = SessionController()
# engine.rootContext().setContextProperty("streamController", stream_controller)
log = logging.getLogger(__name__)
@@ -39,10 +40,10 @@ class DeviceController(QObject):
# ---- public signals ----
portsUpdated = Signal(list)
detectResult = Signal(object) # WaferInfo dict or None
readResult = Signal(object) # {"success": bool, "bytes": int} or {"error": str}
eraseResult = Signal(object) # {"success": bool}
debugResult = Signal(object) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
parsedDataReady = Signal(object) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
eraseResult = Signal(dict) # {"success": bool}
debugResult = Signal(dict) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
parsedDataReady = Signal(dict) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
statusRestored = Signal() # Emitted when status is restored from previous session
logMessage = Signal(str)
activityLogUpdated = Signal(str)
@@ -57,39 +58,31 @@ class DeviceController(QObject):
self._data_dir = data_dir
self._service = DeviceService(settings)
# Initialize status from persisted settings (with defaults for new installations)
self._connection_status = getattr(settings, 'connection_status', "Disconnected")
# Always start fresh — never restore connection status or logs from a prior session
self._connection_status = "Disconnected"
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
# Default save dir lives next to the settings file (~/Documents/isc_data/csv)
from pathlib import Path
self._save_data_dir: str = getattr(settings, 'save_data_dir', str(Path.home() / "Documents" / "isc_data" / "csv"))
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {})
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
self._selected_port: str = getattr(settings, 'selected_port', "")
self._data_row_count: int = getattr(settings, 'data_row_count', 0)
self._data_col_count: int = getattr(settings, 'data_col_count', 0)
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
self._last_wafer_info: dict[str, Any] = {}
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._graph_view = GraphView(self)
# If we have persisted activity log, emit it to QML
if self._activity_log:
self.activityLogUpdated.emit("\n".join(self._activity_log))
# Log status restoration and emit signal for UI updates
if self._connection_status != "Disconnected" or self._selected_port or self._last_wafer_info:
self._append_log("Restored previous session status")
if self._selected_port:
self._append_log(f"Last selected port: {self._selected_port}")
if self._last_wafer_info:
info = self._last_wafer_info
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
self.statusRestored.emit()
# Reset connection status on fresh start
self._connection_status = "Disconnected"
# Marshal worker-thread results onto the Qt main thread.
self._detectFinished.connect(self._handle_detect_finished, Qt.QueuedConnection)
self._readFinished.connect(self._handle_read_finished, Qt.QueuedConnection)
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
root_logger = logging.getLogger()
root_logger.addHandler(QmlActivityLogHandler(self))
root_logger.setLevel(logging.INFO)
# ---- Properties ----
@Property(list, notify=portsUpdated)
@@ -97,6 +90,12 @@ class DeviceController(QObject):
"""Currently available serial port names."""
return self._service.enumerate_ports()
def _set_connection_status(self, status: str) -> None:
"""Update connection status and notify QML."""
if self._connection_status != status:
self._connection_status = status
self.portsUpdated.emit(self.availablePorts)
@Property(str, notify=portsUpdated)
def connectionStatus(self) -> str:
"""Current connection status string for display."""
@@ -113,6 +112,7 @@ class DeviceController(QObject):
return self._save_data_dir
@Slot(str)
@slot_error_boundary
def setSaveDataDir(self, path: str) -> None:
"""Set the directory for saving parsed CSV data files."""
self._save_data_dir = path
@@ -125,6 +125,7 @@ class DeviceController(QObject):
return self._selected_port
@Slot(str)
@slot_error_boundary
def setSelectedPort(self, port: str) -> None:
"""Set the active serial port from the port selector."""
self._selected_port = port
@@ -147,10 +148,17 @@ class DeviceController(QObject):
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
"""
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 [
info.get("familyCode", ""),
family_code,
info.get("serialNumber", ""),
info.get("sensorCount", 0),
sensor_count,
info.get("mfgDateHex", ""),
info.get("runtime", 0),
info.get("cycleCount", 0),
@@ -167,6 +175,7 @@ class DeviceController(QObject):
return self._graph_view
@Slot(result=object)
@slot_error_boundary
def getChartData(self) -> dict[str, Any]:
"""Extract chart-ready data from the current model.
@@ -204,6 +213,35 @@ class DeviceController(QObject):
self._activity_log = self._activity_log[-200:]
self.activityLogUpdated.emit("\n".join(self._activity_log))
@Slot()
@slot_error_boundary
def clearActivityLog(self) -> None:
"""Clear the activity log."""
self._activity_log.clear()
self.activityLogUpdated.emit("")
self._save_status()
@Slot()
@slot_error_boundary
def clearSession(self) -> None:
"""Clear the current session state, allowing a clean detect."""
self._set_connection_status("Disconnected")
self._selected_port = ""
self._last_wafer_info = {}
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.
@@ -217,15 +255,17 @@ class DeviceController(QObject):
# ---- Public Slots ----
@Slot()
@slot_error_boundary
def refreshPorts(self) -> None:
"""Scan for available serial ports and emit updated list."""
ports = self._service.enumerate_ports()
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self.portsUpdated.emit(ports)
self._append_log(f"Scanned {len(ports)} serial port(s)")
self._save_status()
@Slot()
@slot_error_boundary
def detectWafer(self) -> None:
"""Scan all serial ports for a wafer (runs in background thread).
@@ -237,8 +277,20 @@ class DeviceController(QObject):
self._append_log("Already busy — ignoring detect request")
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.parsedDataReady.emit({"success": False})
self._set_operation_progress(True)
self._connection_status = "Detecting..."
self._set_connection_status("Detecting...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log("Scanning all ports for wafer ...")
@@ -255,35 +307,34 @@ class DeviceController(QObject):
self._detectFinished.emit(port, info)
@Slot(object, object)
@slot_error_boundary
def _handle_detect_finished(
self, port: Optional[str], info: Optional[WaferInfo]
) -> None:
"""Main-thread completion handler for detectWafer."""
if info is not None:
self._selected_port = port or ""
self._connection_status = "Connected"
self._append_log(
f"Detected: {info.serial_number} (family={info.family_code}, "
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
)
self._set_connection_status("Connected")
wafer_dict = self._wafer_info_to_dict(info)
self._last_wafer_info = wafer_dict
self.detectResult.emit(wafer_dict)
self._save_status()
else:
self._selected_port = ""
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port")
self._last_wafer_info = {}
self.detectResult.emit(None)
self._set_operation_progress(False)
@Slot()
@slot_error_boundary
def readMemoryAsync(self) -> None:
"""Read wafer memory in a background thread.
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:
self._append_log("Already busy — ignoring read request")
@@ -298,7 +349,7 @@ class DeviceController(QObject):
family_code = self._last_wafer_info.get("familyCode", "")
self._set_operation_progress(True)
self._connection_status = "Reading..."
self._set_connection_status("Reading...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
@@ -320,22 +371,21 @@ class DeviceController(QObject):
self._readFinished.emit(port, family_code, data)
@Slot(str, str, object)
@slot_error_boundary
def _handle_read_finished(
self, port: str, family_code: str, data: Optional[bytes]
) -> None:
"""Main-thread completion handler for readMemoryAsync."""
if data is not None:
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log(f"Read {len(data)} bytes")
self._raw_bytes = data
self.readResult.emit({"success": True, "bytes": len(data)})
self._set_operation_progress(False)
# Auto-chain: read → parse → save (matches C# behavior).
# Parse runs on main thread — it's CPU-bound but bounded (~1s).
self.parseAndSaveData(family_code, port)
self._append_log("Memory read complete - choose save directory to save CSV")
self._save_status()
return
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("Read returned no data")
self._raw_bytes = None
self.readResult.emit({"error": "Read returned no data"})
@@ -343,38 +393,40 @@ class DeviceController(QObject):
self._save_status()
@Slot(str)
@slot_error_boundary
def eraseMemory(self, port: str) -> None:
"""Send p1 erase command."""
self._set_operation_progress(True)
self._connection_status = "Erasing..."
self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
ok = self._service.erase_wafer(port)
if ok:
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log("Erase command accepted — wait ~15 seconds")
else:
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("Erase command failed")
self.eraseResult.emit({"success": ok})
self._set_operation_progress(False)
@Slot(str)
@slot_error_boundary
def readDebug(self, port: str) -> None:
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
Emits debugResult with counts on success.
"""
self._set_operation_progress(True)
self._connection_status = "Reading debug..."
self._set_connection_status("Reading debug...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Reading debug data on {port} ...")
# Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None:
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("D1 read failed — aborting debug read")
self.debugResult.emit({"error": "D1 read failed"})
self._set_operation_progress(False)
@@ -385,13 +437,13 @@ class DeviceController(QObject):
# Step 2: F1 debug data
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
if debug_data is None:
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("F1 read failed")
self.debugResult.emit({"error": "F1 read failed"})
self._set_operation_progress(False)
return
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log(
f"Debug read complete: {len(sensor_data)} sensor bytes, "
f"{len(debug_data)} debug bytes"
@@ -404,6 +456,7 @@ class DeviceController(QObject):
self._set_operation_progress(False)
@Slot(str, str)
@slot_error_boundary
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
"""Parse raw bytes into temperatures and save to CSV.
@@ -419,12 +472,10 @@ class DeviceController(QObject):
return
if not self._save_data_dir:
self._append_log("No save data directory set")
self.parsedDataReady.emit({
"success": False,
"error": "No save data directory set",
})
return
from pathlib import Path
self._save_data_dir = str(Path(self._data_dir) / "csv")
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
self._save_status()
fc = family_code or (
self._last_wafer_info.get("familyCode", "")
@@ -471,6 +522,7 @@ class DeviceController(QObject):
return
self._append_log(f"Saved CSV: {csv_path}")
self._last_csv_path = csv_path
# Load data into the QAbstractTableModel
self._data_model.load_data(temp_data, cols)
@@ -504,18 +556,36 @@ class DeviceController(QObject):
self._settings.last_csv_path = self._last_csv_path
# Save to disk
LocalSettings.save_settings(LocalSettings._settings_path(self._data_dir), self._settings)
LocalSettings.save_settings(self._data_dir, self._settings)
# ---- Helpers ----
@staticmethod
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
"""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 {
"familyCode": info.family_code,
"serialNumber": info.serial_number,
"sensorCount": info.sensor_count,
"sensorCount": sensor_count,
"mfgDateHex": info.mfg_date_hex,
"runtime": info.runtime,
"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)
@@ -0,0 +1,582 @@
"""QML-facing controller for the live/review wafer dashboard."""
from __future__ import annotations
import json
import logging
import time
from typing import Optional
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.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.replay_stats import ReplayStatsTracker
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel
from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader
log = logging.getLogger(__name__)
MODE_LIVE = "live"
MODE_REVIEW = "review"
class SessionController(QObject):
# public signals
frameUpdated = Signal()
modeChanged = Signal()
stateChanged = Signal()
recordingChanged = Signal()
sensorsChanged = Signal()
loadedFileChanged = Signal()
clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats
# private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping
def __init__(self, parent: QObject | None = None,
settings: dict | None = None) -> None:
super().__init__(parent)
self._mode = MODE_REVIEW
self._model = SessionModel()
self._player = FramePlayer()
self._reader: Optional[StreamReader] = None
self._recorder = CsvRecorder()
self._sensors: list[Sensor] = []
self._last = None # last SessionUpdate
self._elapsed = 0.0
self._trend_buffer: list[float] = [] # rolling window
self._trend_timestamps: list[float] = [] # monotonic timestamps
# Trend refresh timer - update every 1s
self._trend_timer = QTimer(self)
self._trend_timer.setInterval(1000)
self._trend_timer.timeout.connect(self._flush_trend)
self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance)
self._speed = 1.0
# Q1: coalesce live repaints to ~20 Hz; data is still processed every frame.
self._repaint_timer = QTimer(self)
self._repaint_timer.setInterval(50) # ~20 Hz
self._repaint_timer.timeout.connect(self._flush_repaint)
self._dirty = False
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._last_raw_frame: Frame | None = None
self._loaded_file: str = ""
self._cluster_averaging_enabled = False
self._active_clusters: list[list[int]] = []
self._stats_tracker = ReplayStatsTracker()
self._received_count: int = 0
self._error_count: int = 0
# Restore persisted state if available
self._load_settings(settings or {})
# ---- persisted settings ----
def _load_settings(self, settings: dict) -> None:
"""Restore session state from persisted settings dict."""
# Do NOT auto-restore the last session file — always start fresh.
def collect_settings(self) -> dict:
"""Return a dict of session state to persist."""
return {
"session_last_file": self._loaded_file,
}
# ---- properties QML binds to ----
@Property(str, notify=modeChanged)
def mode(self) -> str: return self._mode
@Property(int, notify=frameUpdated)
def frameIndex(self) -> int: return self._player.index
@Property(int, notify=frameUpdated)
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)
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)
def recording(self) -> bool: return self._recorder.is_recording
@Property(list, notify=frameUpdated)
def sensorDots(self) -> list[dict]:
"""Per-sensor render data for the radial map."""
if not self._last:
return []
out = []
for i, s in enumerate(self._sensors):
v = self._last.values[i] if i < len(self._last.values) else 0.0
band = self._last.bands[i] if i < len(self._last.bands) else "in_range"
out.append({"label": s.label, "x": s.x, "y": s.y,
"value": round(v, 2), "band": band, "index": i})
return out
@Property("QVariantList", notify=sensorsChanged)
def sensorLayout(self) -> list:
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.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(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)
def sensorValues(self) -> list:
"""[float] in sensor order."""
if not self._last:
return []
return [round(v, 3) for v in self._last.values]
@Property("QVariantList", notify=frameUpdated)
def sensorBands(self) -> list:
"""['in_range'|'high'|'low'] in sensor order."""
if not self._last:
return []
return list(self._last.bands)
@Property(float, notify=frameUpdated)
def target(self) -> float:
"""Resolved band center (frame mean in auto mode, else set_point)."""
return self._last.target if self._last else 149.0
@Property(float, notify=frameUpdated)
def margin(self) -> float:
"""Resolved band half-width (frame 1σ in auto mode, else margin)."""
return self._last.margin if self._last else 1.0
@Property(dict, notify=frameUpdated)
def stats(self) -> dict:
if not self._last:
return {}
s = self._last.stats
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
@Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file
@Property("QVariantList", notify=frameUpdated)
def overriddenSensors(self) -> list[int]:
"""Indices of sensors that currently have a replacement or offset."""
return self._sensor_editor.active_indices()
@Property(bool, notify=clusterAveragingEnabledChanged)
def clusterAveragingEnabled(self) -> bool:
return self._cluster_averaging_enabled
@clusterAveragingEnabled.setter
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
# ---- mode + thresholds ----
@Slot(str)
@slot_error_boundary
def setMode(self, mode: str) -> None:
if mode == self._mode:
return
self.stopStream()
self._play_timer.stop()
self._mode = mode
self.modeChanged.emit()
@Slot(float, float, bool)
@slot_error_boundary
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
if self._last: # re-band current frame in place
self._reprocess_current()
# ---- review: file load + playback ----
@Slot(str)
@slot_error_boundary
def loadFile(self, file_path: str) -> None:
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
frames = []
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records)
else:
try:
data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc)
return
if data is None or not data.sensors:
log.warning("Could not parse %s", file_path)
return
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if not sensors or not frames:
log.warning("No sensors or data in %s", file_path)
return
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._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._model.reset()
self._stats_tracker.reset()
self._loaded_file = file_path
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
self._emit_current()
# Populate stats tracker from all loaded frames for trend chart (P3.1)
for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit()
@Slot()
@slot_error_boundary
def play(self) -> None:
self._play_timer.start(self._next_interval_ms())
self.stateChanged.emit()
@Slot()
@slot_error_boundary
def pause(self) -> None:
self._play_timer.stop()
self.stateChanged.emit()
@Slot()
@slot_error_boundary
def stop(self) -> None:
self._play_timer.stop()
self._player.seek(0)
self._emit_current()
self.stateChanged.emit()
@Slot(int)
@slot_error_boundary
def step(self, delta: int) -> None:
self._play_timer.stop()
self._player.step(delta)
self._emit_current()
self.stateChanged.emit()
@Slot(int)
@slot_error_boundary
def seek(self, i: int) -> None:
self._player.seek(i)
self._emit_current()
@Slot(float)
@slot_error_boundary
def setSpeed(self, x: float) -> None:
self._speed = max(0.1, x)
if self._play_timer.isActive():
self._play_timer.start(self._next_interval_ms())
def _next_interval_ms(self) -> int:
"""Timer interval in ms for the next frame, scaled by playback speed. Falls back to 500ms."""
ms = self._player.next_frame_ms()
if ms <= 0:
ms = 500.0
return max(1, int(ms / self._speed))
def _advance(self) -> None:
if self._player.at_end:
self._play_timer.stop()
self.stateChanged.emit()
return
self._player.step(1)
self._emit_current()
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:
frame = self._player.current()
if frame is None:
return
self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time,
values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited)
self.frameUpdated.emit()
self.stateChanged.emit()
def _reprocess_current(self) -> None:
"""Re-run the model on the last raw frame"""
frame = self._last_raw_frame
if frame is None:
return
edited = Frame(seq=frame.seq, time=frame.time,
values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited)
self.frameUpdated.emit()
self.stateChanged.emit()
# ---- live: stream start/stop ----
@Slot(str, str)
@slot_error_boundary
def startStream(self, port: str, family_code: str = "") -> None:
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
from pygui.serialcomm.serial_port import SerialPort # transport open
if family_code:
try:
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._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._trend_buffer.clear()
self._trend_timestamps.clear()
self._trend_timer.start()
self._stats_tracker.reset()
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)
def on_error(exc: Exception):
log.error("Live stream error: %s", exc)
self._liveError.emit()
self._reader = StreamReader(
transport, parse_binary_frame,
on_frame=lambda f: self._liveFrame.emit(f),
on_error=on_error,
family_code=family_code or "A")
self._reader.start()
self._repaint_timer.start()
self.setMode("live")
self.stateChanged.emit()
@Slot()
@slot_error_boundary
def stopStream(self) -> None:
self._repaint_timer.stop()
self._trend_timer.stop()
self._received_count = 0
self._error_count = 0
self.liveStatsChanged.emit()
if self._reader:
transport = self._reader._transport
self._reader.stop()
self._reader = None
# Send 'D2S' command padded to 512 bytes to stop the stream
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.stateChanged.emit()
self.stopRecording()
@Slot(object)
@slot_error_boundary
def _on_live_frame(self, frame: Frame) -> None:
import json
self._received_count += 1
self.liveStatsChanged.emit()
self._stats_tracker.track(frame.seq, frame.values)
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time,
values=self._apply_pipeline(frame.values))
self._last = self._model.process(edited)
if self._recorder.is_recording:
self._recorder.write(frame) # always record raw values, never edited
self._dirty = True
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
if self._last and self._last.stats:
avg = self._last.stats.avg
now = time.monotonic()
self._trend_buffer.append(avg)
self._trend_timestamps.append(now)
# drop entries older than 60 secs
cutoff = now -60
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
self._trend_timestamps.pop(0)
self._trend_buffer.pop(0)
def _flush_repaint(self) -> None:
if not self._dirty:
return
self._dirty = False
self.frameUpdated.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 ----
@Slot(str, str)
@slot_error_boundary
def startRecording(self, path: str, serial: str = "") -> None:
self._recorder.start(path, self._sensors, serial)
self.recordingChanged.emit()
@Slot()
@slot_error_boundary
def stopRecording(self) -> None:
if self._recorder.is_recording:
self._recorder.stop()
self.recordingChanged.emit()
@Slot(int, float)
@slot_error_boundary
def replaceSensor(self, index: int, value: float) -> None:
"""Override sensor `index` to display `value` every frame."""
self._sensor_editor.set_replacement(index, value)
self._reprocess_current()
@Slot(int, float)
@slot_error_boundary
def offsetSensor(self, index: int, delta: float) -> None:
"""Shift sensor `index` by `delta` every frame."""
self._sensor_editor.set_offset(index, delta)
self._reprocess_current()
@Slot(int)
@slot_error_boundary
def clearSensorEdit(self, index: int) -> None:
"""Remove all overrides for sensor `index`."""
self._sensor_editor.clear(index)
self._reprocess_current()
@Slot()
@slot_error_boundary
def clearSensorEdits(self) -> None:
"""Remove all sensor overrides."""
self._sensor_editor.clear()
self._reprocess_current()
def _flush_trend(self) -> None:
import json
self.trendData.emit(json.dumps(self._trend_buffer))
+2
View File
@@ -0,0 +1,2 @@
# ===== Crypto Sub-package =====
from pygui.backend.crypto.crypto_helper import * # noqa: F403
@@ -2,8 +2,8 @@ import secrets
from pathlib import Path
from typing import Union
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# ===== Crypto Utilities =====
+14
View File
@@ -0,0 +1,14 @@
# ===== Data Sub-package =====
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
__all__ = [
"CsvRecorder", "CSVFileMetadata",
"read_data_records", "is_official_csv", "read_official_csv",
"FileBrowser",
"LocalSettings", "LocalSettingsModel",
]
+3
View File
@@ -0,0 +1,3 @@
# ===== Backend Data Constants =====
DEFAULT_DATA_DIR_NAME = "isc_data"
@@ -25,8 +25,8 @@ class CSVFileMetadata:
try:
return datetime.strptime(self.date, date_format)
except (ValueError, TypeError):
# If format is wrong or date is None, return current time
return datetime.now()
# If format is wrong or date is None, return None
return None
# ===== Formatting =====
@staticmethod
@@ -48,4 +48,5 @@ class CSVFileMetadata:
def string_date_format(self) -> str:
"""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
+67
View File
@@ -0,0 +1,67 @@
"""Append live frames"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional, TextIO
from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder:
def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None
@property
def is_recording(self) -> bool:
return self._file_handle is not None
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
file_handle = open(path, "w", encoding="utf-8", newline ="")
file_handle.write(f"Wafer ID={serial}\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("X (mm)," + ",".join(str(s.x) for s in sensors) + "\n")
file_handle.write("Y (mm)," + ",".join(str(s.y) for s in sensors) + "\n")
file_handle.write("data\n")
file_handle.flush()
self._file_handle, self._path = file_handle, path
def write(self, frame: Frame) -> None:
if self._file_handle is None:
return
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
self._file_handle.write(",".join(row) + "\n")
self._file_handle.flush()
def stop(self) -> Optional[str]:
if self._file_handle is None:
return None
self._file_handle.close()
path, self._file_handle, self._path = self._path, None, None
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.
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.
"""
import pandas as pd
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
return True
+68
View File
@@ -0,0 +1,68 @@
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
from __future__ import annotations
from pathlib import Path
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
def read_data_records(file_path: str) -> list[DataRecord]:
records: list[DataRecord] = []
in_data = False
with Path(file_path).open("r", encoding="utf-8") as file_handle:
for line in file_handle:
line = line.rstrip().rstrip(",").strip()
if not line:
continue
if not in_data:
if line.split(",")[0].lower() == "data":
in_data = True
continue
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
try:
nums = [float(c) for c in cols]
except ValueError:
continue
if not nums:
continue
records.append(DataRecord(time=nums[0], values=nums[1:]))
return records
def is_official_csv(file_path: str) -> bool:
"""Return True if the file uses the headerless official format (sensor-name header row, no 'data' sentinel)."""
with Path(file_path).open("r", encoding="utf-8") as f:
first = f.readline().rstrip().rstrip(",")
if not first:
return False
parts = [p.strip() for p in first.split(",") if p.strip()]
# Official format: first cell looks like "SensorN" (starts with letter, not key=value)
return bool(parts) and parts[0].lower().startswith("sensor") and "=" not in parts[0]
def read_official_csv(file_path: str) -> tuple[list[Sensor], list[DataRecord]]:
"""Parse the headerless official CSV: row 0 = sensor names, rows 1+ = values (no timestamp)."""
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
stem = Path(file_path).stem
wafer_id = stem.split("-")[0] if "-" in stem else stem
try:
sensors = load_layout_for_wafer_id(wafer_id)
except KeyError:
return [], []
records: list[DataRecord] = []
with Path(file_path).open("r", encoding="utf-8") as f:
f.readline() # skip sensor-name header
for i, line in enumerate(f):
line = line.rstrip().rstrip(",")
if not line:
continue
cols = [c.strip() for c in line.split(",") if c.strip()]
try:
values = [float(c) for c in cols]
except ValueError:
continue
if values:
records.append(DataRecord(time=float(i) * 0.5, values=values))
return sensors, records
@@ -5,11 +5,12 @@ import re
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox
from backend.csv_file_metadata import CSVFileMetadata
from backend.zwafer_parser import ZWaferParser
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.wafer.zwafer_parser import ZWaferParser
# ===== File Browser Model =====
@@ -120,24 +121,49 @@ class FileBrowser(QObject):
for csv_path in sorted(self._current_directory.glob("*.csv")):
try:
metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
date_str = metadata.string_date_format()
serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else ""
if not serial:
stem = csv_path.stem
serial = stem[1:] if len(stem) > 1 else ""
if "-" in serial:
serial = serial.split("-")[0]
if "_" in serial:
serial = serial.split("_")[0]
time_str = date_str[11:] if len(date_str) > 10 else ""
self._files.append(
{
"selected": selected_state.get(str(csv_path), False),
"baseName": csv_path.stem,
"wafer": metadata.wafer,
"date": metadata.string_date_format(),
"waferType": wafer_type,
"serialNumber": serial,
"date": date_str,
"timeStr": time_str,
"chamber": metadata.chamber,
"notes": metadata.notes,
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path),
"highlight": metadata.get_wafer_type() in {"A", "B", "C"},
"highlight": wafer_type in {"A", "B", "C"},
}
)
except Exception:
stem = csv_path.stem
serial_fallback = stem[1:] if len(stem) > 1 else ""
if "-" in serial_fallback:
serial_fallback = serial_fallback.split("-")[0]
if "_" in serial_fallback:
serial_fallback = serial_fallback.split("_")[0]
self._files.append(
{
"selected": selected_state.get(str(csv_path), False),
"wafer": csv_path.stem,
"baseName": stem,
"wafer": stem,
"waferType": stem[0].upper() if stem else "",
"serialNumber": serial_fallback,
"date": "",
"timeStr": "",
"chamber": "",
"notes": "Unable to parse metadata",
"masterType": master_state.get(str(csv_path), ""),
@@ -168,25 +194,19 @@ class FileBrowser(QObject):
# Fall back to CSV/header parsing if sidecar is malformed.
pass
meta = self._metadata_from_filename(csv_path)
parser_data, _ = self._parser.parse(str(csv_path))
if parser_data is not None:
wafer = parser_data.serial or ""
date_text = ""
if parser_data.serial:
meta.wafer = parser_data.serial
if parser_data.date != datetime.min:
date_text = CSVFileMetadata.format_date(parser_data.date)
return CSVFileMetadata(
wafer=wafer,
date=date_text,
chamber="",
notes="",
filename=str(csv_path),
)
meta.date = CSVFileMetadata.format_date(parser_data.date)
return self._metadata_from_filename(csv_path)
return meta
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
match = re.match(
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<run>\d+))?$",
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<time>\d{6}))?$",
csv_path.stem,
)
if not match:
@@ -194,7 +214,9 @@ class FileBrowser(QObject):
date_text = ""
try:
parsed = datetime.strptime(match.group("date"), "%Y%m%d")
date_part = match.group("date")
time_part = match.group("time") or "000000"
parsed = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S")
date_text = CSVFileMetadata.format_date(parsed)
except ValueError:
date_text = ""
@@ -211,7 +233,7 @@ class FileBrowser(QObject):
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / "isc_data"
return base_dir / DEFAULT_DATA_DIR_NAME
def _set_current_directory(self, directory: Path) -> None:
normalized = Path(directory)
@@ -236,7 +258,7 @@ class FileBrowser(QObject):
def showInfo(self, message: str) -> None:
self._show_info(message)
@Slot(str, result="QVariantMap")
@Slot(str, result=dict)
def parseCsvMetadata(self, file_path: str) -> dict:
from pathlib import Path as _Path
@@ -1,7 +1,10 @@
import json
import logging
import os
from pathlib import Path
log = logging.getLogger(__name__)
# ===== Settings Persistence Model =====
class LocalSettings:
@@ -29,6 +32,9 @@ class LocalSettings:
self.data_col_count = 0
self.last_csv_path = ""
# Session persistence
self.session_last_file = ""
# ===== File Path Helpers =====
@classmethod
def _settings_path(cls, directory: str) -> Path:
@@ -51,7 +57,8 @@ class LocalSettings:
if hasattr(settings, key):
setattr(settings, key, value)
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
@@ -66,7 +73,7 @@ class LocalSettings:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
except Exception as e:
print(f"Error saving settings: {e}")
log.exception("Error saving settings: %s",e)
# ===== Master File Helpers =====
@classmethod
@@ -4,10 +4,10 @@ from copy import deepcopy
from pathlib import Path
from typing import Any
from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot
from backend.local_settings import LocalSettings
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
from pygui.backend.data.local_settings import LocalSettings
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
@@ -52,7 +52,7 @@ class LocalSettingsModel(QObject):
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / "isc_data"
return base_dir / DEFAULT_DATA_DIR_NAME
def _new_defaults(self) -> dict[str, Any]:
return {
@@ -221,7 +221,7 @@ class LocalSettingsModel(QObject):
self.waferRetriesChanged.emit()
self._recompute_derived()
@Property("QVariantMap", notify=mastersChanged)
@Property(dict, notify=mastersChanged)
def masters(self) -> dict[str, str]:
return dict(self._masters)
@@ -334,27 +334,3 @@ class LocalSettingsModel(QObject):
@Slot(str)
def clearMaster(self, family: str) -> None:
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
+19
View File
@@ -0,0 +1,19 @@
# ===== Models Sub-package =====
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.stability_detector import StabilityDetector
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
__all__ = [
"Frame", "FramePlayer", "frames_from_wafer_data",
"Stats", "compute_stats",
"SessionModel", "SessionUpdate",
"TemperatureTableModel",
"SensorEditor",
"ThresholdConfig", "classify", "resolve_bounds",
"StabilityDetector",
]
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Frame:
"""One sample across all sensors ata a point in time"""
seq: int # monotonically increasing
time: float # seconds (relative or epoch)
values: list[float] # one per sensor, in sensor-layout order
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import ZWaferData
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
"""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)]
class FramePlayer:
def __init__(self) -> None:
self._frames: list[Frame] =[]
self._i =0
def load(self, frames: list[Frame]) -> "FramePlayer":
self._frames = frames
self._i = 0
return self
@property
def total(self) -> int:
return len(self._frames)
@property
def index(self) -> int:
return self._i
@property
def at_end(self) -> bool:
return self._i >= len(self._frames) -1
def current(self) -> Frame | None:
if not self._frames:
return None
return self._frames[self._i]
def _clamp(self, i:int):
return max(0, min(i, len(self._frames) - 1)) if self._frames else 0
def step(self, delta: int) -> "FramePlayer":
self._i = self._clamp(self._i + delta)
return self
def seek(self, i: int) -> "FramePlayer":
self._i = self._clamp(i)
return self
def next_frame_ms(self) -> float:
"""Wall-clock ms until the next frame based on recorded timestamps; 0 if at end."""
if self._i + 1 >= len(self._frames):
return 0.0
return (self._frames[self._i + 1].time - self._frames[self._i].time) * 1000.0
+38
View File
@@ -0,0 +1,38 @@
"""Per-frame descriptive statistics"""
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Stats:
min: float; min_index: int
max: float; max_index: int
diff: float; avg: float
sigma: float; three_sigma: float
def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean:
return Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
min_index, min_v = min(clean, key=lambda iv: iv[1])
max_index, max_v = max(clean, key=lambda iv: iv[1])
nums = [v for _, v in clean]
avg = sum(nums) / len(nums)
variance = sum((v - avg) ** 2 for v in nums) / len(nums)
sigma = math.sqrt(variance)
return Stats(
min=min_v,
min_index=min_index,
max=max_v,
max_index=max_index,
diff=max_v - min_v,
avg=avg,
sigma=sigma,
three_sigma=3 * sigma,
)
+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
+48
View File
@@ -0,0 +1,48 @@
"""Per-session sensor value overrides (replacement and/or offset)."""
from __future__ import annotations
class SensorEditor:
def __init__(self) -> None:
self._replacements: dict[int, float] = {}
self._offsets: dict[int, float] = {}
def set_replacement(self, index: int, value: float) -> None:
"""Force sensor `index` to read `value` for every frame this session."""
self._replacements[index] = value
def set_offset(self, index: int, value: float) -> None:
"""Shift sensor `index` by `delta` (applied after any replacement)."""
self._offsets[index] = value
def clear(self, index: int | None = None) -> None:
"""Remove overrides. Pass an index to clear one sensor; omit to clear all."""
if index is None:
self._replacements.clear()
self._offsets.clear()
else:
self._replacements.pop(index, None)
self._offsets.pop(index, None)
def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets)
def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets))
def apply(self, values: list[float]) -> list[float]:
"""Return a new list wit all overrides applied (original unchanged)"""
if not self.has_overrides():
return list(values)
result = list(values)
for i in range(len(result)):
v = result[i]
if i in self._replacements:
v = self._replacements[i]
if i in self._offsets:
v += self._offsets[i]
result[i] = v
return result
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from dataclasses import dataclass
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.stability_detector import StabilityDetector
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
@dataclass(frozen=True)
class SessionUpdate:
seq: int
values: list[float]
bands: list[str]
stats: Stats
state: str
target: float
margin: float
class SessionModel:
def __init__(self, thresholds: ThresholdConfig | None = None) -> None:
self._config = thresholds or ThresholdConfig()
self._stability = StabilityDetector()
def set_thresholds(self, config: ThresholdConfig) -> None:
self._config = config
def reset(self) -> None:
self._stability.reset()
def process(self, frame: Frame) -> SessionUpdate:
stats = compute_stats(frame.values)
target, margin = resolve_bounds(frame.values, self._config)
bands = [classify(v, target, margin)for v in frame.values]
# State always track the user's process set_point, even in auto-color mode.
state = self._stability.update(stats.avg, frame.time, self._config.set_point)
return SessionUpdate(
seq=frame.seq, values=frame.values, bands=bands,
stats=stats, state=state, target=target, margin=margin,
)
@@ -0,0 +1,34 @@
"""Detect process state (Idle, Ramp, Set) from the running average temp"""
from __future__ import annotations
from typing import Optional
STATE_IDLE = "idle"
STATE_RAMP = "ramp"
STATE_SET = "set"
class StabilityDetector:
def __init__(self, idle_below: float = 50.0, tolerance: float = 1.0,
settle_seconds: float = 10.0) -> None:
self._idle_below = idle_below
self._tolerance = tolerance
self._settle_seconds = settle_seconds
self._near_since: Optional[float] = None # When avg entered the +- tolerance band
def reset(self) -> None:
self._near_since = None
def update(self, avg: float, time: float, set_point: float) ->str:
if avg < self._idle_below:
self._near_since = None
return STATE_IDLE
if abs(avg - set_point) <= self._tolerance:
if self._near_since is None:
self._near_since = time
if time - self._near_since >= self._settle_seconds:
return STATE_SET
return STATE_RAMP
self._near_since = None
return STATE_RAMP
@@ -0,0 +1,43 @@
"""Classify sensor values into three bands around (target, margin)
Auto mode derives target=mean, margin=1
"""
from __future__ import annotations
import math
from dataclasses import dataclass
BAND_IN = "in_range"
BAND_HIGH = "high"
BAND_LOW = "low"
@dataclass(frozen=True)
class ThresholdConfig:
set_point: float = 149.0 # process target: used as band TARGET when auto=False
margin: float = 1.0 # used as band MARGIN when auto=False
auto: bool = True # auto=True: target=frame mean, margin=frame 1σ
def resolve_bounds(values: list[float], cfg: ThresholdConfig) -> tuple[float, float]:
if not cfg.auto:
return cfg.set_point, cfg.margin
clean = [v for v in values if not math.isnan(v)]
if not clean:
return cfg.set_point, cfg.margin
mean = sum(clean) / len(clean)
variance = sum((v - mean) ** 2 for v in clean) / len(clean)
return mean, math.sqrt(variance)
def classify(value: float, target: float, margin: float) -> str:
if math.isnan(value):
return BAND_IN
if value > target + margin:
return BAND_HIGH
if value < target - margin:
return BAND_LOW
return BAND_IN
def classify_all(values: list[float], cfg: ThresholdConfig) -> list[str]:
target, margin = resolve_bounds(values, cfg)
return [classify(v, target, margin)for v in values ]
+48
View File
@@ -0,0 +1,48 @@
"""Threshold-based temperature profile segmentation into Ramp/Soak/Cool phases."""
from dataclasses import dataclass
@dataclass
class DataSegment:
"""A contiguous segment of the temperature profile."""
label: str
start_frame: int
end_frame: int
avg_temp: float
def segment_profile(avg_temps: list[float], threshold: float = 40.0) -> list[DataSegment]:
"""Scan average temperatures and identify Ramp/Soak/Cool phases.
Args:
avg_temps: Per-frame average temperatures.
threshold: Temperature threshold (°C) defining Soak entry/exit.
Returns:
List of DataSegment objects covering the entire profile.
"""
if not avg_temps:
return []
segments: list[DataSegment] = []
n = len(avg_temps)
i = 0
while i < n and avg_temps[i] < threshold:
i += 1
if i > 0:
avg = sum(avg_temps[:i]) / i
segments.append(DataSegment("Ramp", 0, i - 1, round(avg, 2)))
while i < n:
start = i
above = avg_temps[i] >= threshold
while i < n and (avg_temps[i] >= threshold) == above:
i += 1
label = "Soak" if above else "Cool"
count = i - start
avg = sum(avg_temps[start:i]) / count
segments.append(DataSegment(label, start, i - 1, round(avg, 2)))
return segments
+29
View File
@@ -0,0 +1,29 @@
import logging
from functools import wraps
def slot_error_boundary(func):
"""Wrap a @Slot method so exceptions are logged + signalled to QML.
Usage (stack under @Slot):
@Slot()
@slot_error_boundary
def myMethod(self) -> None:
...
On exception:
- Logs full traceback via logger.exception()
- Emits logMessage signal with human-readable error (if available)
- Returns None so QML never sees a crash
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except Exception as e:
logger = logging.getLogger(self.__class__.__module__)
logger.exception("Slot '%s' raised: %s", func.__name__, e)
if hasattr(self, "logMessage"):
self.logMessage.emit(f"Error in {func.__name__}: {str(e)}")
return None
return wrapper
@@ -0,0 +1,11 @@
# ===== Visualization Sub-package =====
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
from pygui.backend.visualization.graph_view import GraphView
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.visualization.trend_chart_item import TrendChartItem
from pygui.backend.visualization.wafer_map_item import WaferMapItem
__all__ = [
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
"interpolate_field",
]
@@ -0,0 +1,417 @@
"""QQuickPaintedItem line chart for embedding in QML.
Draws multiple sensor-data line series with axis labels, grid lines,
auto-scaled Y range, legend, and a dark-theme default palette that
complements Theme.qml.
Usage from QML::
import ISC.Wafer
GraphQuickItem {
id: graph
anchors.fill: parent
seriesData: [...]
sensorNames: ["Sensor1", "Sensor2"]
title: "Sensor Temperature Over Time"
yLabel: "Temperature (°C)"
xLabel: "Measurement"
}
"""
from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem
QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings."""
seriesDataChanged = Signal()
sensorNamesChanged = Signal()
titleChanged = Signal()
xLabelChanged = Signal()
yLabelChanged = Signal()
colorsChanged = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._series_data: list[list[float]] = [] # [[sensor1_vals...], [sensor2_vals...]]
self._sensor_names: list[str] = [] # parallel to series_data
self._title: str = ""
self._x_label: str = "Measurement"
self._y_label: str = "Temperature (°C)"
self._show_legend: bool = True
# Dark-theme colour defaults (match Theme.qml where possible)
self._bg_color = QColor("#1A1A1A") # tone200
self._grid_color = QColor("#2A2A2A") # toneBorder
self._axis_color = QColor("#A8A8A8") # toneMute
self._text_color = QColor("#F2F2F2") # toneText
self._series_colors: list[QColor] = [
QColor("#FF5757"), # Red
QColor("#42A5F5"), # Blue
QColor("#66BB6A"), # Green
QColor("#FFA726"), # Orange
QColor("#AB47BC"), # Purple
QColor("#00BCD4"), # Cyan
QColor("#FF7043"), # Deep Orange
QColor("#795548"), # Brown
QColor("#5C6BC0"), # Indigo
QColor("#307D75"), # Teal
]
self._min_y: float = 0.0
self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged)
def seriesData(self) -> list:
return self._series_data
@seriesData.setter
def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])]
self._auto_range()
self.seriesDataChanged.emit()
self.update()
@Property("QStringList", notify=sensorNamesChanged)
def sensorNames(self) -> list:
return self._sensor_names
@sensorNames.setter
def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or [])
self.sensorNamesChanged.emit()
self.update()
@Property(str, notify=titleChanged)
def title(self) -> str:
return self._title
@title.setter
def title(self, val: str) -> None:
self._title = str(val or "")
self.titleChanged.emit()
self.update()
@Property(str, notify=xLabelChanged)
def xLabel(self) -> str:
return self._x_label
@xLabel.setter
def xLabel(self, val: str) -> None:
self._x_label = str(val or "")
self.xLabelChanged.emit()
self.update()
@Property(str, notify=yLabelChanged)
def yLabel(self) -> str:
return self._y_label
@yLabel.setter
def yLabel(self, val: str) -> None:
self._y_label = str(val or "")
self.yLabelChanged.emit()
self.update()
@Property(bool, notify=colorsChanged)
def showLegend(self) -> bool:
return self._show_legend
@showLegend.setter
def showLegend(self, val: bool) -> None:
self._show_legend = bool(val)
self.colorsChanged.emit()
self.update()
# ── Colour properties (QML can bind Theme tokens here) ────────────────────
@Property(QColor, notify=colorsChanged)
def backgroundColor(self) -> QColor:
return self._bg_color
@backgroundColor.setter
def backgroundColor(self, c: QColor) -> None:
self._bg_color = c
self.update()
@Property(QColor, notify=colorsChanged)
def gridColor(self) -> QColor:
return self._grid_color
@gridColor.setter
def gridColor(self, c: QColor) -> None:
self._grid_color = c
self.update()
@Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor:
return self._axis_color
@axisColor.setter
def axisColor(self, c: QColor) -> None:
self._axis_color = c
self.update()
@Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor:
return self._text_color
@textColor.setter
def textColor(self, c: QColor) -> None:
self._text_color = c
self.update()
@Property("QVariantList", notify=colorsChanged)
def seriesColors(self) -> list:
"""Return hex strings of the current series color palette."""
return [c.name() for c in self._series_colors]
@seriesColors.setter
def seriesColors(self, hex_list: list) -> None:
colors = []
for h in (hex_list or []):
try:
colors.append(QColor(str(h)))
except Exception:
colors.append(QColor("#FFFFFF"))
if colors:
self._series_colors = colors
self.update()
# ── internal ──────────────────────────────────────────────────────────────
def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding."""
all_vals: list[float] = []
for series in self._series_data:
for v in series:
try:
all_vals.append(float(v))
except (ValueError, TypeError):
pass
if not all_vals:
return
mn = min(all_vals)
mx = max(all_vals)
if mx <= mn:
mn -= 5.0
mx += 5.0
pad = (mx - mn) * 0.1
self._min_y = mn - pad
self._max_y = mx + pad
def _plot_rect(self) -> QRectF:
w = self.width()
h = self.height()
pl = self._padding.get("left", 60)
pr = self._padding.get("right", 20)
pt = self._padding.get("top", 30)
pb = self._padding.get("bottom", 50)
return QRectF(pl, pt, max(10, w - pl - pr), max(10, h - pt - pb))
# ── paint ─────────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None:
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w = self.width()
h = self.height()
if w < 50 or h < 50:
return
pr = self._plot_rect()
plot_left = pr.left()
plot_top = pr.top()
plot_w = pr.width()
plot_h = pr.height()
# --- Background fill ---
painter.fillRect(0, 0, int(w), int(h), self._bg_color)
# --- Title ---
if self._title:
title_font = QFont()
title_font.setPixelSize(14)
title_font.setBold(True)
painter.setFont(title_font)
painter.setPen(QPen(self._text_color))
painter.drawText(
QRectF(0, 4, w, 24),
Qt.AlignmentFlag.AlignHCenter,
self._title,
)
if not self._series_data:
self._paint_empty(painter, pr)
return
# --- Determine Y tick count and values ---
n_y_ticks = max(2, min(8, int(plot_h // 40)))
y_range = self._max_y - self._min_y
if y_range <= 0:
y_range = 10.0
y_step = y_range / (n_y_ticks - 1) if n_y_ticks > 1 else y_range
# --- Grid & Y-axis labels ---
grid_pen = QPen(self._grid_color, 1)
axis_pen = QPen(self._axis_color, 1)
label_font = QFont()
label_font.setPixelSize(10)
painter.setFont(label_font)
fm = QFontMetrics(label_font)
for i in range(n_y_ticks):
y_val = self._min_y + i * y_step
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
y_px = plot_top + y_frac * plot_h
# Grid line
painter.setPen(grid_pen)
painter.drawLine(
int(plot_left), int(y_px),
int(plot_left + plot_w), int(y_px),
)
# Label
label_str = f"{y_val:.1f}"
tw = fm.horizontalAdvance(label_str)
painter.setPen(axis_pen)
painter.drawText(
int(plot_left - tw - 6), int(y_px + fm.height() // 2 - 3),
label_str,
)
# --- Y axis label (rotated) ---
if self._y_label:
painter.save()
painter.setPen(QPen(self._axis_color))
label_font_b = QFont()
label_font_b.setPixelSize(11)
painter.setFont(label_font_b)
painter.translate(14, plot_top + plot_h / 2)
painter.rotate(-90)
painter.drawText(0, 0, self._y_label)
painter.restore()
# --- X axis label ---
if self._x_label:
painter.setPen(QPen(self._axis_color))
label_font_b = QFont()
label_font_b.setPixelSize(11)
painter.setFont(label_font_b)
painter.drawText(
QRectF(plot_left, h - 18, plot_w, 16),
Qt.AlignmentFlag.AlignHCenter,
self._x_label,
)
# --- X-axis ticks ---
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
if num_points > 1:
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks):
idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1)
x_px = plot_left + x_frac * plot_w
painter.setPen(axis_pen)
painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1),
)
# --- Axes boundary ---
border_pen = QPen(self._axis_color, 1)
painter.setPen(border_pen)
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
if max_len < 1:
return
# --- Draw each series ---
for si, series in enumerate(self._series_data):
if len(series) < 1:
continue
color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2)
painter.setPen(pen)
pts: list[tuple[float, float]] = []
for i, val in enumerate(series):
try:
v = float(val)
except (ValueError, TypeError):
continue
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
x_px = plot_left + x_frac * plot_w
y_px = plot_top + y_frac * plot_h
pts.append((x_px, y_px))
if len(pts) < 2:
if len(pts) == 1:
painter.drawPoint(int(pts[0][0]), int(pts[0][1]))
continue
for i in range(len(pts) - 1):
painter.drawLine(
int(pts[i][0]), int(pts[i][1]),
int(pts[i + 1][0]), int(pts[i + 1][1]),
)
# --- Legend ---
if self._show_legend and self._sensor_names:
legend_font = QFont()
legend_font.setPixelSize(10)
painter.setFont(legend_font)
lfm = QFontMetrics(legend_font)
# Compute legend dimensions
lh = 16
max_name_w = max(lfm.horizontalAdvance(n) for n in self._sensor_names)
lw = max_name_w + 24
legend_count = min(len(self._sensor_names), len(self._series_data))
lh_total = legend_count * lh + 4
legend_x = int(plot_left + plot_w - lw - 8)
legend_y = int(plot_top + 8)
# Background
painter.fillRect(legend_x, legend_y, lw, lh_total, QColor(0, 0, 0, 140))
for i in range(legend_count):
y_pos = legend_y + 4 + i * lh
color = self._series_colors[i % len(self._series_colors)]
# Color swatch
painter.fillRect(legend_x + 4, y_pos + 2, 12, 10, color)
# Label
painter.setPen(QPen(self._text_color))
painter.drawText(legend_x + 20, y_pos + 11, self._sensor_names[i])
def _paint_empty(self, painter: QPainter, pr: QRectF) -> None:
"""Draw placeholder when no data is available."""
painter.setPen(QPen(self._axis_color))
empty_font = QFont()
empty_font.setPixelSize(14)
painter.setFont(empty_font)
painter.drawText(
pr,
Qt.AlignmentFlag.AlignCenter,
"No data — read a wafer or open a CSV file",
)
@@ -9,16 +9,13 @@ from __future__ import annotations
import logging
from typing import Any, Optional
from PySide6.QtCore import QObject, Property, Signal, Slot
import pyqtgraph as pg
from pyqtgraph import PlotWidget
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from PySide6.QtWidgets import QWidget
log = logging.getLogger(__name__)
# Import pyqtgraph after Qt is initialized
import pyqtgraph as pg
from pyqtgraph import PlotWidget
class GraphView(QObject):
"""QML-exposed controller for a pyqtgraph line chart.
@@ -167,3 +164,71 @@ class GraphView(QObject):
self._plot_window = None
self._plot_widget = None
self._series = []
@Slot(str)
def updateTrend(self, avgs_json: str) -> None:
"""Plot the running avg temp series."""
import json
if not self._plot_widget:
return
try:
avgs = json.loads(avgs_json)
except (json.JSONDecodeError, TypeError) as exc:
log.error("Failed to parse trend data: %s", exc)
return
if hasattr(self,'_trend_line') and self._trend_line is not None:
self._plot_widget.removeItem(self._trend_line)
if not avgs:
return
x = list(range(len(avgs)))
pen = pg.mkPen("#5B9DF5", width=2)
self._trend_line = self._plot_widget.plot(x, avgs, name="Average", pen=pen)
# Y-axis range with buffer
y_min, y_max = min(avgs), max(avgs)
buf = max((y_max - y_min) * 0.1, 1.0) if y_max > y_min else 5.0
self._plot_widget.setYRange(y_min - buf, y_max + buf)
@Slot(str,str,str)
def updateComparison(self, series_a_json: str, series_b_json: str,path_json: str) -> None:
"""Overlay two runs with DTW warping path connections.
Args:
series_a_json: JSON list of floats -- Run Aaverage temps.
series_b_json: JSON list of floats -- Run B average temps.
path_json: JSON list of [index_a, index_b] paris.
"""
import json
if not self._plot_widget:
return
self._plot_widget.clear()
try:
a = json.loads(series_a_json)
b = json.loads(series_b_json)
path = json.loads(path_json)
except (json.JSONDecodeError, TypeError) as exc:
log.error("Failed to parse comparison data: %s", exc)
return
pen_a = pg.mkPen("#5B9DF5", width=2) #blue
pen_b = pg.mkPen("#22C55E", width=2) #green
self._plot_widget.plot(list(range(len(a))), a, name="Run A", pen=pen_a)
self._plot_widget.plot(list(range(len(b))), b, name="Run B", pen=pen_b)
# Draw every 20th warping link as vertical dotted lines
step = max(1, len(path)//20)
for i in range (0,len(path),step):
index_a, index_b = path[i]
if index_a < len(a) and index_b < len(b):
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
style=Qt.DashLine))
self._plot_widget.addItem(line)
self._plot_widget.setTitle("DTW Comparison")
self._plot_widget.setLabel("left", "Temperature", units="°C")
@@ -0,0 +1,61 @@
"""RBF (thin-plate spline) heatmap field.
Uses CuPy for GPU acceleration when available, falls back to NumPy + SciPy.
"""
from __future__ import annotations
import numpy as np
from scipy.interpolate import RBFInterpolator
try:
import cupy as _cupy # type: ignore
BACKEND = "cupy"
except Exception:
_cupy = None
BACKEND = "numpy"
_KERNEL = "thin_plate_spline"
_SMOOTHING = 0.0
def interpolate_field(
xs: np.ndarray,
ys: np.ndarray,
vs: np.ndarray,
*,
width: int,
height: int,
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
round_clip: bool = False,
) -> np.ndarray:
"""Return a (height, width) float64 array of interpolated values.
Args:
xs, ys: sensor positions in mm (1-D arrays, length N)
vs: sensor values (length N)
width/height: output grid dimensions in pixels
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
round_clip: if True, pixels outside the inscribed ellipse become NaN
"""
coords = np.column_stack([xs, ys])
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
xmin, xmax, ymin, ymax = extent
gx = np.linspace(xmin, xmax, width)
gy = np.linspace(ymin, ymax, height)
grid_x, grid_y = np.meshgrid(gx, gy)
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
field = rbf(flat).reshape(height, width)
if round_clip:
cx = (xmin + xmax) / 2
cy = (ymin + ymax) / 2
rx = (xmax - xmin) / 2
ry = (ymax - ymin) / 2
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
field = np.where(dist <= 1.0, field, np.nan)
return field.astype(np.float64)
@@ -0,0 +1,262 @@
"""QQuickPaintedItem trend chart — running-average temperature over time.
Renders a single polyline series (per-frame average temperatures) using QPainter.
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
The series is driven by the existing `streamController.trendData` signal, which
emits a JSON-encoded list of floats from both review-mode replay and live-mode
streaming.
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem
log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class TrendChartItem(QQuickPaintedItem):
"""Painted trend chart; driven by a list of floats via the `data` property."""
dataChanged = Signal()
hasDataChanged = Signal()
lineColorChanged = Signal()
gridColorChanged = Signal()
textColorChanged = Signal()
paddingChanged = Signal()
def __init__(self, parent: Optional[QQuickPaintedItem] = None) -> None:
super().__init__(parent)
self.setRenderTarget(QQuickPaintedItem.RenderTarget.FramebufferObject)
self.setAntialiasing(True)
self.setFillColor(QColor("transparent"))
self._data: list[float] = []
self._padding: int = 8
self._line_color = QColor("#5B9DF5")
self._grid_color = QColor("#2A3441")
self._text_color = QColor("#CBD5E1")
# ── Qt properties ─────────────────────────────────────────────────────
@Property("QVariantList", notify=dataChanged)
def data(self) -> list[float]:
return self._data
@data.setter
def data(self, val) -> None:
coerced = self._coerce_floats(val)
if coerced == self._data:
return
self._data = coerced
self.dataChanged.emit()
self.hasDataChanged.emit()
self.update()
@Property(bool, notify=hasDataChanged)
def hasData(self) -> bool:
"""True when the data list has at least one point.
QML can bind to this safely (QVariantList `.length` is not bindable).
"""
return len(self._data) > 0
@Property(QColor, notify=lineColorChanged)
def lineColor(self) -> QColor:
return self._line_color
@lineColor.setter
def lineColor(self, c: QColor) -> None:
if c == self._line_color:
return
self._line_color = QColor(c)
self.lineColorChanged.emit()
self.update()
@Property(QColor, notify=gridColorChanged)
def gridColor(self) -> QColor:
return self._grid_color
@gridColor.setter
def gridColor(self, c: QColor) -> None:
if c == self._grid_color:
return
self._grid_color = QColor(c)
self.gridColorChanged.emit()
self.update()
@Property(QColor, notify=textColorChanged)
def textColor(self) -> QColor:
return self._text_color
@textColor.setter
def textColor(self, c: QColor) -> None:
if c == self._text_color:
return
self._text_color = QColor(c)
self.textColorChanged.emit()
self.update()
@Property(int, notify=paddingChanged)
def padding(self) -> int:
return self._padding
@padding.setter
def padding(self, val: int) -> None:
v = max(0, int(val))
if v == self._padding:
return
self._padding = v
self.paddingChanged.emit()
self.update()
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
@Slot(str)
def setDataFromJson(self, avgs_json: str) -> None:
"""Slot for QML Connections handler: parse JSON array and update data.
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
"""
try:
parsed = json.loads(avgs_json) if avgs_json else []
except (json.JSONDecodeError, TypeError) as exc:
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
return
coerced = self._coerce_floats(parsed)
if coerced == self._data:
return
self._data = coerced
self.dataChanged.emit()
self.hasDataChanged.emit()
self.update()
# ── paint ─────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None: # type: ignore[override]
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
w = self.width()
h = self.height()
if w <= 0 or h <= 0:
return
plot_rect = QRectF(self._padding, self._padding,
max(1, w - 2 * self._padding),
max(1, h - 2 * self._padding))
self._draw_grid(painter, plot_rect)
if len(self._data) < 2:
return
y_min, y_max = self._y_range()
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
self._draw_line(painter, plot_rect, y_min, y_max)
# ── internals ─────────────────────────────────────────────────────────
def _coerce_floats(self, val) -> list[float]:
if not val:
return []
out: list[float] = []
for v in val:
try:
out.append(float(v))
except (TypeError, ValueError):
continue
return out
def _y_range(self) -> tuple[float, float]:
lo = min(self._data)
hi = max(self._data)
if hi - lo < 1e-9:
return lo - 5.0, hi + 5.0
buf = (hi - lo) * 0.1
return lo - buf, hi + buf
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1:
return plot_rect.left()
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
if y_max - y_min < 1e-9:
return plot_rect.center().y()
t = (v - y_min) / (y_max - y_min)
return plot_rect.bottom() - t * plot_rect.height()
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
pen = QPen(self._grid_color)
pen.setWidthF(1.0)
pen.setCosmetic(True)
painter.setPen(pen)
rows = 4
for i in range(rows + 1):
y = plot_rect.top() + (i / rows) * plot_rect.height()
painter.drawLine(int(plot_rect.left()), int(y),
int(plot_rect.right()), int(y))
def _draw_axes_labels(
self,
painter: QPainter,
plot_rect: QRectF,
y_min: float,
y_max: float,
) -> None:
painter.setPen(self._text_color)
font = QFont(painter.font())
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
painter.setFont(font)
rows = 4
for i in range(rows + 1):
t = i / rows
y_val = y_max - t * (y_max - y_min)
y_px = plot_rect.top() + t * plot_rect.height()
label = f"{y_val:.1f}"
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label)
def _draw_line(
self,
painter: QPainter,
plot_rect: QRectF,
y_min: float,
y_max: float,
) -> None:
n = len(self._data)
pen = QPen(self._line_color)
pen.setWidthF(2.0)
pen.setCosmetic(True)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
poly = QPolygon()
for i, v in enumerate(self._data):
px = self._x_to_px(i, n, plot_rect)
py = self._y_to_px(v, y_min, y_max, plot_rect)
poly.append(QPoint(int(px), int(py)))
painter.drawPolyline(poly)
# Trailing dot at the last data point
last_x = int(self._x_to_px(n - 1, n, plot_rect))
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
painter.setBrush(self._line_color)
painter.setPen(Qt.PenStyle.NoPen)
radius = 3
painter.drawEllipse(last_x - radius, last_y - radius,
radius * 2, radius * 2)
@@ -0,0 +1,617 @@
"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget.
Draws:
Radial ring template (concentric guides + crosshair axes + top notch)
RBF heatmap layer (blended under markers via `blend` 01)
Sensor marker circles colored by band (low/in_range/high)
Numbered labels (toggle via `showLabels`)
All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV).
"""
from __future__ import annotations
import logging
import math
import numpy as np
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot
from PySide6.QtGui import (
QBrush,
QColor,
QFont,
QImage,
QPainter,
QPen,
QPolygon,
) # fmt: skip
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.wafer.zwafer_models import Sensor
log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class WaferMapItem(QQuickPaintedItem):
"""Painted wafer map; driven by SessionController via QML property bindings."""
sensorsChanged = Signal()
valuesChanged = Signal()
bandsChanged = Signal()
targetChanged = Signal()
marginChanged = Signal()
blendChanged = Signal()
showLabelsChanged = Signal()
colorsChanged = Signal()
shapeChanged = Signal()
sizeChanged = Signal()
thicknessChanged = Signal()
showThicknessChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._sensors: list[Sensor] = []
self._values: list[float] = []
self._bands: list[str] = []
self._target: float = 149.0
self._margin: float = 1.0
self._blend: float = 0.0
self._show_labels: bool = True
self._shape: str = "round"
self._size: float = 300.0
self._thickness_data: list[float] = []
self._show_thickness: bool = False
self._thickness_heatmap:QImage | None = None
# Dark-theme color defaults (match Theme.qml tokens)
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
self._axis_color = QColor("#3A4D5C") # waferAxisColor (softBorder)
self._low_color = QColor("#5B9DF5") # sensorLow
self._in_range_color = QColor("#22C55E") # sensorInRange
self._high_color = QColor("#EF4444") # sensorHigh
self._text_color = QColor("#CBD5E1") # bodyColor
# Internal draw state
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
self._marker_r: int = 4
self._heatmap: QImage | None = None
self.widthChanged.connect(self._on_resize)
self.heightChanged.connect(self._on_resize)
# ── Qt properties ────────────────────────────────────────────────────
@Property("QVariantList", notify=sensorsChanged)
def sensors(self) -> list:
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
@sensors.setter
def sensors(self, val: list) -> None:
self._sensors = [
Sensor(
label=d["label"],
x=float(d["x"]),
y=float(d["y"]),
side=d.get("side", "right"),
offset_x=float(d.get("offset_x", 0.0)),
offset_y=float(d.get("offset_y", 0.0))
)
for d in (val or [])
]
self._rebuild()
self.sensorsChanged.emit()
@Property("QVariantList", notify=valuesChanged)
def values(self) -> list:
return self._values
@values.setter
def values(self, val: list) -> None:
self._values = list(val or [])
self._rebuild_heatmap()
self.valuesChanged.emit()
self.update()
@Property("QVariantList", notify=bandsChanged)
def bands(self) -> list:
return self._bands
@bands.setter
def bands(self, val: list) -> None:
self._bands = list(val or [])
self.bandsChanged.emit()
self.update()
@Property(float, notify=targetChanged)
def target(self) -> float:
return self._target
@target.setter
def target(self, val: float) -> None:
self._target = float(val)
self._rebuild_heatmap()
self.targetChanged.emit()
self.update()
@Property(float, notify=marginChanged)
def margin(self) -> float:
return self._margin
@margin.setter
def margin(self, val: float) -> None:
self._margin = float(val)
self._rebuild_heatmap()
self.marginChanged.emit()
self.update()
@Property(float, notify=blendChanged)
def blend(self) -> float:
return self._blend
@blend.setter
def blend(self, val: float) -> None:
self._blend = max(0.0, min(1.0, float(val)))
if self._blend > 0 and self._heatmap is None:
self._rebuild_heatmap()
self.blendChanged.emit()
self.update()
@Property(bool, notify=showLabelsChanged)
def showLabels(self) -> bool:
return self._show_labels
@showLabels.setter
def showLabels(self, val: bool) -> None:
self._show_labels = bool(val)
self.showLabelsChanged.emit()
self.update()
@Property(str, notify=shapeChanged)
def shape(self) -> str:
return self._shape
@shape.setter
def shape(self, val: str) -> None:
self._shape = str(val).lower()
self._rebuild()
self.shapeChanged.emit()
@Property(float, notify=sizeChanged)
def size(self) -> float:
return self._size
@size.setter
def size(self, val: float) -> None:
self._size = float(val)
self._rebuild()
self.sizeChanged.emit()
@Property("QVariantList", notify=thicknessChanged)
def thicknessData(self) -> list:
return self._thickness_data
@thicknessData.setter
def thicknessData(self, val:list) -> None:
self._thickness_data = list(val or [])
self._rebuild_thickness()
self.thicknessChanged.emit()
self.update
@Property(bool, notify=showThicknessChanged)
def showThickness(self) -> bool:
return self._show_thickness
@showThickness.setter
def showThickness(self, val: bool) -> None:
self._show_thickness = bool(val)
self.showThicknessChanged.emit()
self.update()
# Colour properties — QML can bind these to Theme tokens
@Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color
@ringColor.setter
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor: return self._axis_color
@axisColor.setter
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def lowColor(self) -> QColor: return self._low_color
@lowColor.setter
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def inRangeColor(self) -> QColor: return self._in_range_color
@inRangeColor.setter
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def highColor(self) -> QColor: return self._high_color
@highColor.setter
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor: return self._text_color
@textColor.setter
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
# ── slots ─────────────────────────────────────────────────────────────
@Slot(float, float, result=int)
def which_marker(self, x: float, y: float) -> int:
"""Return the sensor index nearest to (x, y) within marker radius, else -1."""
r = max(self._marker_r, 6)
for idx, (mx, my) in self._markers.items():
if abs(mx - x) <= r and abs(my - y) <= r:
return idx
return -1
@Slot(str, result=bool)
def export_image(self, file_path: str) -> bool:
"""Export the current wafer map rendering to a PNG file
Args:
file_pat: Absolute path for the output PNG.
Returns:
True on success, False on failure
"""
if not file_path:
return False
try:
result = self.grabToImage()
img = result.image()
img.save(file_path, "PNG")
return True
except Exception as e:
log.error("Export failed: %s", e)
return False
# ── internal ─────────────────────────────────────────────────────────
def _rebuild_thickness(self) -> None:
"""Interpolate thickness data into a gray/orange heatmap QImage."""
if not self._sensors or not self._thickness_data:
self._thickness_heatmap = None
return
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors])
ys = np.array([s.y for s in self._sensors])
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._thickness_heatmap = None
return
try:
field = interpolate_field(
xs, ys, vs,
width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
)
except Exception:
self._thickness_heatmap = None
return
# Gray/orange colormap: map field range 0→1 to gray→orange
vmin, vmax = np.nanmin(field), np.nanmax(field)
span = vmax - vmin or 1.0
t = np.clip((field - vmin) / span, 0.0, 1.0)
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
rgb = np.zeros((ds, ds, 3), dtype=np.float32)
rgb[:, :, 0] = 0.5 + 0.5 * t
rgb[:, :, 1] = 0.5 + 0.15 * t
rgb[:, :, 2] = 0.5 - 0.5 * t
rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8)
self._thickness_heatmap = (
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
)
def _on_resize(self) -> None:
self._rebuild()
def _rebuild(self) -> None:
self._compute_markers()
self._rebuild_heatmap()
self.update()
def _draw_size(self) -> int:
return max(1, int(min(self.width(), self.height())))
def _center(self) -> tuple[int, int]:
return int(self.width() / 2), int(self.height() / 2)
def _wafer_radius_mm(self) -> float:
"""Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor)."""
if not self._sensors:
return 150.0
r = max(math.hypot(s.x, s.y) for s in self._sensors)
return r * 1.05
def _sensor_ring_radii_mm(self) -> list[float]:
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
if not self._sensors:
r = self._wafer_radius_mm()
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0)
groups: list[float] = []
for r in radii:
if not groups or r - groups[-1] > 2.0:
groups.append(r)
else:
groups[-1] = (groups[-1] + r) / 2 # merge close values
# Always include the outer boundary ring so the wafer circle is drawn.
outer = self._wafer_radius_mm()
if not groups or outer - groups[-1] > 2.0:
groups.append(outer)
return groups
def _scale(self, ds: int, r_mm: float) -> float:
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
return (ds / 2 - 24) / r_mm
def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]:
"""Center-origin mm → pixel (top-left origin). Y is flipped."""
return cx + int(x_mm * scale), cy - int(y_mm * scale)
def _compute_markers(self) -> None:
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
sc = self._scale(ds, r_mm)
cx, cy = self._center()
self._marker_r = max(3, ds // 70)
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
for i, s in enumerate(self._sensors)}
def _rebuild_heatmap(self) -> None:
if not self._sensors or not self._values or self._blend == 0.0:
self._heatmap = None
return
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors])
ys = np.array([s.y for s in self._sensors])
vs = np.array(self._values[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._heatmap = None
return
try:
field = interpolate_field(
xs, ys, vs,
width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
)
except Exception:
self._heatmap = None
return
self._heatmap = self._field_to_qimage(field, ds)
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
"""Apply a band-aware tri-color gradient → RGBA QImage."""
lo_b = self._target - self._margin
hi_b = self._target + self._margin
span = hi_b - lo_b or 1.0
# t: 0 = lo_b, 1 = hi_b (clipped)
t = np.clip((field - lo_b) / span, 0.0, 1.0)
def c(q: QColor) -> np.ndarray:
return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32)
lo_c = c(self._low_color)
mid_c = c(self._in_range_color)
hi_c = c(self._high_color)
t2 = t * 2 # 0→2 across full range
lower = t <= 0.5
t_lo = np.clip(t2, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in lower half
t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in upper half
rgb = np.where(
lower[:, :, np.newaxis],
lo_c * (1 - t_lo) + mid_c * t_lo,
mid_c * (1 - t_hi) + hi_c * t_hi,
)
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
# ── paint ─────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None:
ds = self._draw_size()
r_px = int(ds / 2 - 4)
cx, cy = self._center()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
self._paint_template(painter, cx, cy, r_px)
if self._heatmap and self._blend > 0.0:
painter.setOpacity(self._blend)
painter.drawImage(cx - self._heatmap.width() // 2,
cy - self._heatmap.height() // 2, self._heatmap)
painter.setOpacity(1.0)
if self._show_thickness and self._thickness_heatmap:
painter.setOpacity(0.4)
painter.drawImage(cx - self._thickness_heatmap.width() // 2,
cy - self._thickness_heatmap.height()//2, self._thickness_heatmap)
painter.setOpacity(1.0)
self._paint_markers(painter)
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
sc = self._scale(ds, r_mm)
if self._shape == "square":
# Draw square boundary (thick pen)
border_pen = QPen(self._ring_color, 2, Qt.PenStyle.SolidLine)
painter.setPen(border_pen)
half_size_px = int(self._size / 2 * sc)
painter.drawRect(cx - half_size_px, cy - half_size_px, 2 * half_size_px, 2 * half_size_px)
# Crosshair axes
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
painter.setPen(axis_pen)
painter.drawLine(cx, cy - half_size_px, cx, cy + half_size_px)
painter.drawLine(cx - half_size_px, cy, cx + half_size_px, cy)
# Draw concentric square guide lines at the distinct radii of sensor rings
grid_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
painter.setPen(grid_pen)
for ring_r_mm in self._sensor_ring_radii_mm()[:-1]: # exclude the outermost border
rr = max(1, int(ring_r_mm * sc))
painter.drawRect(cx - rr, cy - rr, 2 * rr, 2 * rr)
else:
# Concentric rings
ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
painter.setPen(ring_pen)
for ring_r_mm in self._sensor_ring_radii_mm():
rr = max(1, int(ring_r_mm * sc))
painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr)
# Crosshair axes
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
painter.setPen(axis_pen)
painter.drawLine(cx, cy - r_px, cx, cy + r_px)
painter.drawLine(cx - r_px, cy, cx + r_px, cy)
# Top notch triangle (wafer orientation marker)
nw = max(6, ds // 25)
nh = max(4, ds // 35)
notch = QPolygon([
QPoint(cx, cy - r_px),
QPoint(cx - nw // 2, cy - r_px + nh),
QPoint(cx + nw // 2, cy - r_px + nh),
])
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(self._axis_color))
painter.drawPolygon(notch)
def _paint_markers(self, painter: QPainter) -> None:
r = self._marker_r
# Scale font size based on the number of sensors to prevent overlap on dense wafers
num_sensors = len(self._sensors)
font_scale = 1.0
if num_sensors > 60:
font_scale = 0.7 # reduce font size by 30% for dense wafers
elif num_sensors > 40:
font_scale = 0.85
id_font = QFont()
id_font.setPointSize(max(4, int(r * font_scale)))
id_font.setBold(True)
temp_font = QFont()
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
band_color = {
"in_range": self._in_range_color,
"high": self._high_color,
"low": self._low_color,
}
for i, s in enumerate(self._sensors):
if i not in self._markers:
continue
px, py = self._markers[i]
color = band_color.get(
self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color,
)
# Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color))
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
if self._show_labels:
has_temp = i < len(self._values)
# Fetch text alignment side and offsets
side = getattr(s, "side", "right").lower()
ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r
# Pre-compute metrics using current scaled fonts
painter.setFont(id_font)
id_fm = painter.fontMetrics()
id_line_h = id_fm.height()
id_ascent = id_fm.ascent()
painter.setFont(temp_font)
temp_fm = painter.fontMetrics()
temp_line_h = temp_fm.height()
temp_ascent = temp_fm.ascent()
id_text = s.label
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
id_w = id_fm.horizontalAdvance(id_text)
temp_w = temp_fm.horizontalAdvance(temp_text) if has_temp else 0
text_w = max(id_w, temp_w)
# Height of the 1 or 2-line text block
text_h = (id_line_h + temp_line_h) if has_temp else id_line_h
# Calculate box top-left (lx, ly) relative to dot center (px, py)
gap = 3
if side == "left":
lx = px - r - gap - text_w - ox
ly = py - text_h // 2 + oy
elif side == "top":
lx = px - text_w // 2 + ox
ly = py - r - gap - text_h - oy
elif side == "bottom":
lx = px - text_w // 2 + ox
ly = py + r + gap + oy
else: # "right" or default
lx = px + r + gap + ox
ly = py - text_h // 2 + oy
# Draw Sensor ID (first line)
painter.setFont(id_font)
painter.setPen(QPen(self._text_color))
y1 = ly + id_ascent
if side in ("top", "bottom"):
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
elif side == "left":
painter.drawText(lx + (text_w - id_w), y1, id_text)
else:
painter.drawText(lx, y1, id_text)
# Draw Temperature (second line)
if has_temp:
painter.setFont(temp_font)
painter.setPen(QPen(color))
y2 = ly + id_line_h + temp_ascent
if side in ("top", "bottom"):
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
elif side == "left":
painter.drawText(lx + (text_w - temp_w), y2, temp_text)
else:
painter.drawText(lx, y2, temp_text)
+10
View File
@@ -0,0 +1,10 @@
# ===== Wafer Sub-package =====
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
from pygui.backend.wafer.zwafer_parser import ZWaferParser
__all__ = [
"ZWaferData", "Sensor", "DataRecord",
"ZWaferParser",
"load_layout", "available_families",
]

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