Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 915720085b | |||
| e2d71662ff | |||
| 52cc747536 | |||
| 185e66ea3a | |||
| 3b919f576f | |||
| f604308466 | |||
| 0f813b2376 | |||
| 6996df73f8 | |||
| 3cc10ea7fe |
+11
-40
@@ -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
|
||||
}
|
||||
|
||||
+820
-116
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,32 +206,36 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// }
|
||||
// }
|
||||
// Record start/stop toggle (Live mode)
|
||||
Button {
|
||||
visible: streamController.mode === "live"
|
||||
enabled: streamController.state !== "idle" || streamController.recording
|
||||
text: streamController.recording ? "Stop REC" : "Record"
|
||||
onClicked: {
|
||||
if (streamController.recording) {
|
||||
streamController.stopRecording();
|
||||
} else {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
var serial = (info && info.length > 1) ? info[1] : "";
|
||||
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream statistics popup (Live mode)
|
||||
Button {
|
||||
visible: streamController.mode === "live"
|
||||
text: "Stats"
|
||||
onClicked: streamStatsDialog.open()
|
||||
}
|
||||
|
||||
StreamStatsDialog {
|
||||
id: streamStatsDialog
|
||||
elapsedSecs: root._liveSecs
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -188,8 +188,15 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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:
|
||||
@@ -595,5 +773,3 @@ class SessionController(QObject):
|
||||
def _flush_trend(self) -> None:
|
||||
import json
|
||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user