Compare commits

..

9 Commits

20 changed files with 1678 additions and 475 deletions
+11 -40
View File
@@ -22,6 +22,7 @@ Rectangle {
onSelectedTabIndexChanged: { onSelectedTabIndexChanged: {
if (selectedTabIndex === 2) { if (selectedTabIndex === 2) {
file_browser.refreshFiles(); file_browser.refreshFiles();
streamController.unloadFile()
} }
} }
@@ -36,10 +37,12 @@ Rectangle {
function onParsedDataReady(result) { function onParsedDataReady(result) {
if (result.success && result.csv_path) { if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path) streamController.loadFile(result.csv_path)
if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2 root.selectedTabIndex = 2
} }
} }
} }
}
Connections { Connections {
target: deviceController target: deviceController
function onReadResult(result) { function onReadResult(result) {
@@ -74,6 +77,11 @@ Rectangle {
} }
} }
// ===== Split Dialog (Threshold Segmentation) =====
SplitDialog {
id: splitDialog
}
// ===== Settings Popup ===== // ===== Settings Popup =====
Popup { Popup {
id: settingsPopup id: settingsPopup
@@ -300,7 +308,7 @@ Rectangle {
StackLayout { StackLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
currentIndex: root.selectedTabIndex currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions ──────────────── // ── Status tab: Hardware Actions ────────────────
ColumnLayout { ColumnLayout {
@@ -320,7 +328,7 @@ Rectangle {
label: "DETECT WAFER" label: "DETECT WAFER"
iconSource: "../icons/detect.svg" iconSource: "../icons/detect.svg"
Layout.fillWidth: true Layout.fillWidth: true
enabled: !deviceController.isDetecting enabled: !deviceController.operationInProgress
onClicked: root._doDetect() onClicked: root._doDetect()
} }
@@ -345,44 +353,6 @@ Rectangle {
Item { Layout.fillHeight: true } 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 ────────────── // ── Map tab: Source file browser ──────────────
SourcePanel { SourcePanel {
Layout.fillWidth: true Layout.fillWidth: true
@@ -637,6 +607,7 @@ Rectangle {
active: StackLayout.index === root.selectedTabIndex active: StackLayout.index === root.selectedTabIndex
} }
Loader { Loader {
id: dataTabLoader
source: "Tabs/DataTab.qml" source: "Tabs/DataTab.qml"
active: StackLayout.index === root.selectedTabIndex active: StackLayout.index === root.selectedTabIndex
} }
+809 -105
View File
@@ -2,186 +2,890 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Wafer
// ===== Data Tab ===== // ===== Data Tab (Compare Content Only) =====
// Displays parsed temperature data in a scrollable table. // Per docs/design/navigation-mock.html: the Data tab shows only the Compare
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ... // Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
// is intentionally not displayed in the application.
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
// overlap map; right side panel = run selection dropdowns, overlap settings,
// and DTW readout cards.
Item { Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
readonly property int rowCount: deviceController.dataRowCount // Properties for DTW Comparison
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
property string compareFileA: ""
property string compareFileB: ""
property real warpingDistance: -1
property int frameOffset: 0
property real maxSensorDeviation: -1
property bool comparing: false
property var trendDataA: []
property var trendDataB: []
property string errorMessage: ""
// Re-layout columns whenever the model is reset // Wafer overlap properties
property var compareSensorLayout: []
property var compareSensorDiff: []
property string compareWaferShape: "round"
property real compareWaferSize: 300.0
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
function diffBands(diffs) {
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
}
function shortName(path) {
return path ? path.split("/").pop() : ""
}
function clearResults() {
root.warpingDistance = -1
root.frameOffset = 0
root.maxSensorDeviation = -1
root.trendDataA = []
root.trendDataB = []
root.compareSensorLayout = []
root.compareSensorDiff = []
root.errorMessage = ""
}
function resetComparison() {
root.compareFileA = ""
root.compareFileB = ""
root.comparing = false
root.activeBox = 1
clearResults()
}
Component.onCompleted: file_browser.refreshFiles()
// Clicking a file in the left rail's browser populates the armed run slot
Connections { Connections {
target: deviceController target: streamController
function onParsedDataReady(result) { function onLoadedFileChanged() {
if (streamController.loadedFile && root.activeBox > 0) {
if (root.activeBox === 1) {
root.compareFileA = streamController.loadedFile
root.activeBox = 2 // Auto-advance to box 2
} else if (root.activeBox === 2) {
root.compareFileB = streamController.loadedFile
root.activeBox = 0 // Finished selecting
}
}
}
function onComparisonResult(result) {
root.comparing = false
if (result && result.success) { if (result && result.success) {
dataTable.forceLayout(); root.warpingDistance = result.distance
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
root.trendDataA = result.series_a || []
root.trendDataB = result.series_b || []
root.compareSensorLayout = result.sensor_layout || []
root.compareSensorDiff = result.sensor_diff || []
root.compareWaferShape = result.wafer_shape || "round"
root.compareWaferSize = result.wafer_size || 300.0
root.errorMessage = ""
scrubber.value = Math.floor(root.seriesLen / 2)
} else {
root.clearResults()
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
} }
} }
} }
// ===== Empty State ===== // ── Reusable pieces ────────────────────────────────────────────
Column { component SectionTitle: Label {
anchors.centerIn: parent font.pixelSize: Theme.fontXs
spacing: 16
visible: root.rowCount === 0
Label {
text: "No Data"
font.pixelSize: Theme.font2xl
font.bold: true font.bold: true
color: Theme.headingColor font.letterSpacing: 1.2
anchors.horizontalCenter: parent.horizontalCenter color: Theme.sideMutedText
} }
component PanelBox: Rectangle {
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
}
// Mini stat box under the alignment scrubber (mockup's subtle-box trio)
component ScrubStat: Rectangle {
id: scrubStat
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 38
radius: 4
color: Qt.rgba(1, 1, 1, 0.02)
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 0
Label { Label {
text: "Read and parse wafer data to see temperature readings here." text: scrubStat.label
font.pixelSize: Theme.fontMd font.pixelSize: 9
color: Theme.bodyColor color: Theme.sideMutedText
anchors.horizontalCenter: parent.horizontalCenter }
wrapMode: Text.WordWrap Label {
text: scrubStat.value
font.pixelSize: Theme.fontSm
font.bold: true
font.family: Theme.codeFontFamily
color: scrubStat.valueColor
}
} }
} }
// ===== Data Table ===== // Large labeled value card for the DTW readout panel
component ReadoutCard: Rectangle {
id: readoutCard
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: readoutCard.label
font.pixelSize: 9
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: readoutCard.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: readoutCard.valueColor
}
}
}
// Run slot card: click to arm, then pick a file in the left rail's browser
component RunSlot: Rectangle {
id: slotBox
property int boxIndex: 1
property string title
property string filePath: ""
property color accent: Theme.primaryAccent
signal cleared()
Layout.fillWidth: true
implicitHeight: 58
radius: Theme.radiusSm
color: Theme.fieldBackground
border.width: root.activeBox === boxIndex ? 2 : 1
border.color: root.activeBox === boxIndex ? accent : Theme.cardBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeBox = slotBox.boxIndex
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Rectangle {
width: 8; height: 8; radius: 4
color: slotBox.accent
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: slotBox.title
font.pixelSize: 9
font.bold: true
font.letterSpacing: 1.0
color: root.activeBox === slotBox.boxIndex ? slotBox.accent : Theme.sideMutedText
}
Label {
text: slotBox.filePath !== ""
? root.shortName(slotBox.filePath)
: (root.activeBox === slotBox.boxIndex ? "← Select file from left panel…" : "Click to select")
font.pixelSize: Theme.fontXs
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
Button {
visible: slotBox.filePath !== ""
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: slotBox.cleared()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
}
contentItem: Label {
text: "✕"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
// ── Main Layout ───────────────────────────────────────────────
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap spacing: Theme.rightPaneGap
visible: root.rowCount > 0
// --- Header row --- // ── Title / Header ─────────────────────────────────────────
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.rightPaneGap spacing: 8
Label { Label {
text: "Temperature Data" text: "Run Comparison (DTW)"
font.pixelSize: Theme.fontXl font.pixelSize: Theme.fontXl
font.bold: true font.bold: true
color: Theme.headingColor color: Theme.headingColor
Layout.fillWidth: true
elide: Text.ElideRight
} }
Item { Button {
Layout.fillWidth: true id: resetBtn
flat: true
implicitWidth: 32
implicitHeight: 32
visible: root.compareFileA !== "" || root.compareFileB !== ""
onClicked: root.resetComparison()
background: Rectangle {
radius: Theme.radiusSm
color: resetBtn.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
}
contentItem: Label {
text: "↺"
font.pixelSize: Theme.fontLg
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
ToolTip.visible: resetBtn.hovered
ToolTip.text: "Reset comparison"
} }
Label { Label {
text: deviceController.dataRowCount + " rows × " + deviceController.dataColCount + " sensors" text: "Dynamic Time Warping Profile Matching"
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
color: Theme.bodyColor color: Theme.bodyColor
} }
} }
// --- Toolbar --- // ── Error Banner ───────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
text: "Open in Excel"
icon.source: "icons/excel.svg"
icon.color: Theme.headingColor
onClicked: deviceController.openCsvFile()
}
Button {
text: "Compare"
onClicked: compareDialog.open()
}
Button {
text: "Split"
onClicked: splitDialog.open()
}
Item {
Layout.fillWidth: true
}
}
// --- Table container ---
Rectangle { Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorLabel.implicitHeight + 16
visible: root.errorMessage !== ""
color: Qt.rgba(0.93, 0.27, 0.27, 0.15)
border.color: "#ef4444"
border.width: 1
radius: Theme.radiusSm
Label {
id: errorLabel
anchors.fill: parent
anchors.margins: 8
text: "⚠ " + root.errorMessage
color: "#fca5a5"
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
}
}
// ── Body: main column | side panel ─────────────────────────
RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.panelBackground spacing: 16
// --- Left Column: chart + scrubber, wafer overlap map ---
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumWidth: 350
spacing: 12
// Chart card with alignment scrubber
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: chartCol.implicitHeight + 24
ColumnLayout {
id: chartCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "ALIGNED TEMP CURVE OVERLAY (DTW)"
Layout.fillWidth: true
}
Row {
spacing: 16
visible: root.hasSeries
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 200
color: Theme.fieldBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: Theme.borderThin border.width: 1
radius: Theme.radiusSm radius: Theme.radiusSm
clip: true clip: true
ColumnLayout { Canvas {
id: overlayCanvas
anchors.fill: parent anchors.fill: parent
anchors.margins: 1 visible: root.hasSeries
spacing: 0
// Column header strip readonly property real padLeft: 50
HorizontalHeaderView { readonly property real padRight: 16
id: headerView readonly property real padTop: 16
Layout.fillWidth: true readonly property real padBottom: 28
syncView: dataTable
clip: true
delegate: Rectangle { function drawGrid(ctx, minV, maxV, dataLen) {
implicitHeight: 28 var plotW = width - padLeft - padRight
color: Theme.cardBackground var plotH = height - padTop - padBottom
border.color: Theme.cardBorder var range = (maxV - minV) || 1
border.width: 1
Text { ctx.strokeStyle = Qt.rgba(1, 1, 1, 0.08)
ctx.lineWidth = 1
ctx.fillStyle = Qt.rgba(1, 1, 1, 0.35)
ctx.font = "10px sans-serif"
ctx.textAlign = "right"
var ySteps = 5
for (var yi = 0; yi <= ySteps; yi++) {
var yFrac = yi / ySteps
var yPx = padTop + plotH * yFrac
var yVal = maxV - yFrac * range
ctx.beginPath()
ctx.moveTo(padLeft, yPx)
ctx.lineTo(padLeft + plotW, yPx)
ctx.stroke()
ctx.fillText(yVal.toFixed(1), padLeft - 6, yPx + 3)
}
ctx.textAlign = "center"
var xSteps = Math.min(5, dataLen - 1)
for (var xi = 0; xi <= xSteps; xi++) {
var xFrac = xi / xSteps
var xPx = padLeft + plotW * xFrac
var xIdx = Math.round(xFrac * (dataLen - 1))
ctx.fillText(xIdx.toString(), xPx, height - 6)
}
ctx.save()
ctx.translate(12, padTop + plotH / 2)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = "center"
ctx.fillText("°C", 0, 0)
ctx.restore()
ctx.textAlign = "center"
ctx.fillText("Frame", padLeft + plotW / 2, height - 2)
}
function drawSeries(ctx, series, minV, range, color) {
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
for (var i = 0; i < series.length; i++) {
var x = padLeft + (i / (series.length - 1)) * plotW
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
function drawScrubMarker(ctx) {
if (root.seriesLen < 2) return
var plotW = width - padLeft - padRight
var x = padLeft + (scrubber.value / (root.seriesLen - 1)) * plotW
ctx.strokeStyle = Theme.primaryAccent
ctx.lineWidth = 1.5
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(x, padTop)
ctx.lineTo(x, height - padBottom)
ctx.stroke()
ctx.setLineDash([])
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
if (!root.hasSeries) return
var all = root.trendDataA.concat(root.trendDataB)
var minV = Math.min.apply(null, all)
var maxV = Math.max.apply(null, all)
var padding = (maxV - minV) * 0.05
minV -= padding
maxV += padding
var range = (maxV - minV) || 1
drawGrid(ctx, minV, maxV, root.seriesLen)
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent)
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill)
drawScrubMarker(ctx)
}
Connections {
target: root
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
}
Connections {
target: scrubber
function onValueChanged() { overlayCanvas.requestPaint() }
}
}
// Empty state
ColumnLayout {
anchors.centerIn: parent anchors.centerIn: parent
// 'display' is the Qt.DisplayRole value from headerData() visible: !root.hasSeries
text: display !== undefined ? display : "" spacing: 8
color: Theme.headingColor
Label {
Layout.alignment: Qt.AlignHCenter
text: root.comparing ? "" : "📊"
font.pixelSize: 32
}
Label {
Layout.alignment: Qt.AlignHCenter
text: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: !root.comparing
text: "Click a run box on the right, then select a file from the left panel."
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
}
}
}
// ── Alignment Timeline Scrubber ────────────
ColumnLayout {
Layout.fillWidth: true
spacing: 4
visible: root.hasSeries
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "ALIGNMENT TIMELINE SCRUBBER"
Layout.fillWidth: true
}
Label {
text: "Frame " + Math.round(scrubber.value) + " / " + Math.max(0, root.seriesLen - 1)
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
font.bold: true font.bold: true
elide: Text.ElideRight font.family: Theme.codeFontFamily
color: Theme.headingColor
}
}
Slider {
id: scrubber
Layout.fillWidth: true
from: 0
to: Math.max(1, root.seriesLen - 1)
stepSize: 1
value: 0
}
RowLayout {
id: scrubReadout
Layout.fillWidth: true
spacing: 8
readonly property int scrubIdx: Math.round(scrubber.value)
readonly property real tempA: root.trendDataA.length > 0
? root.trendDataA[Math.min(scrubIdx, root.trendDataA.length - 1)] : 0
readonly property real tempB: root.trendDataB.length > 0
? root.trendDataB[Math.min(scrubIdx, root.trendDataB.length - 1)] : 0
ScrubStat {
label: "Temp (Run A)"
value: scrubReadout.tempA.toFixed(1) + "°C"
valueColor: Theme.primaryAccent
}
ScrubStat {
label: "Temp (Run B)"
value: scrubReadout.tempB.toFixed(1) + "°C"
valueColor: Theme.themeSkill
}
ScrubStat {
label: "Difference"
value: Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C"
}
}
} }
} }
} }
// Data rows // Wafer Map Overlap View card
TableView { PanelBox {
id: dataTable
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
model: deviceController.dataModel Layout.minimumHeight: 220
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "WAFER MAP OVERLAP VIEW"
Layout.fillWidth: true
}
Label {
visible: root.compareFileA !== "" && root.compareFileB !== ""
text: (root.shortName(root.compareFileA).replace(".csv", "") + " vs "
+ root.shortName(root.compareFileB).replace(".csv", "")).toUpperCase()
font.pixelSize: Theme.fontXs
color: Theme.sideMutedText
elide: Text.ElideMiddle
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true clip: true
reuseItems: true
columnWidthProvider: function (col) { WaferMapItem {
if (col === 0) id: overlapMapItem
return 52; // row index column anchors.fill: parent
const sensorCols = Math.max(1, dataTable.columns - 1); visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked
const available = dataTable.width - 52; sensors: root.compareSensorLayout
return Math.max(52, Math.min(90, available / sensorCols)); values: root.compareSensorDiff
bands: root.diffBands(root.compareSensorDiff)
shape: root.compareWaferShape
size: root.compareWaferSize
target: 0
margin: 1.0
blend: blendSlider.value / 100
showLabels: true
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
} }
rowHeightProvider: function () { ColumnLayout {
return 24;
}
delegate: Rectangle {
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
Text {
anchors.centerIn: parent anchors.centerIn: parent
text: display !== undefined ? display : "" visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
spacing: 4
Label {
Layout.alignment: Qt.AlignHCenter
text: "🗺"
font.pixelSize: 24
}
Label {
Layout.alignment: Qt.AlignHCenter
text: !enableOverlapCheck.checked ? "Overlap map disabled"
: "No sensor map available"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
elide: Text.ElideRight }
}
}
}
} }
} }
ScrollBar.horizontal: ScrollBar { // --- Right Column: run selection, overlap settings, readout ---
policy: ScrollBar.AsNeeded ColumnLayout {
Layout.fillWidth: false
Layout.preferredWidth: 280
Layout.minimumWidth: 280
Layout.maximumWidth: 280
Layout.fillHeight: true
spacing: 12
// Bento 1: Run Selection
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: runSelCol.implicitHeight + 24
ColumnLayout {
id: runSelCol
anchors.fill: parent
anchors.margins: 12
spacing: 10
SectionTitle { text: "RUN SELECTION" }
RunSlot {
title: "REFERENCE (RUN A)"
boxIndex: 1
accent: Theme.primaryAccent
filePath: root.compareFileA
onCleared: {
root.compareFileA = ""
root.activeBox = 1
root.clearResults()
} }
ScrollBar.vertical: ScrollBar { }
policy: ScrollBar.AsNeeded
RunSlot {
title: "SESSION (RUN B)"
boxIndex: 2
accent: Theme.themeSkill
filePath: root.compareFileB
onCleared: {
root.compareFileB = ""
root.activeBox = 2
root.clearResults()
}
}
Button {
id: compareBtn
Layout.fillWidth: true
Layout.preferredHeight: 32
enabled: root.compareFileA !== "" && root.compareFileB !== "" && !root.comparing
onClicked: {
root.comparing = true
root.clearResults()
streamController.compareFiles(root.compareFileA, root.compareFileB)
}
background: Rectangle {
radius: Theme.radiusSm
color: compareBtn.enabled
? (compareBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
: Theme.buttonNeutralBackground
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
anchors.verticalCenter: parent.verticalCenter
text: compareBtn.enabled
? (root.comparing ? "Comparing…" : "Run DTW Comparison")
: (root.comparing ? "Comparing…" : "Select both runs")
color: compareBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.bold: true
}
BusyIndicator {
running: root.comparing
visible: root.comparing
width: 14; height: 14
anchors.verticalCenter: parent.verticalCenter
} }
} }
} }
} }
} }
// Bento 2: Overlap Map Settings
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: overlapCol.implicitHeight + 24
ColumnLayout {
id: overlapCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
SectionTitle { text: "OVERLAP MAP SETTINGS" }
CheckBox {
id: enableOverlapCheck
Layout.fillWidth: true
checked: true
text: "Enable Overlap Map"
font.pixelSize: Theme.fontXs
contentItem: Label {
leftPadding: enableOverlapCheck.indicator.width + 6
text: enableOverlapCheck.text
font.pixelSize: Theme.fontXs
color: Theme.headingColor
verticalAlignment: Text.AlignVCenter
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 6
visible: enableOverlapCheck.checked
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Blend"
font.pixelSize: Theme.fontXs
color: Theme.bodyColor
Layout.preferredWidth: 40
}
Slider {
id: blendSlider
Layout.fillWidth: true
from: 0; to: 100; value: 50
stepSize: 1
}
Label {
text: Math.round(blendSlider.value) + "%"
font.pixelSize: Theme.fontXs
color: Theme.sideMutedText
Layout.preferredWidth: 30
}
}
// Diff color legend
Row {
Layout.fillWidth: true
spacing: 10
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
Label { text: "B cooler"; font.pixelSize: 9; color: Theme.sideMutedText }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Match"; font.pixelSize: 9; color: Theme.sideMutedText }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
Label { text: "B hotter"; font.pixelSize: 9; color: Theme.sideMutedText }
}
}
}
}
}
// Bento 3: DTW Comparison Readout
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: readoutCol.implicitHeight + 24
ColumnLayout {
id: readoutCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
SectionTitle { text: "DTW COMPARISON READOUT" }
ReadoutCard {
label: "DTW ALIGNMENT DISTANCE"
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
valueColor: root.warpingDistance >= 0
? (root.warpingDistance < 50 ? "#6ee7b7" : root.warpingDistance < 100 ? "#fde047" : "#ef4444")
: Theme.sideMutedText
}
ReadoutCard {
label: "TEMPORAL FRAME OFFSET"
value: root.warpingDistance >= 0
? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames")
: "--"
valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
}
ReadoutCard {
label: "MAX SENSOR DEVIATION"
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
valueColor: root.maxSensorDeviation >= 0
? (root.maxSensorDeviation < 1.0 ? "#6ee7b7" : root.maxSensorDeviation < 3.0 ? "#fde047" : "#ef4444")
: Theme.sideMutedText
}
}
}
Item { Layout.fillHeight: true }
}
}
}
} }
-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
@@ -206,32 +206,36 @@ Item {
} }
} }
// LIVE timer // Record start/stop toggle (Live mode)
// RowLayout { Button {
// spacing: 5 visible: streamController.mode === "live"
// visible: streamController.mode === "live" && streamController.state !== "idle" enabled: streamController.state !== "idle" || streamController.recording
// Rectangle { text: streamController.recording ? "Stop REC" : "Record"
// width: 7; height: 7; radius: 4 onClicked: {
// color: Theme.liveColor if (streamController.recording) {
// } streamController.stopRecording();
// Label { } else {
// text: "LIVE " + root.fmtTime(root._liveSecs) var info = deviceController.lastWaferInfo;
// color: Theme.liveColor var serial = (info && info.length > 1) ? info[1] : "";
// font.pixelSize: Theme.fontXs var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
// font.weight: Font.Medium var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
// font.letterSpacing: 0.8 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 { Button {
text: "Export PNG" text: "Export PNG"
onClicked: exportDialog.open() onClicked: exportDialog.open()
@@ -188,8 +188,15 @@ ColumnLayout {
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
} }
// Active only in review mode; no highlight during live streaming // Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
readonly property bool isActive: streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile 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 visible: matchesFilter
height: matchesFilter ? implicitHeight : 0 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 WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.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 ===== # ===== ISC Tabs Module =====
module ISC.Tabs module ISC.Tabs
GraphTab 1.0 GraphTab.qml
WaferMapTab 1.0 WaferMapTab.qml WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml SettingsTab 1.0 SettingsTab.qml
+1 -21
View File
@@ -44,9 +44,6 @@ QtObject {
readonly property int fontWeightMedium: 500 readonly property int fontWeightMedium: 500
readonly property int fontWeightBold: 700 readonly property int fontWeightBold: 700
// Legacy shim
readonly property int tabFontSize: fontSm
// ── 3. Motion ────────────────────────────────────────────────────────── // ── 3. Motion ──────────────────────────────────────────────────────────
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash) readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
readonly property int durationBase: 180 // standard transitions (pill switch, row select) readonly property int durationBase: 180 // standard transitions (pill switch, row select)
@@ -118,14 +115,8 @@ QtObject {
// ── 7. Tracks (pill toggle, scrollbar thumb) ───────────────────────────── // ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE" readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
// ── 8. Tabs (footer tab bar) ───────────────────────────────────────────── // ── 8. Tabs (mode toggle pills) ──────────────────────────────────────────
readonly property color tabBarBackground: tone200
readonly property color tabBackground: "transparent"
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF" 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 ───────────────────────────────────────────────────────── // ── 9. Side rail ─────────────────────────────────────────────────────────
readonly property color sideRailBackground: tone150 readonly property color sideRailBackground: tone150
@@ -133,12 +124,8 @@ QtObject {
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF" readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color sideBorder: toneBorder readonly property color sideBorder: toneBorder
readonly property color sideMutedText: toneMute 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 ──────────────────────────────────── // ── 10a. Transport / toolbar surfaces ────────────────────────────────────
readonly property color transportBackground: isDarkMode ? "#101014" : "#EFEFED"
readonly property color transportButtonBg: tone250 readonly property color transportButtonBg: tone250
readonly property color transportButtonHover: tone300 readonly property color transportButtonHover: tone300
readonly property color liveColor: themeAdded readonly property color liveColor: themeAdded
@@ -179,14 +166,7 @@ QtObject {
readonly property int sidePanelRadius: 10 readonly property int sidePanelRadius: 10
readonly property int sideFooterConnection: 100 readonly property int sideFooterConnection: 100
readonly property int sideFooterUtility: 46 readonly property int sideFooterUtility: 46
// Tab bar layout
readonly property int tabBarHeight: 34
readonly property int sidePillHeight: 54 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 // Settings page layout
readonly property int settingsPanelMaxWidth: 760 readonly property int settingsPanelMaxWidth: 760
readonly property int settingsOuterMargin: 40 readonly property int settingsOuterMargin: 40
@@ -3,8 +3,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import subprocess
import sys
import threading import threading
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
@@ -20,7 +24,6 @@ from pygui.serialcomm.data_parser import (
remove_trailing_zeros, remove_trailing_zeros,
save_to_csv, save_to_csv,
) )
from pygui.serialcomm.device_service import DeviceService
from pygui.serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
# import pygui.backend.wafer_map_item # import pygui.backend.wafer_map_item
@@ -61,6 +64,7 @@ class DeviceController(QObject):
super().__init__(parent) super().__init__(parent)
self._settings = settings self._settings = settings
self._data_dir = data_dir self._data_dir = data_dir
from pygui.serialcomm.device_service import DeviceService
self._service = DeviceService(settings) self._service = DeviceService(settings)
# Always start fresh — never restore connection status or logs from a prior session # 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._operation_in_progress = False
self._activity_log: list[str] = [] self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
from pathlib import Path
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv") self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
self._last_wafer_info: dict[str, Any] = {} self._last_wafer_info: dict[str, Any] = {}
self._last_csv_path: str = "" self._last_csv_path: str = ""
@@ -126,6 +129,34 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}") self._append_log(f"Save data dir set to: {path}")
self._save_status() self._save_status()
@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) @Property(str, notify=selectedPortChanged)
def selectedPort(self) -> str: def selectedPort(self) -> str:
"""Currently selected serial port shared across the UI.""" """Currently selected serial port shared across the UI."""
@@ -517,7 +548,6 @@ class DeviceController(QObject):
return return
if not self._save_data_dir: if not self._save_data_dir:
from pathlib import Path
self._save_data_dir = str(Path(self._data_dir) / "csv") self._save_data_dir = str(Path(self._data_dir) / "csv")
self._append_log(f"Auto-set save directory to: {self._save_data_dir}") self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
self._save_status() self._save_status()
@@ -40,6 +40,10 @@ class SessionController(QObject):
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph # trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats 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 # private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame _liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping _liveError = Signal() # worker-thread error ping
@@ -226,6 +230,10 @@ class SessionController(QObject):
def errorCount(self) -> int: def errorCount(self) -> int:
return self._error_count return self._error_count
@Property(int, notify=liveStatsChanged)
def resyncCount(self) -> int:
return self._reader.resync_count if self._reader else 0
# ---- mode + thresholds ---- # ---- mode + thresholds ----
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
@@ -305,6 +313,176 @@ class SessionController(QObject):
self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit() 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()
@slot_error_boundary @slot_error_boundary
def play(self) -> None: def play(self) -> None:
@@ -595,5 +773,3 @@ class SessionController(QObject):
def _flush_trend(self) -> None: def _flush_trend(self) -> None:
import json 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 ===== # ===== 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.wafer_layouts import available_families, load_layout
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -7,4 +8,5 @@ __all__ = [
"ZWaferData", "Sensor", "DataRecord", "ZWaferData", "Sensor", "DataRecord",
"ZWaferParser", "ZWaferParser",
"load_layout", "available_families", "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 import os
from typing import Optional 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 log = logging.getLogger(__name__)
# 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
def csv_column_count(family_code: str) -> int: 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. """Parse P-family (and A/B/C/D/E/F) binary data.
Each block of 256 readings has 244 valid + 12 overhead. 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] = [] readings: list[str] = []
# Read 2 bytes at a time (UInt16 little-endian) # 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 num_words = len(data_bytes) // 2
for i in range(num_words): for i in range(num_words):
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little") 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}") readings.append(f"{value:04X}")
# Chunk into rows of MAXDUT_P # Chunk into rows of sensor_count
result: list[list[str]] = [] result: list[list[str]] = []
idx = 0 idx = 0
while idx + MAXDUT_P <= len(readings): while idx + sensor_count <= len(readings):
result.append(readings[idx : idx + MAXDUT_P]) result.append(readings[idx : idx + sensor_count])
idx += MAXDUT_P 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 return result
@@ -183,24 +180,25 @@ def _parse_x_binary(data_bytes: bytes) -> list[list[str]]:
"""Parse X-family binary data. """Parse X-family binary data.
Each block of 256 readings has 80 valid + 14 overhead. 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] = [] readings: list[str] = []
num_words = len(data_bytes) // 2 num_words = len(data_bytes) // 2
for i in range(num_words): for i in range(num_words):
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little") 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}") readings.append(f"{value:04X}")
# Chunk into rows of MAXDUT_X # Chunk into rows of sensor_count
result: list[list[str]] = [] result: list[list[str]] = []
idx = 0 idx = 0
while idx + MAXDUT_X <= len(readings): while idx + sensor_count <= len(readings):
result.append(readings[idx : idx + MAXDUT_X]) result.append(readings[idx : idx + sensor_count])
idx += MAXDUT_X 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 return result
-12
View File
@@ -19,14 +19,6 @@ log = logging.getLogger(__name__)
class DeviceService: class DeviceService:
"""High-level wafer device communication.""" """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: def __init__(self, settings: LocalSettings) -> None:
self._settings = settings self._settings = settings
self._cached_ports: list[str] = [] self._cached_ports: list[str] = []
@@ -209,7 +201,3 @@ class DeviceService:
except Exception as exc: except Exception as exc:
log.error("Erase failed on %s: %s", port, exc) log.error("Erase failed on %s: %s", port, exc)
return False 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 typing import Callable
from pygui.backend.models.frame import Frame 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 from pygui.serialcomm.data_parser import _convert_hex_to_temp
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -30,6 +31,8 @@ class StreamReader:
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._stop = threading.Event() self._stop = threading.Event()
self.error_count = 0 self.error_count = 0
# Cumulative count of resync events (lost sync marker) for stream stats.
self.resync_count = 0
def start(self) -> None: def start(self) -> None:
self._stop.clear() self._stop.clear()
@@ -158,6 +161,7 @@ class StreamReader:
else: else:
# Resync: keep only trailing 0xAA if present, then read more. # Resync: keep only trailing 0xAA if present, then read more.
resync_attempts += 1 resync_attempts += 1
self.resync_count += 1
if resync_attempts > _MAX_RESYNC_ATTEMPTS: if resync_attempts > _MAX_RESYNC_ATTEMPTS:
log.error( log.error(
"StreamReader: giving up after %d resync attempts " "StreamReader: giving up after %d resync attempts "
@@ -179,7 +183,7 @@ class StreamReader:
def _run_ascii(self, initial_bytes: bytes) -> None: def _run_ascii(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting ASCII hex dump stream parsing") 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 block_words = 256
word_char_len = 4 word_char_len = 4
chars_needed = block_words * word_char_len # total hex chars per block 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, remove_trailing_zeros,
save_to_csv, save_to_csv,
_convert_hex_to_temp, _convert_hex_to_temp,
MAXDUT_P,
MAXDUT_X,
) )
# ── csv_column_count ────────────────────────────────────────────────────────── # ── csv_column_count ──────────────────────────────────────────────────────────
@@ -106,7 +104,7 @@ def _make_p_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
data = bytearray() data = bytearray()
for _ in range(num_blocks): for _ in range(num_blocks):
for i in range(256): 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") data += word.to_bytes(2, byteorder="little")
return bytes(data) return bytes(data)
@@ -120,7 +118,7 @@ def _make_x_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
data = bytearray() data = bytearray()
for _ in range(num_blocks): for _ in range(num_blocks):
for i in range(256): 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") data += word.to_bytes(2, byteorder="little")
return bytes(data) return bytes(data)
@@ -136,7 +134,7 @@ class TestParseBinaryData:
data = _make_p_block(1) data = _make_p_block(1)
result = parse_binary_data(data, "P") result = parse_binary_data(data, "P")
assert result is not None 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): def test_p_family_two_blocks_returns_two_rows(self):
data = _make_p_block(2) data = _make_p_block(2)
@@ -161,14 +159,14 @@ class TestParseBinaryData:
data = _make_x_block(1) data = _make_x_block(1)
result = parse_binary_data(data, "X") result = parse_binary_data(data, "X")
assert result is not None 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): def test_a_family_uses_p_parser(self):
# A/B/C/D/E/F all route to _parse_p_binary # A/B/C/D/E/F all route to _parse_p_binary
data = _make_p_block(1) data = _make_p_block(1)
result = parse_binary_data(data, "A") result = parse_binary_data(data, "A")
assert result is not None assert result is not None
assert len(result[0]) == MAXDUT_P assert len(result[0]) == 244
def test_empty_bytes_returns_empty_list(self): def test_empty_bytes_returns_empty_list(self):
result = parse_binary_data(b"", "P") 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.seek(2)
controller.stop() controller.stop()
assert controller.frameIndex == 0 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