Compare commits

..

9 Commits

20 changed files with 1678 additions and 475 deletions
+11 -40
View File
@@ -22,6 +22,7 @@ Rectangle {
onSelectedTabIndexChanged: {
if (selectedTabIndex === 2) {
file_browser.refreshFiles();
streamController.unloadFile()
}
}
@@ -36,7 +37,9 @@ Rectangle {
function onParsedDataReady(result) {
if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path)
if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2
}
}
}
}
@@ -74,6 +77,11 @@ Rectangle {
}
}
// ===== Split Dialog (Threshold Segmentation) =====
SplitDialog {
id: splitDialog
}
// ===== Settings Popup =====
Popup {
id: settingsPopup
@@ -300,7 +308,7 @@ Rectangle {
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: root.selectedTabIndex
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions ────────────────
ColumnLayout {
@@ -320,7 +328,7 @@ Rectangle {
label: "DETECT WAFER"
iconSource: "../icons/detect.svg"
Layout.fillWidth: true
enabled: !deviceController.isDetecting
enabled: !deviceController.operationInProgress
onClicked: root._doDetect()
}
@@ -345,44 +353,6 @@ Rectangle {
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
@@ -637,6 +607,7 @@ Rectangle {
active: StackLayout.index === root.selectedTabIndex
}
Loader {
id: dataTabLoader
source: "Tabs/DataTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
File diff suppressed because it is too large Load Diff
-222
View File
@@ -1,222 +0,0 @@
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();
}
}
}
+29 -25
View File
@@ -205,33 +205,37 @@ Item {
font.letterSpacing: 1.2
}
}
// Record start/stop toggle (Live mode)
Button {
visible: streamController.mode === "live"
enabled: streamController.state !== "idle" || streamController.recording
text: streamController.recording ? "Stop REC" : "Record"
onClicked: {
if (streamController.recording) {
streamController.stopRecording();
} else {
var info = deviceController.lastWaferInfo;
var serial = (info && info.length > 1) ? info[1] : "";
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
}
}
}
// 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
// }
// }
// Stream statistics popup (Live mode)
Button {
visible: streamController.mode === "live"
text: "Stats"
onClicked: streamStatsDialog.open()
}
StreamStatsDialog {
id: streamStatsDialog
elapsedSecs: root._liveSecs
}
// 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()
+10 -3
View File
@@ -188,9 +188,16 @@ ColumnLayout {
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
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
readonly property bool isActive:{
try {
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
}
} catch (e) {}
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
}
visible: matchesFilter
height: matchesFilter ? implicitHeight : 0
@@ -0,0 +1,315 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Dialogs
import ISC
// ===== Split Dialog (Threshold Segmentation) =====
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
// Backend: splitter.segment_profile() returns list of DataSegment objects.
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: Math.min(parent.width - 100, 800)
height: Math.min(parent.height - 100, 600)
anchors.centerIn: Overlay.overlay
property real threshold: 40.0
property bool segmenting: false
property var segments: []
property string currentFile: streamController.loadedFile || ""
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 16
// ── Header ────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "SPLIT DATA"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "\u2715"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// ── Threshold Input ───────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "THRESHOLD TEMPERATURE (°C)"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
RowLayout {
Layout.fillWidth: true
spacing: 8
SpinBox {
id: thresholdSpin
from: 0
to: 200
value: root.threshold
stepSize: 1
editable: true
Layout.fillWidth: true
background: Rectangle {
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
}
Label {
text: "°C"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
verticalAlignment: Text.AlignVCenter
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: "SESSION CSV"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
Label {
Layout.fillWidth: true
text: root.currentFile !== ""
? root.currentFile.split("/").pop()
: "No file loaded"
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle
}
}
}
// ── Split Button ──────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Button {
id: splitBtn
text: "SPLIT"
Layout.fillWidth: true
enabled: !root.segmenting && root.currentFile !== ""
onClicked: {
root.threshold = thresholdSpin.value;
root.segmenting = true;
streamController.splitData(root.currentFile, root.threshold);
}
background: Rectangle {
radius: Theme.radiusSm
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
border.width: 1
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.0
}
BusyIndicator {
running: root.segmenting
visible: root.segmenting
width: 16
height: 16
}
}
}
}
// ── Segments Table ────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.radiusSm
color: Theme.panelBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 0
spacing: 0
// Table header
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
}
}
// Table body (scrollable)
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ColumnLayout {
width: parent.width - 32
spacing: 0
Repeater {
model: root.segments.length > 0 ? root.segments : []
delegate: Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 40
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
border.color: Theme.cardBorder
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Label {
text: modelData.label || ""
Layout.preferredWidth: 100
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
font.bold: true
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.start_frame || 0).toString()
Layout.preferredWidth: 120
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.end_frame || 0).toString()
Layout.preferredWidth: 120
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
Layout.fillWidth: true
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
}
}
}
// Empty state
Label {
visible: root.segments.length === 0
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
Layout.fillWidth: true
Layout.preferredHeight: 100
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// ── Summary line ──────────────────────────────────────────
Label {
visible: root.segments.length > 0
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
}
// ── Backend Connection ────────────────────────────────────────
Connections {
target: streamController
function onSplitResult(result) {
root.segmenting = false;
if (result && result.success) {
root.segments = result.segments || [];
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
} else {
root.segments = [];
console.log("[SplitDialog] Split failed:", result.error || "unknown");
}
}
}
}
@@ -0,0 +1,128 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import ISC
// ===== Stream Stats Dialog =====
// Popup showing live stream statistics: received frames, errors,
// resync count, and elapsed time. Opened from the Live toolbar.
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 320
implicitHeight: content.implicitHeight + 40
anchors.centerIn: Overlay.overlay
// Elapsed seconds supplied by the Live timer in WaferMapTab.
property int elapsedSecs: 0
function fmtTime(s) {
var m = Math.floor(s / 60);
var ss = s % 60;
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
}
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
component StatRow: RowLayout {
property string label
property string value
Layout.fillWidth: true
spacing: 8
Label {
text: label
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
Layout.fillWidth: true
}
Label {
text: value
color: Theme.headingColor
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
}
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 20
spacing: 16
// ── Header ────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "STREAM DATA"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "✕"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// ── Stats box ─────────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
implicitHeight: statRows.implicitHeight + 20
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
id: statRows
anchors.fill: parent
anchors.margins: 10
spacing: 8
StatRow {
label: "Received frames"
value: streamController.receivedCount
}
StatRow {
label: "Errors"
value: streamController.errorCount
}
StatRow {
label: "Resyncs"
value: streamController.resyncCount
}
StatRow {
label: "Elapsed"
value: root.fmtTime(root.elapsedSecs)
}
}
}
}
}
+2
View File
@@ -6,3 +6,5 @@ TransportBar 1.0 TransportBar.qml
WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.qml
SplitDialog 1.0 SplitDialog.qml
StreamStatsDialog 1.0 StreamStatsDialog.qml
-1
View File
@@ -1,7 +1,6 @@
# ===== 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
+1 -21
View File
@@ -44,9 +44,6 @@ QtObject {
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)
@@ -118,14 +115,8 @@ QtObject {
// ── 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"
// ── 8. Tabs (mode toggle pills) ──────────────────────────────────────────
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
@@ -133,12 +124,8 @@ QtObject {
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
@@ -179,14 +166,7 @@ QtObject {
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
@@ -3,8 +3,12 @@
from __future__ import annotations
import logging
import os
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
@@ -20,7 +24,6 @@ from pygui.serialcomm.data_parser import (
remove_trailing_zeros,
save_to_csv,
)
from pygui.serialcomm.device_service import DeviceService
from pygui.serialcomm.serial_port import WaferInfo
# import pygui.backend.wafer_map_item
@@ -61,6 +64,7 @@ class DeviceController(QObject):
super().__init__(parent)
self._settings = settings
self._data_dir = data_dir
from pygui.serialcomm.device_service import DeviceService
self._service = DeviceService(settings)
# Always start fresh — never restore connection status or logs from a prior session
@@ -68,7 +72,6 @@ class DeviceController(QObject):
self._operation_in_progress = False
self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None
from pathlib import Path
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
self._last_wafer_info: dict[str, Any] = {}
self._last_csv_path: str = ""
@@ -126,6 +129,34 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}")
self._save_status()
@Slot(str)
@slot_error_boundary
def openCsvFile(self, file_path: str) -> None:
"""Open a CSV file in the system default spreadsheet application."""
if not file_path:
self._append_log("No file path provided to openCsvFile")
return
try:
path = Path(file_path)
if not path.exists():
self._append_log(f"File not found: {file_path}")
return
# Platform-appropriate file opener
if sys.platform == "darwin":
subprocess.Popen(["open", str(path)])
elif sys.platform == "linux":
subprocess.Popen(["xdg-open", str(path)])
else: # Windows
os.startfile(str(path))
self._append_log(f"Opened file: {file_path}")
except Exception as exc:
log.warning("Failed to open file %s: %s", file_path, exc)
self._append_log(f"Error opening file: {exc}")
@Property(str, notify=selectedPortChanged)
def selectedPort(self) -> str:
"""Currently selected serial port shared across the UI."""
@@ -517,7 +548,6 @@ class DeviceController(QObject):
return
if not self._save_data_dir:
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()
@@ -40,6 +40,10 @@ class SessionController(QObject):
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
# dicts to QML as an opaque empty wrapper with no accessible fields.
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
splitResult = Signal(dict) # dict with success, segments
# private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping
@@ -226,6 +230,10 @@ class SessionController(QObject):
def errorCount(self) -> int:
return self._error_count
@Property(int, notify=liveStatsChanged)
def resyncCount(self) -> int:
return self._reader.resync_count if self._reader else 0
# ---- mode + thresholds ----
@Slot(str)
@slot_error_boundary
@@ -305,6 +313,176 @@ class SessionController(QObject):
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit()
@Slot()
@slot_error_boundary
def unloadFile(self) -> None:
"""Clear the loaded file, resetting the player and frame states."""
self._player.load([])
self._model.reset()
self._stats_tracker.reset()
self._loaded_file = ""
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
self.settingsChanged.emit()
# ---- comparison: DTW between two CSV files ----
@Slot(str, str)
@slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result."""
from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
try:
if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a)
else:
recs_a = read_data_records(file_a)
if is_official_csv(file_b):
_, recs_b = read_official_csv(file_b)
else:
recs_b = read_data_records(file_b)
if not recs_a or not recs_b:
self.comparisonResult.emit({
"success": False,
"error": "No data in one or both files"
})
return
# Use first 100 frames of each for comparison (avoid O(n²) blowup)
max_frames = min(100, len(recs_a), len(recs_b))
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
for i in range(max_frames)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
for i in range(max_frames)]
result = compare_runs(series_a, series_b)
# Mean temporal shift along the DTW path: positive means run B
# reaches the same profile features later than run A.
path = result["warping_path"]
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
# Max sensor deviation: walk the DTW-aligned frame pairs and take
# the largest per-sensor abs difference across all sensors, not
# just the sensor[0] series used for alignment.
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
max_sensor_deviation = 0.0
for i, j in zip(result["index_a"], result["index_b"]):
if i >= max_frames or j >= max_frames:
continue
values_a = recs_a[i].values
values_b = recs_b[j].values
for s in range(min(num_sensors, len(values_a), len(values_b))):
diff = abs(values_a[s] - values_b[s])
if diff > max_sensor_deviation:
max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile() (file_a is assumed representative
# of both files' wafer type).
from pathlib import Path
from pygui.backend.data.data_records import is_official_csv, read_official_csv
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser
sensor_layout: list[dict] = []
sensor_diff: list[float] = []
wafer_shape = "round"
wafer_size = 300.0
try:
if is_official_csv(file_a):
sensors, _ = read_official_csv(file_a)
stem = Path(file_a).stem
wafer_id = stem.split("-")[0] if "-" in stem else stem
else:
parsed, _ = ZWaferParser().parse(file_a)
sensors = parsed.sensors if parsed else []
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
if sensors:
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
last_a = recs_a[max_frames - 1].values
last_b = recs_b[max_frames - 1].values
n = min(len(sensors), len(last_a), len(last_b))
sensor_layout = [
{
"label": s.label,
"x": s.x,
"y": s.y,
"side": getattr(s, "side", "right"),
"offset_x": getattr(s, "offset_x", 0.0),
"offset_y": getattr(s, "offset_y", 0.0),
}
for s in sensors[:n]
]
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
except Exception as layout_exc:
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
self.comparisonResult.emit({
"success": True,
"distance": result["distance"],
# Lists, not tuples — tuples don't survive QVariant conversion
"warping_path": [list(p) for p in result["warping_path"][:50]],
"frame_offset": frame_offset,
"max_sensor_deviation": max_sensor_deviation,
"series_a": series_a,
"series_b": series_b,
"sensor_layout": sensor_layout,
"sensor_diff": sensor_diff,
"wafer_shape": wafer_shape,
"wafer_size": wafer_size,
})
except Exception as exc:
log.warning("Comparison failed: %s", exc)
self.comparisonResult.emit({
"success": False,
"error": str(exc)
})
# ---- splitting: threshold-based segmentation ----
@Slot(str, float)
@slot_error_boundary
def splitData(self, file_path: str, threshold: float) -> None:
"""Segment temperature profile into Ramp/Soak/Cool phases."""
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
from pygui.backend.splitter import segment_profile
try:
if is_official_csv(file_path):
_, records = read_official_csv(file_path)
else:
records = read_data_records(file_path)
if not records:
self.splitResult.emit({
"success": False,
"error": "No data in file"
})
return
# Extract average temperatures (use first sensor as proxy)
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
segments = segment_profile(avg_temps, threshold)
self.splitResult.emit({
"success": True,
"segments": [{
"label": seg.label,
"start_frame": seg.start_frame,
"end_frame": seg.end_frame,
"avg_temp": seg.avg_temp
} for seg in segments]
})
except Exception as exc:
log.warning("Split failed: %s", exc)
self.splitResult.emit({
"success": False,
"error": str(exc)
})
@Slot()
@slot_error_boundary
def play(self) -> None:
@@ -594,6 +772,4 @@ class SessionController(QObject):
def _flush_trend(self) -> None:
import json
self.trendData.emit(json.dumps(self._trend_buffer))
self.trendData.emit(json.dumps(self._trend_buffer))
+2
View File
@@ -1,4 +1,5 @@
# ===== Wafer Sub-package =====
from pygui.backend.wafer.family_spec import sensor_count_for
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -7,4 +8,5 @@ __all__ = [
"ZWaferData", "Sensor", "DataRecord",
"ZWaferParser",
"load_layout", "available_families",
"sensor_count_for",
]
+9
View File
@@ -0,0 +1,9 @@
"""Single source of truth for the wafer sensor-count partition."""
def sensor_count_for(family_code: str) -> int:
"""Return the number of valid sensor readings for a wafer family code.
Returns 80 for the "X" family. Returns 244 for every other family
code."""
return 80 if family_code == "X" else 244
+19 -21
View File
@@ -12,13 +12,9 @@ import logging
import os
from typing import Optional
log = logging.getLogger(__name__)
from pygui.backend.wafer.family_spec import sensor_count_for
# Max DUTs (sensors) per row before overhead bytes are stripped
# P wafer: 244 valid readings per 256-block (12 overhead bytes)
# X wafer: 80 valid readings per 256-block (14 overhead bytes)
MAXDUT_P = 244
MAXDUT_X = 80
log = logging.getLogger(__name__)
def csv_column_count(family_code: str) -> int:
@@ -156,26 +152,27 @@ def _parse_p_binary(data_bytes: bytes) -> list[list[str]]:
"""Parse P-family (and A/B/C/D/E/F) binary data.
Each block of 256 readings has 244 valid + 12 overhead.
Valid readings are chunked into rows of MAXDUT_P (244).
Valid readings are chunked into rows of the family's sensor count (244).
"""
sensor_count = sensor_count_for("P")
readings: list[str] = []
# Read 2 bytes at a time (UInt16 little-endian)
# Each 256-word block has MAXDUT_P valid readings (first N words), rest are overhead
# Each 256-word block has `sensor_count` valid readings (first N words), rest are overhead
num_words = len(data_bytes) // 2
for i in range(num_words):
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
if i % 256 < MAXDUT_P:
if i % 256 < sensor_count:
readings.append(f"{value:04X}")
# Chunk into rows of MAXDUT_P
# Chunk into rows of sensor_count
result: list[list[str]] = []
idx = 0
while idx + MAXDUT_P <= len(readings):
result.append(readings[idx : idx + MAXDUT_P])
idx += MAXDUT_P
while idx + sensor_count <= len(readings):
result.append(readings[idx : idx + sensor_count])
idx += sensor_count
log.info("Parsed P-family: %d rows × %d cols", len(result), MAXDUT_P)
log.info("Parsed P-family: %d rows × %d cols", len(result), sensor_count)
return result
@@ -183,24 +180,25 @@ def _parse_x_binary(data_bytes: bytes) -> list[list[str]]:
"""Parse X-family binary data.
Each block of 256 readings has 80 valid + 14 overhead.
Valid readings are chunked into rows of MAXDUT_X (80).
Valid readings are chunked into rows of the family's sensor count (80).
"""
sensor_count = sensor_count_for("X")
readings: list[str] = []
num_words = len(data_bytes) // 2
for i in range(num_words):
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
if i % 256 < MAXDUT_X:
if i % 256 < sensor_count:
readings.append(f"{value:04X}")
# Chunk into rows of MAXDUT_X
# Chunk into rows of sensor_count
result: list[list[str]] = []
idx = 0
while idx + MAXDUT_X <= len(readings):
result.append(readings[idx : idx + MAXDUT_X])
idx += MAXDUT_X
while idx + sensor_count <= len(readings):
result.append(readings[idx : idx + sensor_count])
idx += sensor_count
log.info("Parsed X-family: %d rows × %d cols", len(result), MAXDUT_X)
log.info("Parsed X-family: %d rows × %d cols", len(result), sensor_count)
return result
-12
View File
@@ -19,14 +19,6 @@ log = logging.getLogger(__name__)
class DeviceService:
"""High-level wafer device communication."""
# Expected hex string lengths for known wafer families
# P wafer: 393216 hex chars (196608 bytes)
# X wafer: 1310720 hex chars (655360 bytes)
EXPECTED_HEX_LENGTHS = {
"P": 393216,
"X": 1310720,
}
def __init__(self, settings: LocalSettings) -> None:
self._settings = settings
self._cached_ports: list[str] = []
@@ -209,7 +201,3 @@ class DeviceService:
except Exception as exc:
log.error("Erase failed on %s: %s", port, exc)
return False
def get_expected_hex_length(self, family_code: str) -> int:
"""Return expected hex string length for a wafer family."""
return self._settings.get_wafer_data_size(family_code)
+5 -1
View File
@@ -7,6 +7,7 @@ import time
from typing import Callable
from pygui.backend.models.frame import Frame
from pygui.backend.wafer.family_spec import sensor_count_for
from pygui.serialcomm.data_parser import _convert_hex_to_temp
log = logging.getLogger(__name__)
@@ -30,6 +31,8 @@ class StreamReader:
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self.error_count = 0
# Cumulative count of resync events (lost sync marker) for stream stats.
self.resync_count = 0
def start(self) -> None:
self._stop.clear()
@@ -158,6 +161,7 @@ class StreamReader:
else:
# Resync: keep only trailing 0xAA if present, then read more.
resync_attempts += 1
self.resync_count += 1
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
log.error(
"StreamReader: giving up after %d resync attempts "
@@ -179,7 +183,7 @@ class StreamReader:
def _run_ascii(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting ASCII hex dump stream parsing")
valid_words = 80 if self._family_code == "X" else 244
valid_words = sensor_count_for(self._family_code)
block_words = 256
word_char_len = 4
chars_needed = block_words * word_char_len # total hex chars per block
+5 -7
View File
@@ -8,8 +8,6 @@ from pygui.serialcomm.data_parser import (
remove_trailing_zeros,
save_to_csv,
_convert_hex_to_temp,
MAXDUT_P,
MAXDUT_X,
)
# ── csv_column_count ──────────────────────────────────────────────────────────
@@ -106,7 +104,7 @@ def _make_p_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
data = bytearray()
for _ in range(num_blocks):
for i in range(256):
word = value if i < MAXDUT_P else 0
word = value if i < 244 else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
@@ -120,7 +118,7 @@ def _make_x_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
data = bytearray()
for _ in range(num_blocks):
for i in range(256):
word = value if i < MAXDUT_X else 0
word = value if i < 80 else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
@@ -136,7 +134,7 @@ class TestParseBinaryData:
data = _make_p_block(1)
result = parse_binary_data(data, "P")
assert result is not None
assert len(result[0]) == MAXDUT_P # 244
assert len(result[0]) == 244
def test_p_family_two_blocks_returns_two_rows(self):
data = _make_p_block(2)
@@ -161,14 +159,14 @@ class TestParseBinaryData:
data = _make_x_block(1)
result = parse_binary_data(data, "X")
assert result is not None
assert len(result[0]) == MAXDUT_X # 80
assert len(result[0]) == 80
def test_a_family_uses_p_parser(self):
# A/B/C/D/E/F all route to _parse_p_binary
data = _make_p_block(1)
result = parse_binary_data(data, "A")
assert result is not None
assert len(result[0]) == MAXDUT_P
assert len(result[0]) == 244
def test_empty_bytes_returns_empty_list(self):
result = parse_binary_data(b"", "P")
+23
View File
@@ -0,0 +1,23 @@
"""Tests for the shared wafer family sensor-count lookup."""
from pygui.backend.wafer.family_spec import sensor_count_for
def test_x_family_returns_80():
assert sensor_count_for("X") == 80
def test_p_family_returns_244():
assert sensor_count_for("P") == 244
def test_other_known_families_return_244():
for code in ("A", "B", "C", "D", "E", "F"):
assert sensor_count_for(code) == 244
def test_unknown_family_returns_244():
assert sensor_count_for("Z") == 244
def test_empty_string_returns_244():
assert sensor_count_for("") == 244
+87
View File
@@ -46,3 +46,90 @@ def test_pause_stop_timer(controller):
controller.seek(2)
controller.stop()
assert controller.frameIndex == 0
def test_compare_files_integration(controller):
# Mock the comparisonResult signal
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
# Use sample_stream.csv for both arguments to test identical files compare
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
# Verify that the comparison signal is emitted with a success dict
assert mock_slot.call_count == 1
args = mock_slot.call_args[0][0]
assert args["success"] is True
assert "distance" in args
assert "series_a" in args
assert "series_b" in args
assert args["frame_offset"] == 0
def test_comparison_result_reaches_qml(qapp, controller):
"""The result dict must arrive in QML as a real JS object with fields.
Signal(object) delivers Python dicts to QML as an opaque empty wrapper;
the signal must be declared with a dict/QVariantMap signature.
"""
from PySide6.QtQml import QQmlApplicationEngine
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("streamController", controller)
engine.loadData(b"""
import QtQuick
Item {
id: probe
property bool gotSuccess: false
property real gotDistance: -1
Connections {
target: streamController
function onComparisonResult(result) {
probe.gotSuccess = result.success === true
probe.gotDistance = result.distance !== undefined ? result.distance : -1
}
}
}
""")
assert engine.rootObjects(), "probe QML failed to load"
root = engine.rootObjects()[0]
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
qapp.processEvents()
assert root.property("gotSuccess") is True
assert root.property("gotDistance") >= 0
def test_recording_toggle(controller, tmp_path):
csv_path = str(tmp_path / "rec" / "live_test.csv")
controller.startRecording(csv_path, "SN123")
assert controller.recording == True
controller.stopRecording()
assert controller.recording == False
# Header written with wafer serial
content = open(csv_path).read()
assert "Wafer ID=SN123" in content
def test_resync_count_default(controller):
# No active reader -> zero
assert controller.resyncCount == 0
controller._reader = MagicMock(resync_count=7)
assert controller.resyncCount == 7
controller._reader = None
def test_split_data_integration(controller):
# Mock the splitResult signal
mock_slot = MagicMock()
controller.splitResult.connect(mock_slot)
csv_path = "tests/fixtures/sample_stream.csv"
controller.splitData(csv_path, 149.0)
# Verify that the split signal is emitted
assert mock_slot.call_count == 1
args = mock_slot.call_args[0][0]
assert args["success"] is True
assert "segments" in args