Compare commits

..

12 Commits

Author SHA1 Message Date
jack 9e3bec9031 feat(debug): add debug read with CSV export
- Parse F1 debug data via family-independent temp formula
  (bits 4-14 scaled by 2**(i-8), sign at bit 0)
- Save sensor+debug temps side-by-side to debug_info.csv
- Wire READ DEBUG button in StatusActionsPanel
- Rename ReplayChart → RunChart; move into GraphTab
- Extract PanelBox/SectionTitle/PanelSlider in ReadoutPanel
  to cut ~300 lines of duplicated styling
- Add rightRailWidth token to Theme for consistent rail sizing
- Wrap ReadoutPanel in ScrollView so Thresholds card stays
  reachable when E-C delta rows appear
2026-07-11 18:39:29 -07:00
jack fed4d9b590 feat: replay chart with zoomable viewpor 2026-07-10 20:54:24 -07:00
jack 4bb855a940 feat(graph): add graph tab for whole-run data
Provides a static line chart plotting multi-sensor temperature values
over time when a session file is loaded.
2026-07-10 20:06:48 -07:00
jack 94f917b116 feat(map): add edge-center delta stats, thickness overlay, and layout fix
- Add edge-center sensor pair tables and delta calculations per wafer family.
- Parse and interpolate wafer thickness CSV data for offscreen visualization.
- Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping.
- Compact E-C Delta text format to fit standard sidebar width.
- Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
2026-07-10 17:32:12 -07:00
jack 25fa7507ce feat(map): add min/max highlighting toggle and live safeguards
- Add "Highlight Min/Max" option to ReadoutPanel showing peak rings.
- Reset to review mode and blank wafer map when switching away from map tab.
- Disable hover highlights and sensor dialog triggers during live stream.
- Lower trendPane height and add top margin headroom for tick labels.
- Add test coverage for blanking states, toggle overrides, and interactions.
2026-07-10 16:28:55 -07:00
jack 034f13b717 feat(map): add peak highlights and live metrics to PNG export
- Draw rings around hottest and coldest sensors on export.
- Extend export_image to support second line for live metrics.
- Format detailed sensor stats in export footer.
- Add test coverage for footer rows, overlays, and stats.
2026-07-10 15:39:30 -07:00
jack 69753e35f9 feat(map): add batch export and adjust layout alignment
- Implement synchronous offscreen wafer map PNG rendering and CSV summary.
- Add "Batch Export" button to file browser sidebar.
- Adjust trend pane bottom spacer to align with About button when visible.
- Fix type check errors and add pytest coverage for batch export.
2026-07-10 15:22:36 -07:00
jack 278a48a2e4 feat(transport): editable frame field + uniform spacing 2026-07-10 14:38:12 -07:00
jack e2ce8778e0 feat(files): sync Source panel browser with chosen save directory 2026-07-10 14:37:29 -07:00
jack bdc667b2ed refactor: extract StreamControlPanel and MetricRow from WaferMapTab 2026-07-09 15:16:11 -07:00
jack b6249c2b8e fix: added property var settingsPopup: null / property var aboutDialog: null to UtilityFooter.qml and updated onClicked to use local properties instead of utilFooter.parent.*. 2026-07-09 14:30:19 -07:00
jack 2fe8aef805 feat(ui): refine AboutDialog layout and add nav pill tooltips 2026-07-09 13:56:48 -07:00
43 changed files with 2787 additions and 1041 deletions
+34 -35
View File
@@ -9,8 +9,8 @@ Popup {
modal: true modal: true
dim: true dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 480 width: 600
height: 440 height: 500
anchors.centerIn: Overlay.overlay anchors.centerIn: Overlay.overlay
onOpened: licenseModel.refresh() onOpened: licenseModel.refresh()
@@ -31,8 +31,8 @@ Popup {
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 24 anchors.margins: 28
spacing: 16 spacing: 20
// ── Title ── // ── Title ──
Label { Label {
@@ -44,27 +44,20 @@ Popup {
// ── App info ── // ── App info ──
ColumnLayout { ColumnLayout {
spacing: 4 spacing: 6
Label { Label {
text: "ISenseCloud (ISC)" text: "ISenseCloud"
font.pixelSize: Theme.fontLg font.pixelSize: Theme.fontXl
font.bold: true
color: Theme.headingColor color: Theme.headingColor
} }
Label { Label {
text: "Version " + appVersion text: "Version " + appVersion
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontMd
color: Theme.bodyColor color: Theme.bodyColor
} }
Label {
text: "Temperature-sensing wafer monitoring"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
} }
// ── Separator ── // ── Separator ──
@@ -78,7 +71,7 @@ Popup {
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.minimumHeight: 120 Layout.minimumHeight: 150
radius: Theme.radiusSm radius: Theme.radiusSm
color: Theme.panelBackground color: Theme.panelBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
@@ -86,8 +79,8 @@ Popup {
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.margins: 14
spacing: 2 spacing: 6
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
@@ -95,7 +88,8 @@ Popup {
Label { Label {
text: "LICENSE" text: "LICENSE"
color: Theme.panelTitleText color: Theme.panelTitleText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 0.5 font.letterSpacing: 0.5
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -105,18 +99,18 @@ Popup {
visible: false visible: false
text: "Invalid license file" text: "Invalid license file"
color: Theme.statusWarningColor color: Theme.statusWarningColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
} }
// Grid header // Grid header
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 4 spacing: 8
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 } Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 90 } Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 50 } Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
Label { text: "License Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true } Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
} }
Rectangle { Rectangle {
@@ -133,11 +127,11 @@ Popup {
model: licenseModel.licenses model: licenseModel.licenses
delegate: RowLayout { delegate: RowLayout {
width: ListView.view.width width: ListView.view.width
spacing: 4 spacing: 8
Label { text: modelData.serial; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 100 } Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 90 } Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
Label { text: modelData.level; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 50 } Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
Label { text: modelData.licDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.fillWidth: true } Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
} }
} }
@@ -145,7 +139,7 @@ Popup {
visible: licenseModel.licenses.length === 0 visible: licenseModel.licenses.length === 0
text: "No license loaded" text: "No license loaded"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontMd
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
@@ -157,11 +151,12 @@ Popup {
// ── Buttons ── // ── Buttons ──
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 12
Button { Button {
text: "Load License" text: "Load License"
Layout.preferredWidth: 120 Layout.preferredWidth: 140
Layout.preferredHeight: 32 Layout.preferredHeight: 36
onClicked: licenseFileDialog.open() onClicked: licenseFileDialog.open()
background: Rectangle { background: Rectangle {
@@ -173,6 +168,8 @@ Popup {
contentItem: Label { contentItem: Label {
text: parent.text text: parent.text
color: Theme.buttonNeutralText color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
@@ -183,7 +180,7 @@ Popup {
Button { Button {
text: "Close" text: "Close"
Layout.preferredWidth: 100 Layout.preferredWidth: 100
Layout.preferredHeight: 32 Layout.preferredHeight: 36
onClicked: root.close() onClicked: root.close()
background: Rectangle { background: Rectangle {
@@ -195,6 +192,8 @@ Popup {
contentItem: Label { contentItem: Label {
text: parent.text text: parent.text
color: Theme.buttonNeutralText color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
+43 -13
View File
@@ -23,9 +23,14 @@ Rectangle {
if (selectedTabIndex === 2) { if (selectedTabIndex === 2) {
file_browser.refreshFiles(); file_browser.refreshFiles();
streamController.unloadFile() streamController.unloadFile()
} else if (streamController.mode === "live") {
// Leaving the map tab mid-stream: stop and blank the live map.
streamController.setMode("review")
} }
} }
property bool memoryRead: false property bool memoryRead: false
property bool waferDetected: false property bool waferDetected: false
// TODO P2.2: add `property bool waferLicensed: false`; set it in // TODO P2.2: add `property bool waferLicensed: false`; set it in
@@ -86,7 +91,9 @@ Rectangle {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save Data" title: "Choose a folder to save Data"
onAccepted: { onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)) var dir = root.cleanFolderUrl(selectedFolder)
deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir)
root._doDetect() root._doDetect()
} }
} }
@@ -213,9 +220,30 @@ Rectangle {
ListModel { ListModel {
id: pillModel id: pillModel
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 } ListElement {
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 } label: "STATUS"
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 } icon: "Tabs/icons/status.svg"
expandW: 86
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
}
ListElement {
label: "DATA"
icon: "Tabs/icons/data.svg"
expandW: 66
desc: "Manage, review, compare (DTW), and split historical sensor data records."
}
ListElement {
label: "MAP"
icon: "Tabs/icons/map.svg"
expandW: 58
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
}
ListElement {
label: "GRAPH"
icon: "Tabs/icons/bar-chart.svg"
expandW: 70
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
}
} }
RowLayout { RowLayout {
@@ -227,15 +255,6 @@ Rectangle {
model: pillModel model: pillModel
delegate: Button { delegate: Button {
id: pillBtn id: pillBtn
// TODO P3.1: lock MAP pill (index 2) when no license:
// `enabled: index !== 2 || licenseModel.licenses.length > 0`
// and dim icon/label when disabled. licensesChanged
// notify already fires on Load License → unlocks live,
// no restart. Rule is "any valid license" (not
// per-wafer): Map also replays CSVs with no wafer
// attached. Also gate the Map Loader (see TODO at the
// StackLayout below) and the auto-switch at
// onParsedDataReady. See plan §3.1.
checked: root.selectedTabIndex === index checked: root.selectedTabIndex === index
flat: true flat: true
Layout.fillWidth: true Layout.fillWidth: true
@@ -305,6 +324,11 @@ Rectangle {
} }
} }
AppToolTip {
visible: pillBtn.hovered
text: "<b>" + model.label + "</b>: " + model.desc
}
onClicked: root.selectedTabIndex = index onClicked: root.selectedTabIndex = index
} }
} }
@@ -598,6 +622,8 @@ Rectangle {
UtilityFooter { UtilityFooter {
id: utilFooter id: utilFooter
Layout.fillWidth: true Layout.fillWidth: true
settingsPopup: settingsPopup
aboutDialog: aboutDialog
} }
} }
} }
@@ -628,6 +654,10 @@ Rectangle {
// so the tab never instantiates while locked. See plan §3.1. // so the tab never instantiates while locked. See plan §3.1.
active: StackLayout.index === root.selectedTabIndex active: StackLayout.index === root.selectedTabIndex
} }
Loader {
source: "Tabs/GraphTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
} }
} }
} }
+4 -4
View File
@@ -57,7 +57,7 @@ Item {
if (root.runAWaferType === "" || root.runBWaferType === "") if (root.runAWaferType === "" || root.runBWaferType === "")
return "Recording has no wafer family — register it as a master file (Settings → Master Files)" return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
if (root.runAWaferType !== root.runBWaferType) if (root.runAWaferType !== root.runBWaferType)
return "Wafer family mismatch: " + root.runAWaferType + " vs " + root.runBWaferType return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
return "" return ""
} }
property real warpingDistance: -1 property real warpingDistance: -1
@@ -292,9 +292,9 @@ Item {
ComparisonSidePanel { ComparisonSidePanel {
id: sidePanel id: sidePanel
Layout.fillWidth: false Layout.fillWidth: false
Layout.preferredWidth: 280 Layout.preferredWidth: Theme.rightRailWidth
Layout.minimumWidth: 280 Layout.minimumWidth: Theme.rightRailWidth
Layout.maximumWidth: 280 Layout.maximumWidth: Theme.rightRailWidth
Layout.fillHeight: true Layout.fillHeight: true
warpingDistance: root.warpingDistance warpingDistance: root.warpingDistance
frameOffset: root.frameOffset frameOffset: root.frameOffset
+49
View File
@@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
// ===== Graph Tab =====
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
// snapshot recomputed whenever a file is loaded — not live-updating during
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
Item {
id: root
anchors.fill: parent
function refresh() {
if (!streamController.loadedFile) {
chart.seriesData = []
chart.sensorNames = []
return
}
chart.sensorNames = streamController.graphSensorNames
? streamController.graphSensorNames.split(",") : []
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
}
Connections {
target: streamController
function onLoadedFileChanged() { root.refresh() }
}
Component.onCompleted: root.refresh()
Rectangle {
anchors.fill: parent
anchors.margins: Theme.panelPadding
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
RunChart {
id: chart
anchors.fill: parent
anchors.margins: 8
title: "Sensor Temperature Over Time"
}
}
}
+8 -2
View File
@@ -122,7 +122,9 @@ ColumnLayout {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save." title: "Choose a folder to save."
onAccepted: { onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)); var dir = root.cleanFolderUrl(selectedFolder);
deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
root.parseAndSavePendingRead(); root.parseAndSavePendingRead();
} }
} }
@@ -132,7 +134,11 @@ ColumnLayout {
FolderDialog { FolderDialog {
id: dirOnlyDialog id: dirOnlyDialog
title: "Choose a folder to save Data" title: "Choose a folder to save Data"
onAccepted: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)) onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder);
deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
}
} }
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read). // Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
+47 -185
View File
@@ -1,7 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl import QtQuick.Controls.impl
import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Tabs.components import ISC.Tabs.components
@@ -11,39 +10,23 @@ Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
// Live elapsed-time counter // Wire streamController.trendDelta (Signal(str) carrying one new
property int _liveSecs: 0 // [elapsed_s, value] pair per live frame) into the trend chart, which
Timer { // accumulates its own bounded 60s window. trendReset clears it when a
id: liveTimer // new stream starts. Parsing happens on the Python TrendChartItem side
interval: 1000 // so malformed payloads are logged there instead of crashing the QML
repeat: true // binding.
running: streamController.mode === "live" && streamController.state !== "idle"
onTriggered: root._liveSecs++
onRunningChanged: if (!running)
root._liveSecs = 0
}
function fmtTime(s) {
var m = Math.floor(s / 60);
var ss = s % 60;
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
}
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
// into the trend chart's data property. The slot parseJsonToData() is defined
// on the Python TrendChartItem; we use it so malformed payloads are logged
// there instead of crashing the QML binding.
property string loadErrorMessage: "" property string loadErrorMessage: ""
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onTrendDelta(deltaJson) {
trendChart.setDataFromJson(avgsJson); trendChart.appendDelta(deltaJson);
}
function onTrendReset() {
trendChart.clearTrend();
} }
// A file was picked from the rail (any tab) — loadFile() switches the
// backend to review mode; mirror that in the toolbar toggle so a
// successful load is never hidden behind a stale "Live" selection.
function onLoadedFileChanged() { function onLoadedFileChanged() {
root.loadErrorMessage = ""; root.loadErrorMessage = "";
if (streamController.mode === "review")
modeBar.currentIndex = 0;
} }
function onLoadFileError(message) { function onLoadFileError(message) {
root.loadErrorMessage = message; root.loadErrorMessage = message;
@@ -60,107 +43,6 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10 spacing: 10
// Mode toggle
TabBar {
id: modeBar
currentIndex: 0 // Default to "Review"
spacing: 2
padding: 2
background: Rectangle {
color: Theme.subtleSectionBackground
radius: Theme.radiusSm
border.color: Theme.cardBorder
border.width: Theme.borderThin
}
TabButton {
text: "Review"
implicitWidth: 84
implicitHeight: 34
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
id: liveTab
text: "Live"
enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
implicitWidth: 84
implicitHeight: 34
// Disabled controls swallow no events at all, so hover for the
// "why is this greyed out" tooltip needs its own MouseArea.
AppToolTip {
visible: disabledHover.containsMouse && !liveTab.enabled
text: "Connect a wafer to enable Live mode"
delay: 300
}
MouseArea {
id: disabledHover
anchors.fill: parent
enabled: !liveTab.enabled
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Row {
spacing: 5
leftPadding: 8
Rectangle {
id: liveTabDot
width: 7; height: 7; radius: 3.5
anchors.verticalCenter: parent.verticalCenter
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
// Flash while actually streaming live
SequentialAnimation on opacity {
running: streamController.mode === "live" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
}
}
Text {
text: liveTab.text
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontMd
font.weight: liveTab.checked ? Font.Medium : Font.Normal
verticalAlignment: Text.AlignVCenter
height: parent.height
}
}
}
onCurrentIndexChanged: if (currentIndex === 1) {
// Guard: revert to Review if not connected
if (deviceController.connectionStatus !== "Connected") {
currentIndex = 0;
return;
}
// Entering Live mode
streamController.setMode("live");
var fc = "";
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
fc = deviceController.lastWaferInfo[0];
}
streamController.startStream(deviceController.selectedPort, fc);
} else {
// Entering Review Mode (automatically calls stopStream in backend)
streamController.setMode("review");
}
}
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -215,49 +97,6 @@ Item {
font.letterSpacing: 1.2 font.letterSpacing: 1.2
} }
} }
// Record start/stop toggle (Live mode)
Button {
visible: streamController.mode === "live"
enabled: streamController.state !== "idle" || streamController.recording
text: streamController.recording ? "Stop REC" : "Record"
onClicked: {
if (streamController.recording) {
streamController.stopRecording();
} else {
var info = deviceController.lastWaferInfo;
var serial = (info && info.length > 1) ? info[1] : "";
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
}
}
}
// Stream statistics popup (Live mode)
Button {
visible: streamController.mode === "live"
text: "Stats"
onClicked: streamStatsDialog.open()
}
StreamStatsDialog {
id: streamStatsDialog
elapsedSecs: root._liveSecs
}
Button {
text: "Export PNG"
onClicked: exportDialog.open()
}
FileDialog {
id: exportDialog
title: "Export Wafer Map"
fileMode: FileDialog.SaveFile
nameFilters: ["PNG files (*.png)"]
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
}
} }
// ── Load error banner ─────────────────────────────────────────── // ── Load error banner ───────────────────────────────────────────
@@ -305,55 +144,78 @@ Item {
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 8 spacing: 16
WaferMapView { WaferMapView {
id: waferView id: waferView
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.preferredHeight: 400
Layout.minimumHeight: 280
blend: readoutPanel.heatmapBlend blend: readoutPanel.heatmapBlend
showLabels: readoutPanel.showLabels showLabels: readoutPanel.showLabels
showExtremes: readoutPanel.showExtremes
showThickness: readoutPanel.showThickness showThickness: readoutPanel.showThickness
} }
Rectangle { Rectangle {
id: trendPane id: trendPane
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 160 Layout.fillHeight: false
Layout.preferredHeight: 220
Layout.minimumHeight: 120
visible: trendChart.hasData && streamController.mode === "live" visible: trendChart.hasData && streamController.mode === "live"
color: Theme.cardBackground color: Theme.cardBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
radius: Theme.radiusMd radius: Theme.radiusMd
TrendChartItem { ColumnLayout {
id: trendChart
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: 8
spacing: 10
TrendChartItem {
id: trendChart
Layout.fillWidth: true
Layout.fillHeight: true
}
} }
} }
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 16 visible: transportBar.hasContent
implicitHeight: trendPane.visible ? 52 : 16
} }
TransportBar { TransportBar {
id: transportBar
Layout.fillWidth: true Layout.fillWidth: true
} }
} }
// Readout panel — floating wrapper box // Readout panel — scrolls instead of clipping so the last card
Rectangle { // (Thresholds) is always reachable even if content outgrows
Layout.preferredWidth: 220 // the available height (e.g. once E-C delta rows appear).
ScrollView {
Layout.preferredWidth: Theme.rightRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: "transparent"
border.color: "transparent"
border.width: 0
clip: true clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ReadoutPanel { ReadoutPanel {
id: readoutPanel id: readoutPanel
anchors.fill: parent width: parent.width
anchors.margins: 0 hasThicknessData: waferView.hasThickness
onExportRequested: function(filePath, extra) {
waferView.exportImage(filePath, extra);
}
onThicknessFileChosen: function(filePath) {
var err = waferView.loadThickness(filePath);
if (err !== "") {
root.loadErrorMessage = err;
readoutPanel.showThickness = false;
}
}
} }
} }
} }
@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
RowLayout {
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
spacing: 4
Label {
text: label
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
Layout.fillWidth: true
}
Label {
text: value
color: valueColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
@@ -12,6 +12,9 @@ Rectangle {
property string label: "" property string label: ""
property string iconSource: "" property string iconSource: ""
property bool destructive: false property bool destructive: false
// Set true to show the button as "active" (e.g. a one-shot toggle
// that's mid-operation), independent of hover/press state.
property bool toggled: false
property color _textColor: !root.enabled ? Theme.sideMutedText property color _textColor: !root.enabled ? Theme.sideMutedText
: root.destructive ? Theme.statusErrorColor : root.destructive ? Theme.statusErrorColor
: Theme.headingColor : Theme.headingColor
@@ -20,7 +23,7 @@ Rectangle {
implicitHeight: Theme.sideButtonHeight implicitHeight: Theme.sideButtonHeight
radius: 8 radius: 8
color: mouseArea.containsMouse || mouseArea.pressed color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
? Theme.sideActiveBackground : "transparent" ? Theme.sideActiveBackground : "transparent"
border.width: 0 border.width: 0
+105 -429
View File
@@ -1,61 +1,26 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
// ===== Readout Panel ===== // ===== Readout Panel =====
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS. // Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
// Cards match the left-rail SOURCE / CONNECTION FLOW style. // THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
// PanelCheckBox / PanelSlider components as the Data tab's
// ComparisonSidePanel, so both right rails share one visual language.
ColumnLayout { ColumnLayout {
id: root
spacing: 6 spacing: 6
property var s: streamController.stats property var s: streamController.stats
property alias showLabels: labelsToggle.checked property alias showLabels: labelsToggle.checked
property alias showExtremes: extremesToggle.checked
property alias heatmapBlend: heatmapSlider.value property alias heatmapBlend: heatmapSlider.value
property alias showThickness: thicknessToggle.checked property alias showThickness: thicknessToggle.checked
property bool hasThicknessData: false
component PanelCheckBox: CheckBox { signal exportRequested(string filePath, string extra)
id: toggle signal thicknessFileChosen(string filePath)
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
IconImage {
anchors.centerIn: parent
source: "../icons/check.svg"
width: 12; height: 12
sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
component BadgePill: Rectangle { component BadgePill: Rectangle {
implicitWidth: badgeLabel.implicitWidth + 10 implicitWidth: badgeLabel.implicitWidth + 10
@@ -75,374 +40,106 @@ ColumnLayout {
} }
} }
// Live-mode stream stats + stop — moved here from the footer transport bar StreamControlPanel {
// so that bar collapses to zero height in live mode, freeing vertical
// space for the trend chart.
Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
visible: streamController.mode === "live" onExportRequested: function(filePath, extra) {
implicitHeight: visible ? liveStatsCard.implicitHeight + 24 : 0 root.exportRequested(filePath, extra);
color: Theme.sidePanelBackground }
radius: Theme.sidePanelRadius }
border.color: Theme.sideBorder
border.width: 1 // ── READOUT ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: readoutCol.implicitHeight + 24
ColumnLayout { ColumnLayout {
id: liveStatsCard id: readoutCol
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 8 spacing: 8
Label { SectionTitle { text: "READOUT" }
text: "LIVE STREAM"
color: Theme.sideMutedText ReadoutStat {
font.family: Theme.uiFontFamily label: "Min Temp"
font.pixelSize: Theme.fontSm value: s.min !== undefined
font.letterSpacing: 1.5 ? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
font.bold: true : "—"
Layout.bottomMargin: 4 valueColor: Theme.sensorLow
}
ReadoutStat {
label: "Max Temp"
value: s.max !== undefined
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
: "—"
valueColor: Theme.sensorHigh
}
ReadoutStat {
label: "Diff"
value: s.diff !== undefined ? s.diff : "—"
valueColor: Theme.diffAccent
}
ReadoutStat {
label: "Average"
value: s.avg !== undefined ? s.avg : "—"
}
ReadoutStat {
label: "Sigma (Σ)"
value: s.sigma !== undefined ? s.sigma : "—"
}
ReadoutStat {
label: "3Σ Value"
value: s.threeSigma !== undefined ? s.threeSigma : "—"
}
ReadoutStat {
visible: s.ecMinDelta !== undefined
label: "E-C Δ Min"
value: s.ecMinDelta !== undefined
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
: "—"
}
ReadoutStat {
visible: s.ecMaxDelta !== undefined
label: "E-C Δ Max"
value: s.ecMaxDelta !== undefined
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
: "—"
}
}
} }
RowLayout { // ── DISPLAY ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
Label { Layout.preferredHeight: displayCard.implicitHeight + 24
text: "Received"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: streamController.receivedCount
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Errors"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: streamController.errorCount
color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Button {
id: stopStreamBtn
Layout.fillWidth: true
Layout.topMargin: 4
hoverEnabled: true
text: "STOP"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.letterSpacing: 1.0
background: Rectangle {
radius: 8
color: stopStreamBtn.down ? Theme.transportButtonHover
: (stopStreamBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: stopStreamBtn.text
color: Theme.headingColor
font: stopStreamBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.stopStream()
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: readoutCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout {
id: readoutCard
anchors {
left: parent.left
right: parent.right
top: parent.top
topMargin: 12
}
spacing: 0
// Header Inside the Box
Label {
text: "READOUT"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.topMargin: 2
Layout.bottomMargin: 10
}
// Min Temp Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MIN TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
Label {
visible: (s.minIndex !== undefined) && s.minIndex >= 0
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Max Temp Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MAX TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
Label {
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Differential Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "DIFF"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.diffAccent
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Average Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "AVERAGE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "SIGMA (Σ)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 3-Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "3Σ VALUE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: displayCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: displayCard id: displayCard
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 8 spacing: 8
Label { SectionTitle { text: "DISPLAY" }
text: "DISPLAY"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
PanelCheckBox { PanelCheckBox {
id: thicknessToggle id: thicknessToggle
text: "Show Thickness" text: "Show Thickness"
checked: false checked: false
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
// First check with no data prompts for the customer CSV;
// unchecking only hides the overlay, data stays loaded.
onToggled: {
if (checked && !root.hasThicknessData)
thicknessDialog.open();
}
}
FileDialog {
id: thicknessDialog
title: "Select Thickness Data File"
nameFilters: ["CSV files (*.csv)"]
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
onRejected: thicknessToggle.checked = false
} }
PanelCheckBox { PanelCheckBox {
@@ -452,6 +149,13 @@ ColumnLayout {
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
} }
PanelCheckBox {
id: extremesToggle
text: "Highlight Min/Max"
checked: true
font.pixelSize: Theme.fontSm
}
PanelCheckBox { PanelCheckBox {
id: clusterAverageToggle id: clusterAverageToggle
text: "Average Clusters" text: "Average Clusters"
@@ -475,7 +179,7 @@ ColumnLayout {
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52 Layout.preferredWidth: 52
} }
Slider { PanelSlider {
id: heatmapSlider id: heatmapSlider
from: 0 from: 0
to: 1 to: 1
@@ -486,19 +190,6 @@ ColumnLayout {
visible: heatmapSlider.hovered visible: heatmapSlider.hovered
text: Math.round(heatmapSlider.value * 100) + "%" text: Math.round(heatmapSlider.value * 100) + "%"
} }
handle: Rectangle {
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: heatmapSlider.pressed ? 1.15 : 1.0
Behavior on scale {
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
}
}
} }
Label { Label {
text: Math.round(heatmapSlider.value * 100) + "%" text: Math.round(heatmapSlider.value * 100) + "%"
@@ -512,33 +203,18 @@ ColumnLayout {
} }
} }
Rectangle { // ── THRESHOLDS ──────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: thresholdCard.implicitHeight + 24 Layout.preferredHeight: thresholdCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: thresholdCard id: thresholdCard
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 6 spacing: 6
Label { SectionTitle { text: "THRESHOLDS" }
text: "THRESHOLDS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
Label { Label {
text: "Set Point (°C)" text: "Set Point (°C)"
+135
View File
@@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Run Chart =====
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
// the strip below shows the aggregate min/max/avg over the *visible* range
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
Item {
id: root
property alias sensorNames: chart.sensorNames
property alias seriesData: chart.seriesData
property alias title: chart.title
ColumnLayout {
anchors.fill: parent
spacing: 8
Item {
Layout.fillWidth: true
Layout.fillHeight: true
GraphQuickItem {
id: chart
anchors.fill: parent
showMinMaxMarkers: true
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
}
MouseArea {
id: interaction
anchors.fill: parent
acceptedButtons: Qt.LeftButton
property real lastX: 0
onWheel: function(wheel) {
var frac = Math.max(0, Math.min(1, wheel.x / width));
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
chart.zoomAtFraction(frac, factor);
wheel.accepted = true;
}
onPressed: function(mouse) {
lastX = mouse.x;
}
onPositionChanged: function(mouse) {
if (pressed && width > 0) {
chart.panByFraction(-(mouse.x - lastX) / width);
lastX = mouse.x;
}
}
}
}
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
Label {
text: "Min:"
color: Theme.sensorLow
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Max:"
color: Theme.sensorHigh
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Avg:"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewAvg.toFixed(2)
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Item { Layout.fillWidth: true }
Button {
id: resetZoomBtn
implicitHeight: 32
hoverEnabled: true
text: "Reset Zoom"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
background: Rectangle {
radius: Theme.radiusSm
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
}
contentItem: Text {
text: resetZoomBtn.text
color: Theme.headingColor
font: resetZoomBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: chart.resetZoom()
}
}
}
}
@@ -57,6 +57,17 @@ ColumnLayout {
onClicked: deviceController.eraseMemory( onClicked: deviceController.eraseMemory(
deviceController.selectedPort) deviceController.selectedPort)
} }
RailActionButton {
label: "READ DEBUG"
iconSource: "../icons/read.svg"
Layout.fillWidth: true
// One-shot toggle: "on" while the debug read is in flight,
// resets automatically once deviceController flips status.
toggled: deviceController.connectionStatus === "Reading debug..."
enabled: deviceController.waferDetected && !deviceController.operationInProgress
onClicked: deviceController.readDebug(deviceController.selectedPort)
}
} }
} }
@@ -0,0 +1,374 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
Rectangle {
id: root
Layout.fillWidth: true
implicitHeight: contentCol.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
signal exportRequested(string filePath, string extra)
property int _liveSecs: 0
Connections {
target: streamController
function onModeChanged() {
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
modeBar.currentIndex = 0;
}
}
}
Timer {
id: liveTimer
interval: 1000
repeat: true
running: streamController.mode === "live" && streamController.state !== "idle"
onTriggered: root._liveSecs++
onRunningChanged: if (!running) root._liveSecs = 0
}
function fmtTime(s) {
var m = Math.floor(s / 60);
var ss = s % 60;
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
}
function switchToReview() {
modeBar.currentIndex = 0;
}
ColumnLayout {
id: contentCol
anchors {
fill: parent
topMargin: 10
leftMargin: 12
rightMargin: 12
bottomMargin: 12
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 6
TabBar {
id: modeBar
currentIndex: 0
spacing: 2
padding: 2
Layout.fillWidth: true
Layout.preferredHeight: 28
background: Rectangle {
color: Theme.subtleSectionBackground
radius: Theme.radiusXs
border.color: Theme.cardBorder
border.width: 1
}
TabButton {
text: "Review"
Layout.fillWidth: true
implicitHeight: 24
font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusXs - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
id: liveTab
text: "Live"
enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
Layout.fillWidth: true
implicitHeight: 24
font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal
AppToolTip {
visible: disabledHover.containsMouse && !liveTab.enabled
text: "Connect a wafer to enable Live mode"
delay: 300
}
MouseArea {
id: disabledHover
anchors.fill: parent
enabled: !liveTab.enabled
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusXs - 1
}
contentItem: Item {
Row {
spacing: 4
anchors.centerIn: parent
Rectangle {
id: liveTabDot
width: 5; height: 5; radius: 2.5
anchors.verticalCenter: parent.verticalCenter
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
SequentialAnimation on opacity {
running: streamController.mode === "live" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
}
}
Text {
text: liveTab.text
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
font: liveTab.font
verticalAlignment: Text.AlignVCenter
height: parent.height
}
}
}
}
onCurrentIndexChanged: {
if (currentIndex === 1) {
if (deviceController.connectionStatus !== "Connected") {
currentIndex = 0;
return;
}
streamController.setMode("live");
var fc = "";
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
fc = deviceController.lastWaferInfo[0];
}
streamController.startStream(deviceController.selectedPort, fc);
} else {
streamController.setMode("review");
}
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
Label {
text: "METRICS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 2
}
MetricRow {
label: "Received Frames"
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
}
MetricRow {
label: "Errors"
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
}
MetricRow {
label: "Resyncs"
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
}
MetricRow {
label: "Elapsed"
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
Label {
text: "ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 2
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Button {
id: recordBtn
Layout.fillWidth: true
implicitHeight: 28
enabled: streamController.mode === "live"
opacity: enabled ? 1.0 : 0.4
text: streamController.recording ? "Stop REC" : "Record"
AppToolTip {
visible: recordBtn.hovered && !recordBtn.enabled
text: "Connect to Live to start recording"
delay: 300
}
onClicked: {
if (streamController.recording) {
streamController.stopRecording();
} else {
var info = deviceController.lastWaferInfo;
var serial = (info && info.length > 1) ? info[1] : "";
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
}
}
background: Rectangle {
radius: Theme.radiusSm
color: recordBtn.down ? Theme.transportButtonHover
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
}
contentItem: Text {
text: recordBtn.text
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: exportBtn
Layout.fillWidth: true
implicitHeight: 28
text: "Export"
// Nothing to export until a file is loaded or a stream has played
enabled: streamController.sensorValues.length > 0
opacity: enabled ? 1 : 0.4
onClicked: {
// Default into <saveDataDir>/Export (created on demand)
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
exportDialog.open()
}
background: Rectangle {
radius: Theme.radiusSm
color: exportBtn.down ? Theme.transportButtonHover
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
}
contentItem: Text {
text: exportBtn.text
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
FileDialog {
id: exportDialog
title: "Export Wafer Map"
fileMode: FileDialog.SaveFile
defaultSuffix: "png"
nameFilters: ["PNG files (*.png)"]
onAccepted: {
var path = String(selectedFile).replace(/^file:\/\//, "");
var extra = "";
if (streamController.mode === "live") {
extra = "Frames: " + streamController.receivedCount
+ " Errors: " + streamController.errorCount
+ " Resyncs: " + streamController.resyncCount
+ " Elapsed: " + root.fmtTime(root._liveSecs);
}
root.exportRequested(path, extra);
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
Button {
id: primaryBtn
Layout.fillWidth: true
implicitHeight: 32
text: streamController.mode === "live" ? "STOP" : "START"
enabled: streamController.mode === "live"
? true
: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
AppToolTip {
visible: primaryBtn.hovered && !primaryBtn.enabled
text: "Connect a wafer to enable Live mode"
delay: 300
}
onClicked: {
if (streamController.mode === "live") {
streamController.stopStream();
modeBar.currentIndex = 0;
} else {
modeBar.currentIndex = 1;
}
}
background: Rectangle {
radius: Theme.radiusSm
color: primaryBtn.down ? Theme.transportButtonHover
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
}
contentItem: Text {
text: primaryBtn.text
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
@@ -1,130 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.impl
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 {
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: IconImage {
source: "../icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
anchors.centerIn: parent
}
}
}
// 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)
}
}
}
}
}
+59 -5
View File
@@ -42,15 +42,69 @@ Item {
spacing: 24 spacing: 24
// Frame counter // Frame counter
Text { Row {
spacing: 6
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Frame " + (streamController.frameIndex + 1)
+ " / " + streamController.frameTotal Label {
text: "Frame"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Medium font.weight: Font.Medium
leftPadding: 8 anchors.verticalCenter: parent.verticalCenter
}
TextField {
id: frameInput
text: String(streamController.frameIndex + 1)
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.family: Theme.codeFontFamily
color: Theme.headingColor
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
padding: 0
width: Math.max(44, contentWidth + 16)
height: 28
anchors.verticalCenter: parent.verticalCenter
background: Rectangle {
color: Theme.fieldBackground
radius: Theme.radiusXs
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
border.width: 1
}
onEditingFinished: {
var val = parseInt(text)
if (!isNaN(val)) {
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
streamController.seek(targetFrame - 1)
}
text = String(streamController.frameIndex + 1)
}
Connections {
target: streamController
function onFrameUpdated() {
if (!frameInput.activeFocus) {
frameInput.text = String(streamController.frameIndex + 1)
}
}
}
}
Label {
text: "/ " + streamController.frameTotal
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
} }
// Separator 1 // Separator 1
@@ -124,7 +178,7 @@ Item {
Button { Button {
id: playPauseBtn id: playPauseBtn
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
implicitWidth: 64 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
background: Rectangle { background: Rectangle {
@@ -14,6 +14,9 @@ Rectangle {
border.width: 1 border.width: 1
color: Theme.sidePanelBackground color: Theme.sidePanelBackground
property var settingsPopup: null
property var aboutDialog: null
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 4 anchors.margins: 4
@@ -25,7 +28,7 @@ Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
icon.source: "../icons/settings.svg" icon.source: "../icons/settings.svg"
onClicked: utilFooter.parent.settingsPopup.open() onClicked: if (settingsPopup) settingsPopup.open()
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
@@ -73,7 +76,7 @@ Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
icon.source: "../icons/about.svg" icon.source: "../icons/about.svg"
onClicked: utilFooter.parent.aboutDialog.open() onClicked: if (aboutDialog) aboutDialog.open()
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
+13 -3
View File
@@ -7,7 +7,9 @@ Item {
id: root id: root
property real blend: 0.0 property real blend: 0.0
property bool showLabels: true property bool showLabels: true
property alias showExtremes: map.showExtremes
property alias showThickness: map.showThickness property alias showThickness: map.showThickness
property alias hasThickness: map.hasThickness
WaferMapItem { WaferMapItem {
id: map id: map
@@ -39,7 +41,9 @@ Item {
HoverHandler { HoverHandler {
id: hoverHandler id: hoverHandler
onPointChanged: { onPointChanged: {
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y); map.hoveredIndex = streamController.mode === "live"
? -1
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
} }
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1 onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
@@ -47,6 +51,8 @@ Item {
TapHandler { TapHandler {
onTapped: ev => { onTapped: ev => {
if (streamController.mode === "live")
return;
var idx = map.which_marker(ev.position.x, ev.position.y); var idx = map.which_marker(ev.position.x, ev.position.y);
if (idx >= 0) if (idx >= 0)
replaceDialog.openFor(idx); replaceDialog.openFor(idx);
@@ -58,7 +64,11 @@ Item {
id: replaceDialog id: replaceDialog
} }
function exportImage(filePath) { function exportImage(filePath, extra) {
return map.export_image(filePath); return map.export_image(filePath, extra || "");
}
function loadThickness(filePath) {
return map.loadThickness(filePath);
} }
} }
+3 -1
View File
@@ -8,12 +8,13 @@ ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.qml RailActionButton 1.0 RailActionButton.qml
SelectFileDialog 1.0 SelectFileDialog.qml SelectFileDialog 1.0 SelectFileDialog.qml
SplitDialog 1.0 SplitDialog.qml SplitDialog 1.0 SplitDialog.qml
StreamStatsDialog 1.0 StreamStatsDialog.qml StreamControlPanel 1.0 StreamControlPanel.qml
StatusActionsPanel 1.0 StatusActionsPanel.qml StatusActionsPanel 1.0 StatusActionsPanel.qml
ConnectionFooter 1.0 ConnectionFooter.qml ConnectionFooter 1.0 ConnectionFooter.qml
UtilityFooter 1.0 UtilityFooter.qml UtilityFooter 1.0 UtilityFooter.qml
PanelCheckBox 1.0 PanelCheckBox.qml PanelCheckBox 1.0 PanelCheckBox.qml
ReadoutStat 1.0 ReadoutStat.qml ReadoutStat 1.0 ReadoutStat.qml
MetricRow 1.0 MetricRow.qml
AppToolTip 1.0 AppToolTip.qml AppToolTip 1.0 AppToolTip.qml
SectionTitle 1.0 SectionTitle.qml SectionTitle 1.0 SectionTitle.qml
PanelBox 1.0 PanelBox.qml PanelBox 1.0 PanelBox.qml
@@ -22,3 +23,4 @@ EditableScrubStat 1.0 EditableScrubStat.qml
ComparisonChartCard 1.0 ComparisonChartCard.qml ComparisonChartCard 1.0 ComparisonChartCard.qml
OverlapMapCard 1.0 OverlapMapCard.qml OverlapMapCard 1.0 OverlapMapCard.qml
ComparisonSidePanel 1.0 ComparisonSidePanel.qml ComparisonSidePanel 1.0 ComparisonSidePanel.qml
RunChart 1.0 RunChart.qml
+1
View File
@@ -206,6 +206,7 @@ QtObject {
readonly property int panelPadding: 12 readonly property int panelPadding: 12
// Side rail layout // Side rail layout
readonly property int sideRailWidth: 300 readonly property int sideRailWidth: 300
readonly property int rightRailWidth: 280
readonly property int sideRailMargin: 16 readonly property int sideRailMargin: 16
readonly property int sideRailSpacing: 16 readonly property int sideRailSpacing: 16
readonly property int sideButtonHeight: 44 readonly property int sideButtonHeight: 44
+3
View File
@@ -74,6 +74,9 @@ def main() -> int:
device_controller = DeviceController(raw_settings, data_dir) device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller) engine.rootContext().setContextProperty("deviceController", device_controller)
# Source panel browses the same folder recordings/reads are saved to.
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
# ===== License Model (About dialog grid, replay trial) ===== # ===== License Model (About dialog grid, replay trial) =====
license_model = LicenseModel(data_dir) license_model = LicenseModel(data_dir)
engine.rootContext().setContextProperty("licenseModel", license_model) engine.rootContext().setContextProperty("licenseModel", license_model)
@@ -18,9 +18,11 @@ from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.visualization.graph_view import GraphView from pygui.backend.visualization.graph_view import GraphView
from pygui.serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
convert_to_debug_temperatures,
convert_to_temperatures, convert_to_temperatures,
parse_binary_data, parse_binary_data,
remove_trailing_zeros, remove_trailing_zeros,
save_debug_csv,
save_to_csv, save_to_csv,
) )
from pygui.serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
@@ -139,6 +141,14 @@ 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(result=str)
@slot_error_boundary
def exportDir(self) -> str:
"""Ensure and return the Export subfolder under saveDataDir."""
path = Path(self._save_data_dir) / "Export"
path.mkdir(parents=True, exist_ok=True)
return str(path)
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def openCsvFile(self, file_path: str) -> None: def openCsvFile(self, file_path: str) -> None:
@@ -547,10 +557,29 @@ class DeviceController(QObject):
self._debugFinished.emit({"error": "F1 read failed"}) self._debugFinished.emit({"error": "F1 read failed"})
return return
fc = (
self._last_wafer_info.get("familyCode", "")
if self._last_wafer_info
else ""
)
sensor_hex = parse_binary_data(sensor_data, fc)
debug_hex = parse_binary_data(debug_data, fc)
if not sensor_hex or not debug_hex:
self._debugFinished.emit({"error": "Debug binary parse failed"})
return
temp_data = convert_to_temperatures(sensor_hex, fc)
debug_temp_data = convert_to_debug_temperatures(debug_hex)
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
if csv_path is None:
self._debugFinished.emit({"error": "Debug CSV save failed"})
return
self._debugFinished.emit({ self._debugFinished.emit({
"success": True, "success": True,
"sensor_bytes": len(sensor_data), "sensor_bytes": len(sensor_data),
"debug_bytes": len(debug_data), "debug_bytes": len(debug_data),
"csv_path": csv_path,
}) })
except Exception as exc: except Exception as exc:
log.exception("Debug worker crashed: %s", exc) log.exception("Debug worker crashed: %s", exc)
@@ -564,10 +593,11 @@ class DeviceController(QObject):
self._set_connection_status("Connected") self._set_connection_status("Connected")
sensor_bytes = result.get("sensor_bytes", 0) sensor_bytes = result.get("sensor_bytes", 0)
debug_bytes = result.get("debug_bytes", 0) debug_bytes = result.get("debug_bytes", 0)
self._append_log( csv_path = result.get("csv_path", "")
f"Debug read complete: {sensor_bytes} sensor bytes, " msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
f"{debug_bytes} debug bytes" if csv_path:
) msg += f", CSV written to {csv_path}"
self._append_log(msg)
else: else:
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
err_msg = result.get("error", "Debug read failed") err_msg = result.get("error", "Debug read failed")
@@ -11,12 +11,17 @@ from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
from pygui.backend.data.csv_recorder import CsvRecorder from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data from pygui.backend.models.frame_player import (
from pygui.backend.models.replay_stats import ReplayStatsTracker FramePlayer,
all_sensor_series,
frames_from_wafer_data,
)
from pygui.backend.models.frame_stats import compute_edge_center
from pygui.backend.models.sensor_editor import SensorEditor from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.threshold_classifier import ThresholdConfig from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader from pygui.serialcomm.stream_reader import StreamReader
@@ -39,7 +44,8 @@ class SessionController(QObject):
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
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 trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
trendReset = Signal() # live trend buffer cleared (new stream started)
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python # NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
# dicts to QML as an opaque empty wrapper with no accessible fields. # 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 comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
@@ -59,13 +65,7 @@ class SessionController(QObject):
self._sensors: list[Sensor] = [] self._sensors: list[Sensor] = []
self._last: Optional[SessionUpdate] = None self._last: Optional[SessionUpdate] = None
self._elapsed = 0.0 self._elapsed = 0.0
self._trend_buffer: list[float] = [] # rolling window self._stream_start_time: float = 0.0
self._trend_timestamps: list[float] = [] # monotonic timestamps
# Trend refresh timer - update every 1s
self._trend_timer = QTimer(self)
self._trend_timer.setInterval(1000)
self._trend_timer.timeout.connect(self._flush_trend)
self._play_timer = QTimer(self) self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance) self._play_timer.timeout.connect(self._advance)
@@ -81,11 +81,11 @@ class SessionController(QObject):
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection) self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
self._sensor_editor = SensorEditor() self._sensor_editor = SensorEditor()
self._ec_pairs: tuple[tuple[int, int], ...] = ()
self._last_raw_frame: Frame | None = None self._last_raw_frame: Frame | None = None
self._loaded_file: str = "" self._loaded_file: str = ""
self._cluster_averaging_enabled = False self._cluster_averaging_enabled = False
self._active_clusters: list[list[int]] = [] self._active_clusters: list[list[int]] = []
self._stats_tracker = ReplayStatsTracker()
self._received_count: int = 0 self._received_count: int = 0
self._error_count: int = 0 self._error_count: int = 0
@@ -187,14 +187,42 @@ class SessionController(QObject):
if not self._last: if not self._last:
return {} return {}
s = self._last.stats s = self._last.stats
return {"min": round(s.min, 2), "minIndex": s.min_index + 1, out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
"max": round(s.max, 2), "maxIndex": s.max_index + 1, "max": round(s.max, 2), "maxIndex": s.max_index + 1,
"diff": round(s.diff, 2), "avg": round(s.avg, 2), "diff": round(s.diff, 2), "avg": round(s.avg, 2),
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)} "sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
ec = compute_edge_center(self._last.values, self._ec_pairs,
self._sensor_editor.replaced_indices())
if ec is not None:
out.update({
"ecMinEdgeIndex": ec.min_edge_index + 1,
"ecMinCenterIndex": ec.min_center_index + 1,
"ecMinEdgeValue": round(ec.min_edge, 2),
"ecMinCenterValue": round(ec.min_center, 2),
"ecMinDelta": round(ec.min_delta, 2),
"ecMaxEdgeIndex": ec.max_edge_index + 1,
"ecMaxCenterIndex": ec.max_center_index + 1,
"ecMaxEdgeValue": round(ec.max_edge, 2),
"ecMaxCenterValue": round(ec.max_center, 2),
"ecMaxDelta": round(ec.max_delta, 2),
})
return out
@Property(str, notify=loadedFileChanged) @Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file def loadedFile(self) -> str: return self._loaded_file
@Property(str, notify=loadedFileChanged)
def graphSensorNames(self) -> str:
"""Comma-joined sensor names for the whole-run Graph tab."""
names, _ = all_sensor_series(list(self._player.frames))
return ",".join(names)
@Property(str, notify=loadedFileChanged)
def graphSeriesJson(self) -> str:
"""JSON per-sensor value series (one list per sensor) for the Graph tab."""
_, series = all_sensor_series(list(self._player.frames))
return json.dumps(series)
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type] @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
def overriddenSensors(self) -> list[int]: def overriddenSensors(self) -> list[int]:
"""Indices of sensors that currently have a replacement or offset.""" """Indices of sensors that currently have a replacement or offset."""
@@ -232,6 +260,12 @@ class SessionController(QObject):
return return
if mode != "live": if mode != "live":
self.stopStream() self.stopStream()
if self._mode == "live":
# Leaving a live session: blank the wafer map instead of
# freezing the last streamed frame on screen.
self._last = None
self._last_raw_frame = None
self.frameUpdated.emit()
self._play_timer.stop() self._play_timer.stop()
self._mode = mode self._mode = mode
self.modeChanged.emit() self.modeChanged.emit()
@@ -287,21 +321,17 @@ class SessionController(QObject):
wafer_id = stem.split("-")[0] if "-" in stem else stem wafer_id = stem.split("-")[0] if "-" in stem else stem
shape, size = resolve_shape_and_size(sensors, wafer_id) shape, size = resolve_shape_and_size(sensors, wafer_id)
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
self._sensors = WaferLayout(sensors, shape=shape, size=size) self._sensors = WaferLayout(sensors, shape=shape, size=size)
self._active_clusters = getattr(self._sensors, 'clusters', []) self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters: if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors) self._active_clusters = group_sensors_by_radius(self._sensors)
self._player.load(frames) self._player.load(frames)
self._model.reset() self._model.reset()
self._stats_tracker.reset()
self._loaded_file = file_path self._loaded_file = file_path
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
# Populate stats tracker from all loaded frames for trend chart (P3.1)
for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
@@ -309,7 +339,7 @@ class SessionController(QObject):
"""Clear the loaded file, resetting the player and frame states.""" """Clear the loaded file, resetting the player and frame states."""
self._player.load([]) self._player.load([])
self._model.reset() self._model.reset()
self._stats_tracker.reset() self._ec_pairs = ()
self._loaded_file = "" self._loaded_file = ""
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
@@ -659,6 +689,7 @@ class SessionController(QObject):
self.sensorsChanged.emit() self.sensorsChanged.emit()
except Exception as e: except Exception as e:
log.warning("Could not load layout for %s: %s", family_code, e) log.warning("Could not load layout for %s: %s", family_code, e)
self._ec_pairs = ec_pairs_for_wafer_id(family_code)
self._active_clusters = getattr(self._sensors, 'clusters', []) self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters: if not self._active_clusters:
@@ -689,10 +720,7 @@ class SessionController(QObject):
# Clear out any old data from the prev sessions # Clear out any old data from the prev sessions
self._model.reset() self._model.reset()
self._trend_buffer.clear() self._reset_live_trend()
self._trend_timestamps.clear()
self._trend_timer.start()
self._stats_tracker.reset()
self._last = None self._last = None
self._last_raw_frame = None self._last_raw_frame = None
self._received_count = 0 self._received_count = 0
@@ -734,7 +762,6 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def stopStream(self) -> None: def stopStream(self) -> None:
self._repaint_timer.stop() self._repaint_timer.stop()
self._trend_timer.stop()
self._received_count = 0 self._received_count = 0
self._error_count = 0 self._error_count = 0
self.liveStatsChanged.emit() self.liveStatsChanged.emit()
@@ -760,10 +787,8 @@ class SessionController(QObject):
@Slot(object) @Slot(object)
@slot_error_boundary @slot_error_boundary
def _on_live_frame(self, frame: Frame) -> None: def _on_live_frame(self, frame: Frame) -> None:
import json
self._received_count += 1 self._received_count += 1
self.liveStatsChanged.emit() self.liveStatsChanged.emit()
self._stats_tracker.track(frame.seq, frame.values)
# Main thread (queued). Record raw, process edited; mark dirty for repaint. # Main thread (queued). Record raw, process edited; mark dirty for repaint.
self._last_raw_frame = frame self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time, edited = Frame(seq=frame.seq, time=frame.time,
@@ -772,22 +797,11 @@ class SessionController(QObject):
if self._recorder.is_recording: if self._recorder.is_recording:
self._recorder.write(frame) # always record raw values, never edited self._recorder.write(frame) # always record raw values, never edited
self._dirty = True self._dirty = True
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
if self._last and self._last.stats: if self._last and self._last.stats:
avg = self._last.stats.avg avg = self._last.stats.avg
now = time.monotonic() elapsed = time.monotonic() - self._stream_start_time
self._trend_buffer.append(avg) self.trendDelta.emit(json.dumps([[elapsed, avg]]))
self._trend_timestamps.append(now)
# drop entries older than 60 secs
cutoff = now -60
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
self._trend_timestamps.pop(0)
self._trend_buffer.pop(0)
def _flush_repaint(self) -> None: def _flush_repaint(self) -> None:
if not self._dirty: if not self._dirty:
@@ -843,7 +857,8 @@ class SessionController(QObject):
self._sensor_editor.clear() self._sensor_editor.clear()
self._reprocess_current() self._reprocess_current()
def _flush_trend(self) -> None: def _reset_live_trend(self) -> None:
import json """Mark a fresh stream start for elapsed-time trend deltas."""
self.trendData.emit(json.dumps(self._trend_buffer)) self._stream_start_time = time.monotonic()
self.trendReset.emit()
+10
View File
@@ -37,6 +37,16 @@ class FileBrowser(QObject):
return str(self._current_directory) return str(self._current_directory)
# ===== Directory Selection ===== # ===== Directory Selection =====
@Slot(str)
def setCurrentDirectory(self, path: str) -> None:
"""Point the browser at `path` without opening a native dialog.
Used to keep this directory in sync with DeviceController.saveDataDir
when the user picks a save folder elsewhere in the app.
"""
self._set_current_directory(Path(path))
self._refresh_files(show_empty_message=False)
@Slot() @Slot()
def chooseDirectory(self) -> None: def chooseDirectory(self) -> None:
selected_dir = QFileDialog.getExistingDirectory( selected_dir = QFileDialog.getExistingDirectory(
+17
View File
@@ -8,6 +8,19 @@ def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
"""Build Frames from parsed DataRecords (records = list[DataRecord].)""" """Build Frames from parsed DataRecords (records = list[DataRecord].)"""
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)] return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
"""Per-sensor value series across every frame, for the whole-run Graph tab.
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
"""
if not frames:
return [], []
n_sensors = len(frames[0].values)
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
return names, series
class FramePlayer: class FramePlayer:
def __init__(self) -> None: def __init__(self) -> None:
self._frames: list[Frame] =[] self._frames: list[Frame] =[]
@@ -22,6 +35,10 @@ class FramePlayer:
def total(self) -> int: def total(self) -> int:
return len(self._frames) return len(self._frames)
@property
def frames(self) -> tuple[Frame, ...]:
return tuple(self._frames)
@property @property
def index(self) -> int: def index(self) -> int:
return self._i return self._i
+38
View File
@@ -14,6 +14,44 @@ class Stats:
sigma: float; three_sigma: float sigma: float; three_sigma: float
@dataclass(frozen=True)
class EdgeCenterStats:
min_edge_index: int; min_center_index: int
min_edge: float; min_center: float; min_delta: float
max_edge_index: int; max_center_index: int
max_edge: float; max_center: float; max_delta: float
def compute_edge_center(
values: list[float],
pairs: tuple[tuple[int, int], ...],
excluded: set[int],
) -> EdgeCenterStats | None:
"""Min/max |edge - center| over the family's pair table.
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
NaN value are skipped. Returns None when no pair is computable.
"""
n = len(values)
best: list[tuple[float, int, int]] = []
for e, c in pairs:
if e in excluded or c in excluded or e >= n or c >= n:
continue
if math.isnan(values[e]) or math.isnan(values[c]):
continue
best.append((abs(values[e] - values[c]), e, c))
if not best:
return None
min_d, min_e, min_c = min(best)
max_d, max_e, max_c = max(best)
return EdgeCenterStats(
min_edge_index=min_e, min_center_index=min_c,
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
max_edge_index=max_e, max_center_index=max_c,
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
)
def compute_stats(values: list[float]) -> Stats: def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)] clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean: if not clean:
@@ -28,6 +28,11 @@ class SensorEditor:
def has_overrides(self) -> bool: def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets) return bool(self._replacements or self._offsets)
def replaced_indices(self) -> set[int]:
"""Sensors whose value is a replacement (offsets don't count — an
offset sensor still reads from its physical location)."""
return set(self._replacements)
def active_indices(self) -> list[int]: def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override.""" """Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets)) return sorted(set(self._replacements) | set(self._offsets))
@@ -31,3 +31,22 @@ def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: flo
return pixel_start + pixel_span / 2 return pixel_start + pixel_span / 2
fraction = index / (count - 1) fraction = index / (count - 1)
return pixel_start + fraction * pixel_span return pixel_start + fraction * pixel_span
def elapsed_to_pixel(
elapsed: float,
window_start: float,
window_end: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map an elapsed-seconds value onto a pixel range, left to right.
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
at larger pixel x-coordinates, matching how time flows left-to-right.
"""
window_span = window_end - window_start
if window_span < 1e-9:
return pixel_start + pixel_span
fraction = (elapsed - window_start) / window_span
return pixel_start + fraction * pixel_span
@@ -21,7 +21,9 @@ Usage from QML::
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal import math
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
@@ -32,6 +34,47 @@ QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
def viewport_stats(
series: list[list[float]],
names: list[str],
start: int,
end: int,
) -> tuple[float, float, float, str, str]:
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
every visible point from every series, plus which sensor achieved each
extreme. Non-numeric values are skipped. No points in range -> zeros and
empty sensor names.
"""
vmin = math.inf
vmax = -math.inf
total = 0.0
count = 0
min_sensor = ""
max_sensor = ""
for i, s in enumerate(series):
name = names[i] if i < len(names) else ""
lo = max(0, start)
hi = min(len(s) - 1, end)
for j in range(lo, hi + 1):
try:
v = float(s[j])
except (TypeError, ValueError):
continue
if v < vmin:
vmin = v
min_sensor = name
if v > vmax:
vmax = v
max_sensor = name
total += v
count += 1
if count == 0:
return (0.0, 0.0, 0.0, "", "")
return (vmin, vmax, total / count, min_sensor, max_sensor)
@QmlElement @QmlElement
class GraphQuickItem(QQuickPaintedItem): class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings.""" """Painted line chart; driven by series data passed via QML property bindings."""
@@ -42,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
xLabelChanged = Signal() xLabelChanged = Signal()
yLabelChanged = Signal() yLabelChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
viewportChanged = Signal()
viewStatsChanged = Signal()
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -74,6 +119,18 @@ class GraphQuickItem(QQuickPaintedItem):
self._max_y: float = 150.0 self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50} self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
# so the Graph tab (which never sets these) is unaffected. See
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
self._view_start: int = 0
self._view_end: int = -1
self._show_min_max_markers: bool = False
self._view_min: float = 0.0
self._view_max: float = 0.0
self._view_avg: float = 0.0
self._view_min_sensor: str = ""
self._view_max_sensor: str = ""
# ── Qt properties (QML-bindable) ────────────────────────────────────────── # ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged) @Property("QVariantList", notify=seriesDataChanged)
@@ -83,7 +140,16 @@ class GraphQuickItem(QQuickPaintedItem):
@seriesData.setter # type: ignore[no-redef] @seriesData.setter # type: ignore[no-redef]
def seriesData(self, val: list) -> None: def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])] self._series_data = [list(s) for s in (val or [])]
# New data resets the viewport to the full range -- matches
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
# before RecalculateAxesScale(). Without this, a stale viewport left
# over from a previous (longer) series can point entirely past the
# new data -- every per-series slice becomes empty and the chart
# silently renders nothing instead of the new data.
self._view_start = 0
self._view_end = -1
self._auto_range() self._auto_range()
self._recompute_view_stats()
self.seriesDataChanged.emit() self.seriesDataChanged.emit()
self.update() self.update()
@@ -94,9 +160,130 @@ class GraphQuickItem(QQuickPaintedItem):
@sensorNames.setter # type: ignore[no-redef] @sensorNames.setter # type: ignore[no-redef]
def sensorNames(self, val: list) -> None: def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or []) self._sensor_names = list(val or [])
self._recompute_view_stats()
self.sensorNamesChanged.emit() self.sensorNamesChanged.emit()
self.update() self.update()
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
@Property(int, notify=viewportChanged)
def viewStartIndex(self) -> int:
return self._view_start
@viewStartIndex.setter # type: ignore[no-redef]
def viewStartIndex(self, val: int) -> None:
v = max(0, int(val))
if v == self._view_start:
return
self._view_start = v
self._on_viewport_changed()
@Property(int, notify=viewportChanged)
def viewEndIndex(self) -> int:
return self._view_end
@viewEndIndex.setter # type: ignore[no-redef]
def viewEndIndex(self, val: int) -> None:
v = int(val)
if v == self._view_end:
return
self._view_end = v
self._on_viewport_changed()
@Property(bool, notify=viewportChanged)
def showMinMaxMarkers(self) -> bool:
return self._show_min_max_markers
@showMinMaxMarkers.setter # type: ignore[no-redef]
def showMinMaxMarkers(self, val: bool) -> None:
v = bool(val)
if v == self._show_min_max_markers:
return
self._show_min_max_markers = v
self.viewportChanged.emit()
self.update()
@Property(float, notify=viewStatsChanged)
def viewMin(self) -> float:
return self._view_min
@Property(float, notify=viewStatsChanged)
def viewMax(self) -> float:
return self._view_max
@Property(float, notify=viewStatsChanged)
def viewAvg(self) -> float:
return self._view_avg
@Property(str, notify=viewStatsChanged)
def viewMinSensor(self) -> str:
return self._view_min_sensor
@Property(str, notify=viewStatsChanged)
def viewMaxSensor(self) -> str:
return self._view_max_sensor
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
# with fractions derived from wheel/drag pixel deltas) ───────────────
@Slot(float, float)
def zoomAtFraction(self, frac: float, factor: float) -> None:
"""Zoom the viewport around a fractional X position within it.
`frac` in [0, 1] is where within the current viewport to zoom around;
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
(zoom out). Clamped to data bounds and a minimum 1-index window.
"""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
f = max(0.0, min(1.0, frac))
center = start + f * width
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
new_start = center - f * new_width
new_end = new_start + new_width
self._set_viewport_clamped(new_start, new_end, max_len)
@Slot(float)
def panByFraction(self, delta_frac: float) -> None:
"""Shift the viewport by `delta_frac * window_width` indices."""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
shift = delta_frac * width
self._set_viewport_clamped(start + shift, end + shift, max_len)
@Slot()
def resetZoom(self) -> None:
"""Restore the full-range viewport sentinel (0, -1)."""
self._set_viewport(0, -1)
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
if new_start < 0.0:
new_end -= new_start
new_start = 0.0
if new_end > max_len - 1:
new_start -= new_end - (max_len - 1)
new_end = float(max_len - 1)
new_start = max(0.0, new_start)
self._set_viewport(int(round(new_start)), int(round(new_end)))
def _set_viewport(self, start: int, end: int) -> None:
"""Set the viewport bounds directly (not through the QML-facing
property setters, so this stays a plain attribute write for mypy)."""
new_start = max(0, start)
if new_start == self._view_start and end == self._view_end:
return
self._view_start = new_start
self._view_end = end
self._on_viewport_changed()
@Property(str, notify=titleChanged) @Property(str, notify=titleChanged)
def title(self) -> str: def title(self) -> str:
return self._title return self._title
@@ -194,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
# ── internal ────────────────────────────────────────────────────────────── # ── internal ──────────────────────────────────────────────────────────────
def _resolved_view_end(self) -> int:
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
max_len = max((len(s) for s in self._series_data), default=0)
if self._view_end < 0 or self._view_end > max_len - 1:
return max_len - 1
return self._view_end
def _on_viewport_changed(self) -> None:
self._auto_range()
self._recompute_view_stats()
self.viewportChanged.emit()
self.update()
def _recompute_view_stats(self) -> None:
end = self._resolved_view_end()
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
self._series_data, self._sensor_names, self._view_start, end
)
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
self.viewStatsChanged.emit()
def _auto_range(self) -> None: def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding.""" """Set Y range to cover the current viewport's data with 10% padding."""
start = max(0, self._view_start)
end = self._resolved_view_end()
all_vals: list[float] = [] all_vals: list[float] = []
for series in self._series_data: for series in self._series_data:
for v in series: hi = min(len(series) - 1, end)
for i in range(start, hi + 1):
try: try:
all_vals.append(float(v)) all_vals.append(float(series[i]))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not all_vals: if not all_vals:
@@ -319,18 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
self._x_label, self._x_label,
) )
# --- X-axis ticks --- # --- Viewport slice (replay chart zoom/pan; full range by default,
num_points = max(len(s) for s in self._series_data) if self._series_data else 0 # so unset viewport reproduces the pre-viewport rendering) ---
if num_points > 1: view_start = max(0, self._view_start)
n_x_ticks = min(num_points, max(2, int(plot_w // 80))) view_end = self._resolved_view_end()
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1) view_len = max(1, view_end - view_start + 1)
# --- X-axis ticks (labelled in original, non-sliced index space) ---
if view_len > 1:
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks): for i in range(n_x_ticks):
idx = int(round(i * x_tick_step)) rel_idx = int(round(i * x_tick_step))
x_px = index_to_pixel(idx, num_points, plot_left, plot_w) x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
painter.setPen(axis_pen) painter.setPen(axis_pen)
painter.drawText( painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14), int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1), str(view_start + rel_idx + 1),
) )
# --- Axes boundary --- # --- Axes boundary ---
@@ -339,28 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length --- # --- Draw each series (sliced to the current viewport) ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0 min_pt: tuple[float, float] | None = None
if max_len < 1: max_pt: tuple[float, float] | None = None
return min_seen = math.inf
max_seen = -math.inf
# --- Draw each series ---
for si, series in enumerate(self._series_data): for si, series in enumerate(self._series_data):
if len(series) < 1: hi = min(len(series) - 1, view_end)
sliced = series[view_start:hi + 1] if view_start <= hi else []
if not sliced:
continue continue
color = self._series_colors[si % len(self._series_colors)] color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2) pen = QPen(color, 2)
painter.setPen(pen) painter.setPen(pen)
pts: list[tuple[float, float]] = [] pts: list[tuple[float, float]] = []
for i, val in enumerate(series): for i, val in enumerate(sliced):
try: try:
v = float(val) v = float(val)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
x_px = index_to_pixel(i, max_len, plot_left, plot_w) x_px = index_to_pixel(i, view_len, plot_left, plot_w)
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h) y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
pts.append((x_px, y_px)) pts.append((x_px, y_px))
if self._show_min_max_markers:
if v < min_seen:
min_seen = v
min_pt = (x_px, y_px)
if v > max_seen:
max_seen = v
max_pt = (x_px, y_px)
if len(pts) < 2: if len(pts) < 2:
if len(pts) == 1: if len(pts) == 1:
@@ -373,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
int(pts[i + 1][0]), int(pts[i + 1][1]), int(pts[i + 1][0]), int(pts[i + 1][1]),
) )
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
if self._show_min_max_markers:
marker_font = QFont()
marker_font.setPixelSize(14)
marker_font.setBold(True)
painter.setFont(marker_font)
if max_pt is not None:
painter.setPen(QPen(QColor("#FF5757")))
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "")
if min_pt is not None:
painter.setPen(QPen(QColor("#42A5F5")))
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "")
# --- Legend --- # --- Legend ---
if self._show_legend and self._sensor_names: if self._show_legend and self._sensor_names:
legend_font = QFont() legend_font = QFont()
@@ -1,18 +1,20 @@
"""QQuickPaintedItem trend chart — running-average temperature over time. """QQuickPaintedItem trend chart — running-average temperature over time.
Renders a single polyline series (per-frame average temperatures) using QPainter. Renders a single polyline series (per-frame average temperatures) over a fixed
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can 0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`. rolling window. Mirrors the @QmlElement registration pattern used by
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
and used as `TrendChartItem { }`.
The series is driven by the existing `streamController.trendData` signal, which The series is driven by `streamController.trendDelta` (one new
emits a JSON-encoded list of floats from both review-mode replay and live-mode `[elapsed_s, value]` point per live frame) and cleared on
streaming. `streamController.trendReset` (new stream started). See
docs/adr/0001-trend-chart-axes-standardization.md.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import math
from typing import Optional from typing import Optional
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
@@ -20,19 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
WINDOW_SECONDS = 60.0
Y_MIN = 0.0
Y_MAX = 200.0
Y_TICKS = (0, 40, 80, 120, 160, 200)
@QmlElement @QmlElement
class TrendChartItem(QQuickPaintedItem): class TrendChartItem(QQuickPaintedItem):
"""Painted trend chart; driven by a list of floats via the `data` property.""" """Painted trend chart; driven by incremental (elapsed_s, value) points."""
dataChanged = Signal()
hasDataChanged = Signal() hasDataChanged = Signal()
lineColorChanged = Signal() lineColorChanged = Signal()
gridColorChanged = Signal() gridColorChanged = Signal()
@@ -45,40 +51,29 @@ class TrendChartItem(QQuickPaintedItem):
self.setAntialiasing(True) self.setAntialiasing(True)
self.setFillColor(QColor("transparent")) self.setFillColor(QColor("transparent"))
self._data: list[float] = [] self._points: list[tuple[float, float]] = []
self._padding: int = 8 self._padding: int = 8
# Separate from `_padding` (breathing room around the plot area): these # Separate from `_padding` (breathing room around the plot area): these
# reserve space for the axis labels themselves, which are wider than # reserve space for the axis labels themselves, which are wider than
# `_padding` alone — that mismatch is what clipped y-axis text before. # `_padding` alone — that mismatch is what clipped y-axis text before.
self._margin_left: int = 34 self._margin_left: int = 48
self._margin_bottom: int = 16 self._margin_bottom: int = 30
# Top tick label is drawn centered on the plot's top edge (y_px - 7),
# so the plot needs headroom or the label kisses the card border.
self._margin_top: int = 10
self._line_color = QColor("#5B9DF5") self._line_color = QColor("#5B9DF5")
self._grid_color = QColor("#2A3441") self._grid_color = QColor("#2A3441")
self._text_color = QColor("#CBD5E1") self._text_color = QColor("#CBD5E1")
# ── Qt properties ───────────────────────────────────────────────────── # ── Qt properties ─────────────────────────────────────────────────────
@Property("QVariantList", notify=dataChanged)
def data(self) -> list[float]:
return self._data
@data.setter # type: ignore[no-redef]
def data(self, val) -> None:
coerced = self._coerce_floats(val)
if coerced == self._data:
return
self._data = coerced
self.dataChanged.emit()
self.hasDataChanged.emit()
self.update()
@Property(bool, notify=hasDataChanged) @Property(bool, notify=hasDataChanged)
def hasData(self) -> bool: def hasData(self) -> bool:
"""True when the data list has at least one point. """True when at least one point is buffered.
QML can bind to this safely (QVariantList `.length` is not bindable). QML can bind to this safely (a plain list length is not bindable).
""" """
return len(self._data) > 0 return len(self._points) > 0
@Property(QColor, notify=lineColorChanged) @Property(QColor, notify=lineColorChanged)
def lineColor(self) -> QColor: def lineColor(self) -> QColor:
@@ -129,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
self.paddingChanged.emit() self.paddingChanged.emit()
self.update() self.update()
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ── # ── slots for QML: streamController.trendDelta / trendReset ────────────
@Slot(str) @Slot(str)
def setDataFromJson(self, avgs_json: str) -> None: def appendDelta(self, delta_json: str) -> None:
"""Slot for QML Connections handler: parse JSON array and update data. """Append new (elapsed_s, value) points and prune the 60s window.
Mirrors `streamController.trendData` (which emits JSON-encoded floats). Mirrors `streamController.trendDelta`, which emits one new pair per
live frame as `[[elapsed_s, value], ...]`.
""" """
try: try:
parsed = json.loads(avgs_json) if avgs_json else [] parsed = json.loads(delta_json) if delta_json else []
except (json.JSONDecodeError, TypeError) as exc: except (json.JSONDecodeError, TypeError) as exc:
log.error("TrendChartItem: failed to parse trend JSON: %s", exc) log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
return return
coerced = self._coerce_floats(parsed)
if coerced == self._data: had_data = len(self._points) > 0
added = False
for pair in parsed:
try:
elapsed, value = float(pair[0]), float(pair[1])
except (TypeError, ValueError, IndexError):
continue
self._points.append((elapsed, value))
added = True
if not added:
return return
self._data = coerced
self.dataChanged.emit() latest_elapsed = self._points[-1][0]
cutoff = latest_elapsed - WINDOW_SECONDS
self._points = [p for p in self._points if p[0] >= cutoff]
if not had_data:
self.hasDataChanged.emit()
self.update()
@Slot()
def clearTrend(self) -> None:
"""Clear all buffered points (new stream started)."""
if not self._points:
return
self._points = []
self.hasDataChanged.emit() self.hasDataChanged.emit()
self.update() self.update()
@@ -162,58 +181,38 @@ class TrendChartItem(QQuickPaintedItem):
return return
left = self._padding + self._margin_left left = self._padding + self._margin_left
top = self._padding + self._margin_top
bottom_inset = self._padding + self._margin_bottom bottom_inset = self._padding + self._margin_bottom
plot_rect = QRectF(left, self._padding, plot_rect = QRectF(left, top,
max(1, w - left - self._padding), max(1, w - left - self._padding),
max(1, h - self._padding - bottom_inset)) max(1, h - top - bottom_inset))
self._draw_grid(painter, plot_rect) self._draw_grid(painter, plot_rect)
if len(self._data) < 2: if len(self._points) < 2:
return return
y_min, y_max = self._y_range() window_end = self._points[-1][0]
self._draw_axes_labels(painter, plot_rect, y_min, y_max) window_start = max(0.0, window_end - WINDOW_SECONDS)
self._draw_line(painter, plot_rect, y_min, y_max) self._draw_axes_labels(painter, plot_rect, window_start, window_end)
self._draw_line(painter, plot_rect, window_start, window_end)
# ── internals ───────────────────────────────────────────────────────── # ── internals ─────────────────────────────────────────────────────────
def _coerce_floats(self, val) -> list[float]: def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
if not val: return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
return []
out: list[float] = []
for v in val:
try:
out.append(float(v))
except (TypeError, ValueError):
continue
return out
def _y_range(self) -> tuple[float, float]: def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
lo = min(self._data) px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
hi = max(self._data) # Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
if hi - lo < 1e-9: return max(plot_rect.top(), min(plot_rect.bottom(), px))
lo, hi = lo - 5.0, hi + 5.0
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
# while streaming and only moves when data crosses a step boundary —
# raw min/max rescaled every frame, which read as an unstable chart.
raw_step = (hi - lo) / 4
mag = 10 ** math.floor(math.log10(raw_step))
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
return math.floor(lo / step) * step, math.ceil(hi / step) * step
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
return index_to_pixel(i, n, plot_rect.left(), plot_rect.width())
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
return value_to_pixel(v, y_min, y_max, plot_rect.top(), plot_rect.height())
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
pen = QPen(self._grid_color) pen = QPen(self._grid_color)
pen.setWidthF(1.0) pen.setWidthF(1.0)
pen.setCosmetic(True) pen.setCosmetic(True)
painter.setPen(pen) painter.setPen(pen)
rows = 4 rows = len(Y_TICKS) - 1
for i in range(rows + 1): for i in range(rows + 1):
y = plot_rect.top() + (i / rows) * plot_rect.height() y = plot_rect.top() + (i / rows) * plot_rect.height()
painter.drawLine(int(plot_rect.left()), int(y), painter.drawLine(int(plot_rect.left()), int(y),
@@ -223,36 +222,29 @@ class TrendChartItem(QQuickPaintedItem):
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
painter.setPen(self._text_color) painter.setPen(self._text_color)
font = QFont(painter.font()) font = QFont(painter.font())
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
painter.setFont(font) painter.setFont(font)
rows = 4 title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
label_w = self._margin_left - 4 # 4px gutter before the plot area label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
for i in range(rows + 1): for tick in reversed(Y_TICKS):
t = i / rows y_px = self._y_to_px(tick, plot_rect)
y_val = y_max - t * (y_max - y_min) painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
y_px = plot_rect.top() + t * plot_rect.height()
label = f"{y_val:g}"
painter.drawText(QRectF(0, y_px - 7, label_w, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label) str(tick))
# X-axis: sample index. `trendData` carries plain averages with no # X-axis: elapsed seconds within the current rolling window.
# timestamps attached, so index is the only x-coordinate available — cols = 4
# it reads correctly for review mode (index == frame number) and as a
# relative recency order for the live rolling window. paint() already
# guarantees len(self._data) >= 2 before calling this.
n = len(self._data)
cols = min(4, n - 1)
y_px = plot_rect.bottom() + 4 y_px = plot_rect.bottom() + 4
for i in range(cols + 1): for i in range(cols + 1):
idx = round((i / cols) * (n - 1)) t_val = window_start + (i / cols) * (window_end - window_start)
x_px = self._x_to_px(idx, n, plot_rect) x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
label = f"{t_val:.0f}"
if i == 0: if i == 0:
rect = QRectF(x_px, y_px, 40, 14) rect = QRectF(x_px, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignLeft align = Qt.AlignmentFlag.AlignLeft
@@ -263,16 +255,36 @@ class TrendChartItem(QQuickPaintedItem):
else: else:
rect = QRectF(x_px - 20, y_px, 40, 14) rect = QRectF(x_px - 20, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignHCenter align = Qt.AlignmentFlag.AlignHCenter
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx)) painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
def _draw_axis_titles(
self,
painter: QPainter,
plot_rect: QRectF,
title_w: float,
x_label_bottom: float,
) -> None:
# Y axis: "Temp", rotated upright, centered along the plot's height.
painter.save()
painter.translate(title_w / 2, plot_rect.center().y())
painter.rotate(-90)
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
painter.restore()
# X axis: "Time", centered under the elapsed-seconds tick row.
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
def _draw_line( def _draw_line(
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
n = len(self._data)
pen = QPen(self._line_color) pen = QPen(self._line_color)
pen.setWidthF(2.0) pen.setWidthF(2.0)
pen.setCosmetic(True) pen.setCosmetic(True)
@@ -281,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
painter.setPen(pen) painter.setPen(pen)
poly = QPolygon() poly = QPolygon()
for i, v in enumerate(self._data): for elapsed, value in self._points:
px = self._x_to_px(i, n, plot_rect) px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
py = self._y_to_px(v, y_min, y_max, plot_rect) py = self._y_to_px(value, plot_rect)
poly.append(QPoint(int(px), int(py))) poly.append(QPoint(int(px), int(py)))
painter.drawPolyline(poly) painter.drawPolyline(poly)
# Trailing dot at the last data point # Trailing dot at the last data point
last_x = int(self._x_to_px(n - 1, n, plot_rect)) last_elapsed, last_value = self._points[-1]
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect)) last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
last_y = int(self._y_to_px(last_value, plot_rect))
painter.setBrush(self._line_color) painter.setBrush(self._line_color)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
radius = 3 radius = 3
+135 -18
View File
@@ -14,7 +14,7 @@ import logging
import math import math
import numpy as np import numpy as np
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import ( from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
@@ -27,6 +27,7 @@ from PySide6.QtGui import (
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.models.frame_stats import compute_stats
from pygui.backend.visualization.rbf_heatmap import ( from pygui.backend.visualization.rbf_heatmap import (
ellipse_alpha, ellipse_alpha,
interpolate_field, interpolate_field,
@@ -36,6 +37,59 @@ from pygui.backend.wafer.zwafer_models import Sensor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
Returns (points, error) error is "" on success.
"""
import csv
points: list[list[float]] = []
slot: str | None = None
try:
with open(file_path, newline="") as fh:
reader = csv.reader(fh)
header = next(reader, None)
if header is None:
return [], "Unable to read header row from CSV file."
if header and header[-1] == "": # trailing comma
header = header[:-1]
nfields = len(header)
if nfields != 8:
return [], "Incorrect field count in CSV header row."
for lno, row in enumerate(reader, start=2):
if row and row[-1] == "":
row = row[:-1]
if len(row) != nfields:
return [], f"Incorrect field count in CSV row {lno}"
if slot is None:
slot = row[3]
elif row[3] != slot:
break
try:
t2, x, y = float(row[4]), float(row[6]), float(row[7])
except ValueError:
return [], f"Malformed value in CSV row {lno}"
points.append([x, y, t2])
except OSError as exc:
return [], f"Unable to read CSV file: {exc}"
if not points:
return [], "No data found in CSV file."
return points, ""
def readout_text(values: list[float]) -> str:
"""Full READOUT stats line for the export footer; '' when no data."""
if not values:
return ""
s = compute_stats(values)
return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) "
f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} "
f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}")
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
@@ -51,6 +105,7 @@ class WaferMapItem(QQuickPaintedItem):
marginChanged = Signal() marginChanged = Signal()
blendChanged = Signal() blendChanged = Signal()
showLabelsChanged = Signal() showLabelsChanged = Signal()
showExtremesChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
shapeChanged = Signal() shapeChanged = Signal()
sizeChanged = Signal() sizeChanged = Signal()
@@ -69,9 +124,10 @@ class WaferMapItem(QQuickPaintedItem):
self._margin: float = 1.0 self._margin: float = 1.0
self._blend: float = 0.0 self._blend: float = 0.0
self._show_labels: bool = True self._show_labels: bool = True
self._show_extremes: bool = True
self._shape: str = "round" self._shape: str = "round"
self._size: float = 300.0 self._size: float = 300.0
self._thickness_data: list[float] = [] self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
self._show_thickness: bool = False self._show_thickness: bool = False
self._thickness_heatmap:QImage | None = None self._thickness_heatmap:QImage | None = None
# Hover highlight: index of the marker under the pointer (-1 = none) # Hover highlight: index of the marker under the pointer (-1 = none)
@@ -188,6 +244,16 @@ class WaferMapItem(QQuickPaintedItem):
self.showLabelsChanged.emit() self.showLabelsChanged.emit()
self.update() self.update()
@Property(bool, notify=showExtremesChanged)
def showExtremes(self) -> bool:
return self._show_extremes
@showExtremes.setter # type: ignore[no-redef]
def showExtremes(self, val: bool) -> None:
self._show_extremes = bool(val)
self.showExtremesChanged.emit()
self.update()
@Property(str, notify=shapeChanged) @Property(str, notify=shapeChanged)
def shape(self) -> str: def shape(self) -> str:
return self._shape return self._shape
@@ -210,13 +276,31 @@ class WaferMapItem(QQuickPaintedItem):
@Property("QVariantList", notify=thicknessChanged) @Property("QVariantList", notify=thicknessChanged)
def thicknessData(self) -> list: def thicknessData(self) -> list:
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
return self._thickness_data return self._thickness_data
@thicknessData.setter # type: ignore[no-redef] @thicknessData.setter # type: ignore[no-redef]
def thicknessData(self, val:list) -> None: def thicknessData(self, val:list) -> None:
self._thickness_data = list(val or []) self._thickness_data = [list(p) for p in (val or [])]
self._rebuild_thickness() self._rebuild_thickness()
self.thicknessChanged.emit() self.thicknessChanged.emit()
self.update()
@Property(bool, notify=thicknessChanged)
def hasThickness(self) -> bool:
return bool(self._thickness_data)
@Slot(str, result=str)
def loadThickness(self, file_path: str) -> str:
"""Load a customer thickness CSV; returns error message, '' on success."""
points, err = parse_thickness_csv(file_path)
if err:
return err
self._thickness_data = points
self._rebuild_thickness()
self.thicknessChanged.emit()
self.update()
return ""
self.update self.update
@Property(bool, notify=showThicknessChanged) @Property(bool, notify=showThicknessChanged)
@@ -301,41 +385,73 @@ class WaferMapItem(QQuickPaintedItem):
# chosen output dir, plus an optional summary CSV of (file, min/max/mean). # chosen output dir, plus an optional summary CSV of (file, min/max/mean).
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1. # No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
@Slot(str, result=bool) @Slot(str, result=bool)
def export_image(self, file_path: str) -> bool: @Slot(str, str, result=bool)
def export_image(self, file_path: str, extra: str = "") -> bool:
"""Export the current wafer map rendering to a PNG file """Export the current wafer map rendering to a PNG file
Args: Args:
file_pat: Absolute path for the output PNG. file_path: Absolute path for the output PNG.
extra: Optional second footer line (live stream metrics).
Returns: Returns:
True on success, False on failure True on success, False on failure
""" """
if not file_path: if not file_path or not self._values:
# No file loaded / no stream played yet — only the empty template
# would render, which isn't a meaningful export.
return False return False
try: try:
result = self.grabToImage() # Render synchronously via paint() into our own image —
img = result.image() # type: ignore[attr-defined] # grabToImage() resolves on a later render frame, so its result
img.save(file_path, "PNG") # is null when read immediately (the old silent failure).
return True w, h = int(self.width()), int(self.height())
if w <= 0 or h <= 0:
return False
lines = [ln for ln in (readout_text(self._values), extra) if ln]
footer_h = 28 * len(lines)
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
img.fill(0) # transparent background
painter = QPainter(img)
self.paint(painter)
if lines:
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
painter.setPen(QColor("#CBD5E1"))
for i, line in enumerate(lines):
painter.drawText(QRectF(0, h + 28 * i, w, 28),
Qt.AlignmentFlag.AlignCenter, line)
painter.end()
return bool(img.save(file_path))
except Exception as e: except Exception as e:
log.error("Export failed: %s", e) log.error("Export failed: %s", e)
return False return False
# ── internal ───────────────────────────────────────────────────────── # ── internal ─────────────────────────────────────────────────────────
def _paint_extremes(self, painter: QPainter) -> None:
"""Ring the hottest (high color) and coldest (low color) markers."""
if not self._show_extremes or not self._values or not self._markers:
return
s = compute_stats(self._values)
ring_r = self._marker_r + 5
painter.setBrush(Qt.BrushStyle.NoBrush)
for idx, color in ((s.max_index, self._high_color),
(s.min_index, self._low_color)):
if idx not in self._markers:
continue
px, py = self._markers[idx]
painter.setPen(QPen(color, 2))
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
def _rebuild_thickness(self) -> None: def _rebuild_thickness(self) -> None:
"""Interpolate thickness data into a gray/orange heatmap QImage.""" """Interpolate thickness points into a gray/orange heatmap QImage."""
if not self._sensors or not self._thickness_data: if not self._thickness_data:
self._thickness_heatmap = None self._thickness_heatmap = None
return return
ds = self._draw_size() ds = self._draw_size()
r_mm = self._wafer_radius_mm() r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors]) pts = np.array(self._thickness_data, dtype=float)
ys = np.array([s.y for s in self._sensors]) xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._thickness_heatmap = None
return
try: try:
# No round_clip — see _rebuild_heatmap for why the mask is analytic. # No round_clip — see _rebuild_heatmap for why the mask is analytic.
field = interpolate_field( field = interpolate_field(
@@ -554,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
painter.setOpacity(1.0) painter.setOpacity(1.0)
self._paint_markers(painter) self._paint_markers(painter)
self._paint_extremes(painter)
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None: def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
ds = self._draw_size() ds = self._draw_size()
+29 -6
View File
@@ -70,12 +70,35 @@ def _load_yaml(path: Path) -> dict:
return loaded return loaded
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser) # Edge→center sensor pair tables, ported verbatim from the C# original
# THINKING: this already returns everything the UI needs (family names) — # (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
# the missing piece is a context-property exposure (SessionController or a # ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
# thin new property) and a setLayout(family) slot that calls load_layout() _EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
# below. No parsing work needed; this is a wiring-only task. "AEP": tuple((e, 40 + e // 2) for e in range(16)),
# See docs/pending/alpha-release-polish-plan.md §6.3. "BCD": tuple((e, 28) for e in range(12)),
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
"Z": tuple((e, 0) for e in range(49, 65)),
}
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
Unknown families deliberately get no pairs (C# fell through to Z).
"""
prefix = wafer_id[0].upper() if wafer_id else ""
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
("X", "X"), ("Z", "Z")):
if prefix and prefix in families:
return _EC_MAPS[key]
return ()
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
def available_families() -> list[str]: def available_families() -> list[str]:
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")] return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
+69
View File
@@ -107,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
return result return result
def _convert_debug_temp(hex_str: str) -> float:
"""Convert a 4-char hex string to a debug/cold-junction temperature.
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
2**(i-8), sign bit at bit 0. No family-code branching this formula
applies regardless of wafer family, unlike _convert_hex_to_temp.
"""
bits = _hex_to_binary(hex_str)
value = 0.0
for i in range(4, 15):
if bits[i]:
value += 2.0 ** (i - 8)
if bits[0]:
value = -value
return round(value, 2)
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float: def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
"""Convert a singi hale 4-char hex string to a float temperature.""" """Convert a singi hale 4-char hex string to a float temperature."""
bits = _hex_to_binary(hex_str) bits = _hex_to_binary(hex_str)
@@ -225,6 +242,15 @@ def convert_to_temperatures(
return temp_value return temp_value
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
"""Convert debug/cold-junction hex values to temperature strings.
Same shape contract as convert_to_temperatures, but uses the
family-independent debug formula (_convert_debug_temp).
"""
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
def remove_trailing_zeros(data: list[list[str]]) -> None: def remove_trailing_zeros(data: list[list[str]]) -> None:
"""Remove rows of all-zero values from the end of data (in-place). """Remove rows of all-zero values from the end of data (in-place).
@@ -294,3 +320,46 @@ def save_to_csv(
except Exception as exc: except Exception as exc:
log.error("CSV save failed: %s", exc) log.error("CSV save failed: %s", exc)
return None return None
def save_debug_csv(
temp_data: list[list[str]],
debug_data: list[list[str]],
output_dir: str,
) -> Optional[str]:
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
truncated to the shorter of the two row/column counts. Fixed filename
(not timestamped), unlike save_to_csv.
Args:
temp_data: 2D array of sensor temperature strings (D1, decoded via
convert_to_temperatures).
debug_data: 2D array of debug temperature strings (F1, decoded via
convert_to_debug_temperatures).
output_dir: Directory to save the CSV file.
Returns:
Full file path on success, None on empty input or write failure.
"""
if not temp_data or not debug_data:
log.error("Debug CSV save failed: temp or debug data is empty")
return None
try:
os.makedirs(output_dir, exist_ok=True)
num_cols = min(len(temp_data[0]), len(debug_data[0]))
num_rows = min(len(temp_data), len(debug_data))
filepath = os.path.join(output_dir, "debug_info.csv")
with open(filepath, "w", encoding="utf-8") as f:
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
f.write(",".join(headers) + "\n")
for i in range(num_rows):
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
f.write(",".join(row) + "\n")
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
return filepath
except Exception as exc:
log.error("Debug CSV save failed: %s", exc)
return None
+25 -1
View File
@@ -1,6 +1,10 @@
import pytest import pytest
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel from pygui.backend.visualization.chart_geometry import (
elapsed_to_pixel,
index_to_pixel,
value_to_pixel,
)
def test_value_to_pixel_min_maps_to_bottom(): def test_value_to_pixel_min_maps_to_bottom():
@@ -26,3 +30,23 @@ def test_index_to_pixel_first_and_last():
def test_index_to_pixel_single_point_centers(): def test_index_to_pixel_single_point_centers():
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0) assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
def test_elapsed_to_pixel_window_start_maps_to_left():
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
def test_elapsed_to_pixel_window_end_maps_to_right():
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
def test_elapsed_to_pixel_midpoint():
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_elapsed_to_pixel_degenerate_window_pins_right():
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
+97
View File
@@ -3,11 +3,14 @@
import pytest import pytest
from pygui.serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
_convert_debug_temp,
_convert_hex_to_temp, _convert_hex_to_temp,
convert_to_debug_temperatures,
convert_to_temperatures, convert_to_temperatures,
csv_column_count, csv_column_count,
parse_binary_data, parse_binary_data,
remove_trailing_zeros, remove_trailing_zeros,
save_debug_csv,
save_to_csv, save_to_csv,
) )
@@ -321,3 +324,97 @@ class TestSaveToCsv:
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}" assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}" assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
assert data_row[243] == "243.0", "last column value must be preserved" assert data_row[243] == "243.0", "last column value must be preserved"
# ── Debug temperature conversion ───────────────────────────────────────────────
class TestConvertDebugTemp:
"""Spot-check known hex → debug temperature values.
Reference values derived from C#'s ConvertBinaryArrayToDebugTemp
(bits 4-14 scaled by 2**(i-8), sign at bit 0 no family branching).
"""
def test_zero_hex_gives_zero(self):
assert _convert_debug_temp("0000") == 0.0
def test_bit8_gives_one(self):
# bit index 8 set (value 0x0080) → contributes 2**(8-8) = 1.0
assert _convert_debug_temp("0080") == pytest.approx(1.0, abs=0.01)
def test_sign_bit_negates(self):
# sign bit (bit 0) + bit 8 → -1.0
assert _convert_debug_temp("8080") == pytest.approx(-1.0, abs=0.01)
def test_no_family_branching(self):
# Debug conversion takes no family code — same result regardless
assert _convert_debug_temp("0080") == _convert_debug_temp("0080")
class TestConvertToDebugTemperatures:
def test_returns_same_shape(self):
hex_data = [["0080", "0000"], ["8080", "0000"]]
result = convert_to_debug_temperatures(hex_data)
assert len(result) == 2
assert len(result[0]) == 2
def test_values_are_strings(self):
result = convert_to_debug_temperatures([["0080"]])
assert isinstance(result[0][0], str)
def test_converts_known_value(self):
result = convert_to_debug_temperatures([["0080"]])
assert float(result[0][0]) == pytest.approx(1.0, abs=0.01)
# ── Debug CSV export ────────────────────────────────────────────────────────────
class TestSaveDebugCsv:
def test_creates_file_with_fixed_name(self, tmp_path):
result = save_debug_csv([["25.0"]], [["1.0"]], str(tmp_path))
assert result is not None
assert result.endswith("debug_info.csv")
def test_header_alternates_sensor_and_debug(self, tmp_path):
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
assert result is not None
header = open(result).readline().strip()
assert header == "Sensor1,Debug1,Sensor2,Debug2"
def test_data_row_alternates_values(self, tmp_path):
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
assert result is not None
lines = open(result).readlines()
assert lines[1].strip() == "25.0,1.0,24.5,2.0"
def test_truncates_to_shorter_row_count(self, tmp_path):
temp_data = [["25.0"], ["24.0"], ["23.0"]]
debug_data = [["1.0"], ["2.0"]]
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
assert result is not None
lines = open(result).readlines()
assert len(lines) == 3 # 1 header + 2 data rows (shorter of the two)
def test_truncates_to_shorter_column_count(self, tmp_path):
temp_data = [["25.0", "24.0", "23.0"]]
debug_data = [["1.0", "2.0"]]
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
assert result is not None
header = open(result).readline().strip()
assert header == "Sensor1,Debug1,Sensor2,Debug2"
def test_returns_none_on_empty_temp_data(self, tmp_path):
assert save_debug_csv([], [["1.0"]], str(tmp_path)) is None
def test_returns_none_on_empty_debug_data(self, tmp_path):
assert save_debug_csv([["25.0"]], [], str(tmp_path)) is None
def test_creates_output_dir_if_missing(self, tmp_path):
nested = str(tmp_path / "a" / "b")
result = save_debug_csv([["25.0"]], [["1.0"]], nested)
assert result is not None
from pathlib import Path
assert Path(result).exists()
+67
View File
@@ -133,6 +133,64 @@ def test_read_debug_handler(controller):
assert controller.connectionStatus == "Connected" assert controller.connectionStatus == "Connected"
assert not controller.operationInProgress assert not controller.operationInProgress
def _make_p_family_bytes(value: int = 0x0100) -> bytes:
"""Synthetic single-block P-family binary: 244 valid words + 12 overhead."""
data = bytearray()
for i in range(256):
word = value if i < 244 else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
def test_debug_worker_decodes_and_saves_csv(controller, tmp_path):
controller._last_wafer_info = {"familyCode": "P"}
controller._save_data_dir = str(tmp_path)
controller._service.read_wafer_data = MagicMock(
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
)
captured = {}
controller._debugFinished.connect(captured.update)
controller._debug_worker("COM1")
assert captured.get("success") is True
assert captured.get("sensor_bytes") == 512
assert captured.get("debug_bytes") == 512
assert "csv_path" in captured
from pathlib import Path
assert Path(captured["csv_path"]).exists()
assert Path(captured["csv_path"]).name == "debug_info.csv"
def test_debug_worker_reports_error_when_csv_save_fails(controller, tmp_path):
controller._last_wafer_info = {"familyCode": "P"}
# Point save dir somewhere save_debug_csv cannot write to, so it returns None.
controller._save_data_dir = str(tmp_path / "debug_info.csv") # a file, not a dir
(tmp_path / "debug_info.csv").write_text("occupied")
controller._service.read_wafer_data = MagicMock(
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
)
captured = {}
controller._debugFinished.connect(captured.update)
controller._debug_worker("COM1")
assert captured.get("success") is not True
assert "error" in captured
def test_debug_handler_logs_csv_path(controller):
controller._handle_debug_finished({
"success": True,
"sensor_bytes": 512,
"debug_bytes": 512,
"csv_path": "/tmp/debug_info.csv",
})
assert "/tmp/debug_info.csv" in controller.activityLog
def test_parse_and_save_data_and_get_chart_data(controller): def test_parse_and_save_data_and_get_chart_data(controller):
res = controller.getChartData() res = controller.getChartData()
assert res == {"success": False} assert res == {"success": False}
@@ -233,3 +291,12 @@ def test_device_service_port_caching(controller):
service.enumerate_ports() service.enumerate_ports()
assert mock_run.call_count == 2 assert mock_run.call_count == 2
def test_export_dir_created_under_save_data_dir(controller):
from pathlib import Path
result = controller.exportDir()
assert result == str(Path(controller.saveDataDir) / "Export")
assert Path(result).is_dir()
+13
View File
@@ -28,3 +28,16 @@ def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
files_by_name = {Path(row["fileName"]).name: row for row in browser.files} files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
"""setCurrentDirectory (no native dialog) is how the Status tab's save-dir
picker keeps the Source panel pointed at the same folder."""
_write_csv(tmp_path / "P00003-20260706_143500.csv")
browser = FileBrowser()
browser.setCurrentDirectory(str(tmp_path))
assert browser.currentDirectory == str(tmp_path)
assert len(browser.files) == 1
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
+22 -1
View File
@@ -1,5 +1,5 @@
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
def frames(n): def frames(n):
@@ -29,3 +29,24 @@ def test_at_end():
assert not p.at_end assert not p.at_end
p.seek(1) p.seek(1)
assert p.at_end assert p.at_end
def test_frames_property_exposes_loaded_frames():
p = FramePlayer(); p.load(frames(3))
assert p.frames == tuple(frames(3))
# ---- all_sensor_series (Graph tab whole-run assembly) ----
def test_all_sensor_series_empty_frames():
assert all_sensor_series([]) == ([], [])
def test_all_sensor_series_names_and_columns():
fs = [
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
]
names, series = all_sensor_series(fs)
assert names == ["Sensor 1", "Sensor 2"]
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
+46 -1
View File
@@ -2,7 +2,12 @@ import math
import pytest import pytest
from pygui.backend.models.frame_stats import Stats, compute_stats from pygui.backend.models.frame_stats import (
EdgeCenterStats,
Stats,
compute_edge_center,
compute_stats,
)
def test_basic_stat(): def test_basic_stat():
@@ -28,3 +33,43 @@ def test_ignores_nan():
assert s.avg == pytest.approx(150.0) assert s.avg == pytest.approx(150.0)
assert s.sigma == pytest.approx(1.0) assert s.sigma == pytest.approx(1.0)
assert s.three_sigma == pytest.approx(3.0) assert s.three_sigma == pytest.approx(3.0)
# ---- edge-center delta (ADR-0002) ----
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
def test_edge_center_min_max_pairs():
# e0 e1 e2 e3 c4 c5
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded=set())
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
assert ec == EdgeCenterStats(
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
min_delta=pytest.approx(0.1),
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
max_delta=pytest.approx(1.5),
)
def test_edge_center_skips_excluded_sensors():
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
assert ec is not None
assert ec.max_delta == pytest.approx(1.0)
# excluding a center sensor kills all its pairs
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
def test_edge_center_skips_nan_and_out_of_range():
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
assert ec is not None
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
def test_edge_center_none_when_no_pairs():
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
assert compute_edge_center([], PAIRS, excluded=set()) is None
+289
View File
@@ -0,0 +1,289 @@
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
import pytest
pytestmark = pytest.mark.usefixtures("qapp")
def test_no_data_initially():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
assert item.seriesData == []
assert item.sensorNames == []
def test_series_data_setter_stores_series():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
def test_series_data_auto_ranges_y_with_padding():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
# min=100, max=200, range=100, 10% pad => [90, 210]
assert item._min_y == pytest.approx(90.0)
assert item._max_y == pytest.approx(210.0)
def test_series_data_empty_keeps_default_range():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = []
assert item._min_y == 0.0
assert item._max_y == 150.0
def test_sensor_names_setter_stores_names():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.sensorNames = ["Sensor 1", "Sensor 2"]
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
def test_non_numeric_values_are_skipped_not_crashing():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, "bad", 151.0]]
assert item._min_y == pytest.approx(149.9)
assert item._max_y == pytest.approx(151.1)
# ---- viewport_stats (replay chart min/max/avg readouts) ----
# Independently hand-computed against PopupChartForm.cs's recalc_stats
# semantics: aggregate across every sensor's points in [start, end], plus
# which sensor achieved each extreme.
def test_viewport_stats_full_range_aggregate():
from pygui.backend.visualization.graph_quick_item import viewport_stats
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
names = ["Sensor 1", "Sensor 2"]
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
assert vmin == pytest.approx(147.0)
assert vmax == pytest.approx(152.0)
assert avg == pytest.approx(149.25)
assert min_sensor == "Sensor 2"
assert max_sensor == "Sensor 1"
def test_viewport_stats_restricted_to_subrange():
from pygui.backend.visualization.graph_quick_item import viewport_stats
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
names = ["Sensor 1", "Sensor 2"]
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
assert vmin == pytest.approx(147.5)
assert vmax == pytest.approx(151.0)
assert avg == pytest.approx(149.125)
assert min_sensor == "Sensor 2"
assert max_sensor == "Sensor 1"
def test_viewport_stats_empty_series_returns_zeros():
from pygui.backend.visualization.graph_quick_item import viewport_stats
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
def test_viewport_stats_skips_non_numeric():
from pygui.backend.visualization.graph_quick_item import viewport_stats
series = [[150.0, "bad", 151.0]]
names = ["Sensor 1"]
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
assert vmin == pytest.approx(150.0)
assert vmax == pytest.approx(151.0)
assert avg == pytest.approx(150.5)
assert min_sensor == "Sensor 1"
assert max_sensor == "Sensor 1"
# ---- GraphQuickItem viewport properties (replay chart) ----
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
# tab (which never sets these) is unaffected — see docs/adr/0004.
def test_viewport_defaults_to_whole_range_and_markers_off():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
assert item.showMinMaxMarkers is False
def test_view_stats_reflect_default_full_range_viewport():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.sensorNames = ["Sensor 1", "Sensor 2"]
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
assert item.viewMin == pytest.approx(147.0)
assert item.viewMax == pytest.approx(152.0)
assert item.viewAvg == pytest.approx(149.25)
assert item.viewMinSensor == "Sensor 2"
assert item.viewMaxSensor == "Sensor 1"
def test_view_stats_restrict_to_narrowed_viewport():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.sensorNames = ["Sensor 1", "Sensor 2"]
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
item.viewStartIndex = 0
item.viewEndIndex = 1
assert item.viewMin == pytest.approx(147.5)
assert item.viewMax == pytest.approx(151.0)
assert item.viewAvg == pytest.approx(149.125)
def test_auto_range_y_restricted_to_narrowed_viewport():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
assert item._min_y == pytest.approx(90.0)
assert item._max_y == pytest.approx(210.0)
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
item.viewStartIndex = 0
item.viewEndIndex = 0
assert item._min_y == pytest.approx(145.0)
assert item._max_y == pytest.approx(205.0)
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
def _eleven_point_item():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[float(i) for i in range(11)]]
return item
def test_zoom_in_centers_on_fraction():
item = _eleven_point_item()
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
item.zoomAtFraction(0.5, 0.4)
assert item.viewStartIndex == 3
assert item.viewEndIndex == 7
def test_zoom_out_clamps_to_data_bounds():
item = _eleven_point_item()
item.viewStartIndex = 3
item.viewEndIndex = 7
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
# new_width=12 exceeds the data's max width of 10, so it clamps to the
# full range (0, 10).
item.zoomAtFraction(0.5, 3.0)
assert item.viewStartIndex == 0
assert item.viewEndIndex == 10
def test_pan_shifts_viewport_by_fraction_of_width():
item = _eleven_point_item()
item.viewStartIndex = 4
item.viewEndIndex = 8
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
item.panByFraction(0.5)
assert item.viewStartIndex == 6
assert item.viewEndIndex == 10
def test_pan_clamps_at_right_edge_preserving_width():
item = _eleven_point_item()
item.viewStartIndex = 4
item.viewEndIndex = 8
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
# out of bounds (max index 10), so the whole window shifts back by 2 to
# stay in bounds while keeping width=4: (6,10).
item.panByFraction(1.0)
assert item.viewStartIndex == 6
assert item.viewEndIndex == 10
def test_reset_zoom_restores_full_range_sentinel():
item = _eleven_point_item()
item.viewStartIndex = 3
item.viewEndIndex = 7
item.resetZoom()
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
def test_zoom_noop_when_fewer_than_two_points():
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[42.0]]
item.zoomAtFraction(0.5, 0.5)
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
"""Regression: zooming near the tail of a long run, then loading a new
(shorter) file, left the stale viewport pointing past the new data --
every per-series slice became empty and the chart went blank with a
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
the viewport to the full range (PopupChartForm.cs's SetData() parity).
"""
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
item = GraphQuickItem()
item.seriesData = [[float(i) for i in range(20)]]
item.viewStartIndex = 15
item.viewEndIndex = 19
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
assert item.viewStartIndex == 0
assert item.viewEndIndex == -1
assert item.viewMin == pytest.approx(100.0)
assert item.viewMax == pytest.approx(104.0)
assert item.viewAvg == pytest.approx(102.0)
+45
View File
@@ -66,6 +66,51 @@ class TestUpdateTrend:
gv.updateTrend("[1, 2, 3]") gv.updateTrend("[1, 2, 3]")
class TestUpdateChart:
def test_plots_one_series_per_sensor(self):
from pygui.backend.visualization.graph_view import GraphView
gv = GraphView()
gv._plot_widget = _make_plot_widget()
series = [[150.0, 151.0, 152.0], [148.5, 148.4, 148.6]]
gv.updateChart("Sensor 1,Sensor 2", json.dumps(series))
assert gv._plot_widget.clear.called
assert gv._plot_widget.plot.call_count == 2
assert gv._sensor_names == ["Sensor 1", "Sensor 2"]
def test_rejects_invalid_json(self):
from pygui.backend.visualization.graph_view import GraphView
gv = GraphView()
gv._plot_widget = _make_plot_widget()
gv.updateChart("Sensor 1", "not json")
assert not gv._plot_widget.plot.called
def test_empty_series_clears_without_plotting(self):
from pygui.backend.visualization.graph_view import GraphView
gv = GraphView()
gv._plot_widget = _make_plot_widget()
gv.updateChart("", "[]")
assert gv._plot_widget.clear.called
assert not gv._plot_widget.plot.called
def test_no_plot_widget(self):
from pygui.backend.visualization.graph_view import GraphView
gv = GraphView()
assert gv._plot_widget is None
# Should not crash
gv.updateChart("Sensor 1", json.dumps([[1.0, 2.0]]))
class TestUpdateComparison: class TestUpdateComparison:
def test_parses_valid_json(self): def test_parses_valid_json(self):
from pygui.backend.visualization.graph_view import GraphView from pygui.backend.visualization.graph_view import GraphView
+118
View File
@@ -1,8 +1,11 @@
import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
import pygui.backend.controllers.session_controller as session_controller_module
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.models.frame import Frame
@pytest.fixture @pytest.fixture
@@ -50,6 +53,49 @@ def test_pause_stop_timer(controller):
controller.stop() controller.stop()
assert controller.frameIndex == 0 assert controller.frameIndex == 0
def test_graph_data_empty_before_load(controller):
assert controller.graphSensorNames == ""
assert controller.graphSeriesJson == "[]"
def test_graph_data_after_load(controller):
controller.loadFile("tests/fixtures/sample_stream.csv")
assert controller.graphSensorNames == "Sensor 1,Sensor 2,Sensor 3"
series = json.loads(controller.graphSeriesJson)
assert series == [
[149.0, 149.2, 149.1],
[148.5, 148.4, 148.6],
[150.6, 150.7, 150.5],
]
def test_graph_data_cleared_after_unload(controller):
controller.loadFile("tests/fixtures/sample_stream.csv")
controller.unloadFile()
assert controller.graphSensorNames == ""
assert controller.graphSeriesJson == "[]"
def test_leaving_live_mode_blanks_wafer_map(controller):
"""Stopping a live session (tab switch → setMode) must not leave stale
sensor values on the wafer map it should read as blank."""
frame_signal = MagicMock()
controller.frameUpdated.connect(frame_signal)
controller.setMode("live")
controller._on_live_frame(Frame(seq=1, time=0.0, values=[100.0] * 22))
assert controller.sensorValues, "sanity: live frame should populate the map"
controller.setMode("review")
assert controller.sensorValues == []
assert controller.stats == {}
assert frame_signal.called, "QML needs frameUpdated to repaint blank"
def test_leaving_live_mode_stops_stream(controller):
controller.setMode("live")
controller.setMode("review")
assert controller._reader is None
assert not controller._repaint_timer.isActive()
def test_compare_files_integration(controller): def test_compare_files_integration(controller):
# Mock the comparisonResult signal # Mock the comparisonResult signal
mock_slot = MagicMock() mock_slot = MagicMock()
@@ -194,3 +240,75 @@ def test_export_segment_bad_index(qapp, controller, tmp_path):
controller.segmentExported.connect(exported) controller.segmentExported.connect(exported)
controller.exportSegment(99) controller.exportSegment(99)
assert exported.call_args[0][0]["success"] is False assert exported.call_args[0][0]["success"] is False
def test_reset_live_trend_sets_start_time_and_emits_reset(controller, monkeypatch):
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 100.0)
reset_seen = MagicMock()
controller.trendReset.connect(reset_seen)
controller._reset_live_trend()
assert controller._stream_start_time == 100.0
assert reset_seen.called
def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkeypatch):
controller._stream_start_time = 100.0
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 102.5)
delta_seen = MagicMock()
controller.trendDelta.connect(delta_seen)
frame = Frame(seq=1, time=102.5, values=[10.0, 20.0, 30.0])
controller._on_live_frame(frame)
payload = json.loads(delta_seen.call_args[0][0])
assert len(payload) == 1
elapsed, avg = payload[0]
assert elapsed == pytest.approx(2.5)
assert avg == pytest.approx(20.0)
# ---- edge-center stats exposure (ADR-0002) ----
def _write_f_family_csv(path):
"""22-sensor F-family stream: pair (0,18) has delta 1.0, all others 0."""
n = 22
values = [149.0] * n
values[0] = 150.0
lines = [
"Wafer ID=F12",
"Acquisition Date=03/26/2025",
"Label," + ",".join(str(i + 1) for i in range(n)),
"X (mm)," + ",".join(str(i) for i in range(n)),
"Y (mm)," + ",".join(str(i) for i in range(n)),
"data",
"0.0," + ",".join(f"{v:.1f}" for v in values),
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_stats_include_edge_center_for_known_family(controller, tmp_path):
csv = tmp_path / "f_run.csv"
_write_f_family_csv(csv)
controller.loadFile(str(csv))
s = controller.stats
assert s["ecMaxDelta"] == pytest.approx(1.0)
assert s["ecMaxEdgeIndex"] == 1 and s["ecMaxCenterIndex"] == 19 # 1-origin
assert s["ecMaxEdgeValue"] == pytest.approx(150.0)
assert s["ecMaxCenterValue"] == pytest.approx(149.0)
assert s["ecMinDelta"] == pytest.approx(0.0)
def test_stats_edge_center_skips_replaced_sensor(controller, tmp_path):
csv = tmp_path / "f_run.csv"
_write_f_family_csv(csv)
controller.loadFile(str(csv))
controller.replaceSensor(0, 149.0) # replaced: pair (0,18) must be skipped
assert controller.stats["ecMaxDelta"] == pytest.approx(0.0)
def test_stats_edge_center_absent_for_unknown_or_short_family(controller):
controller.loadFile("tests/fixtures/sample_stream.csv") # A12, 3 sensors
assert "ecMaxDelta" not in controller.stats
+82
View File
@@ -0,0 +1,82 @@
"""Tests for src/pygui/backend/visualization/trend_chart_item.py."""
import json
import pytest
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
# descriptors work properly.
pytestmark = pytest.mark.usefixtures("qapp")
def test_no_data_initially():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
assert item.hasData is False
def test_append_delta_adds_point_and_sets_has_data():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.5, 42.0]]))
assert item.hasData is True
assert item._points == [(1.5, 42.0)]
def test_append_delta_accumulates_across_calls():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[0.0, 10.0]]))
item.appendDelta(json.dumps([[1.0, 20.0]]))
assert item._points == [(0.0, 10.0), (1.0, 20.0)]
def test_append_delta_prunes_points_older_than_60s_window():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[0.0, 10.0]]))
item.appendDelta(json.dumps([[70.0, 20.0]]))
assert item._points == [(70.0, 20.0)]
def test_append_delta_ignores_malformed_json():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta("not json")
assert item._points == []
assert item.hasData is False
def test_append_delta_skips_repaint_when_no_points_added():
from unittest.mock import MagicMock
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.0, 10.0]]))
item.update = MagicMock()
item.appendDelta("not json")
assert item.update.called is False
def test_clear_trend_resets_points_and_has_data():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.0, 10.0]]))
assert item.hasData is True
item.clearTrend()
assert item._points == []
assert item.hasData is False
+29 -1
View File
@@ -1,7 +1,11 @@
"""Tests for src/pygui/backend/wafer_layouts.py.""" """Tests for src/pygui/backend/wafer_layouts.py."""
import pytest import pytest
from pygui.backend.wafer.wafer_layouts import available_families, load_layout from pygui.backend.wafer.wafer_layouts import (
available_families,
ec_pairs_for_wafer_id,
load_layout,
)
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
@@ -20,3 +24,27 @@ def test_loads_sensors_in_mm():
def test_unknown_family_raises(): def test_unknown_family_raises():
with pytest.raises(KeyError): with pytest.raises(KeyError):
load_layout("nope") load_layout("nope")
# ---- edge-center pair tables (ADR-0002) ----
def test_ec_pairs_aep_match_csharp_table():
pairs = ec_pairs_for_wafer_id("A00123")
assert pairs[0] == (0, 40)
assert pairs[-1] == (15, 47)
assert len(pairs) == 16
assert ec_pairs_for_wafer_id("E1") == pairs
assert ec_pairs_for_wafer_id("P1") == pairs
def test_ec_pairs_per_family_classes():
assert len(ec_pairs_for_wafer_id("B00108")) == 12
assert ec_pairs_for_wafer_id("C1") == ec_pairs_for_wafer_id("D1")
assert ec_pairs_for_wafer_id("F1")[0] == (0, 18)
assert ec_pairs_for_wafer_id("X1")[0] == (5, 76)
assert ec_pairs_for_wafer_id("Z1")[0] == (49, 0)
def test_ec_pairs_unknown_family_empty():
assert ec_pairs_for_wafer_id("Q999") == ()
assert ec_pairs_for_wafer_id("") == ()
+238 -31
View File
@@ -8,20 +8,6 @@ import pytest
pytestmark = pytest.mark.usefixtures("qapp") pytestmark = pytest.mark.usefixtures("qapp")
def test_export_image_valid_path():
"""export_image saves a PNG when grabToImage succeeds."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
mock_image = MagicMock()
mock_result = MagicMock()
mock_result.image.return_value = mock_image
item.grabToImage = MagicMock(return_value=mock_result)
assert item.export_image("/tmp/test_wafer.png") is True
def test_export_image_empty_path(): def test_export_image_empty_path():
"""export_image returns False for empty/None path.""" """export_image returns False for empty/None path."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
@@ -31,29 +17,23 @@ def test_export_image_empty_path():
assert item.export_image(None) is False assert item.export_image(None) is False
def test_export_image_grab_fails(): def test_export_image_zero_size_item_fails():
"""export_image returns False when grabToImage raises.""" """export_image returns False when the item has no geometry to render."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem() item = WaferMapItem()
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
assert item.export_image("/tmp/test_wafer.png") is False assert item.export_image("/tmp/test_wafer.png") is False
def test_export_image_save_fails(): def test_export_image_unwritable_path_fails(tmp_path):
"""export_image returns False when image.save raises.""" """export_image returns False when the target directory doesn't exist."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
mock_image = MagicMock()
mock_image.save.side_effect = IOError("disk full")
mock_result = MagicMock()
mock_result.image.return_value = mock_image
item = WaferMapItem() item = WaferMapItem()
item.grabToImage = MagicMock(return_value=mock_result) item.setWidth(100)
item.setHeight(100)
assert item.export_image("/tmp/test_wafer.png") is False item.values = [10.0, 20.0, 30.0]
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
def test_thickness_properties(): def test_thickness_properties():
@@ -67,16 +47,89 @@ def test_thickness_properties():
def test_thickness_setter_updates_data(): def test_thickness_setter_updates_data():
"""Setting thicknessData updates _thickness_data.""" """Setting thicknessData stores [x, y, t2] triples."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem() item = WaferMapItem()
item._rebuild_thickness = MagicMock() item._rebuild_thickness = MagicMock()
item.thicknessData = [1.0, 2.0, 3.0] item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
assert item._thickness_data == [1.0, 2.0, 3.0] assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
assert item._rebuild_thickness.called assert item._rebuild_thickness.called
assert item.hasThickness is True
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
p = tmp_path / "thickness.csv"
p.write_text(header + body)
return str(p)
def test_parse_thickness_csv_valid(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path,
"t,1,1,7,1.5,0,10.0,-20.0\n"
"t,1,2,7,2.5,0,-30.0,40.0\n")
points, err = parse_thickness_csv(path)
assert err == ""
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
def test_parse_thickness_csv_first_slot_only(tmp_path):
"""Rows after the Slot# changes belong to another wafer and are skipped."""
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path,
"t,1,1,7,1.5,0,10.0,-20.0\n"
"t,1,1,8,9.9,0,0.0,0.0\n")
points, err = parse_thickness_csv(path)
assert err == ""
assert points == [[10.0, -20.0, 1.5]]
def test_parse_thickness_csv_bad_header(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
points, err = parse_thickness_csv(path)
assert points == []
assert "header" in err
def test_parse_thickness_csv_malformed_value(tmp_path):
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
points, err = parse_thickness_csv(path)
assert points == []
assert "Malformed" in err
def test_parse_thickness_csv_missing_file():
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
points, err = parse_thickness_csv("/no/such/file.csv")
assert points == []
assert "Unable to read" in err
def test_load_thickness_slot(tmp_path):
"""loadThickness feeds parsed points into thicknessData."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
assert item.loadThickness(path) == ""
assert item.hasThickness is True
assert item.loadThickness("/no/such/file.csv") != ""
# Failed load keeps the previously loaded data.
assert item.hasThickness is True
def test_show_thickness_setter(): def test_show_thickness_setter():
@@ -88,3 +141,157 @@ def test_show_thickness_setter():
item.showThickness = True item.showThickness = True
assert item._show_thickness is True assert item._show_thickness is True
def test_export_image_writes_real_png_headless(tmp_path):
"""export_image must produce an actual decodable image file without a
QQuickWindow the button was silently failing because grabToImage()
resolves asynchronously and never completes headless/immediately."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
assert out.exists()
loaded = QImage(str(out))
assert not loaded.isNull()
assert loaded.width() == 200
def test_export_image_includes_metrics_footer(tmp_path):
"""With sensor values loaded, the export gains a footer strip below the
map carrying the metric readouts (sensors / min / max / avg)."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
loaded = QImage(str(out))
assert loaded.height() > 200 # footer strip appended
# footer is painted opaque (dark bg), not transparent
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
def test_export_image_extra_line_grows_footer(tmp_path):
"""Live export passes an extra metrics line; the footer gains a second
row for it. Review export (no extra) keeps the single-row footer."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
review = tmp_path / "review.png"
live = tmp_path / "live.png"
assert item.export_image(str(review)) is True
assert item.export_image(str(live), "Frames: 42 Errors: 0") is True
h_review = QImage(str(review)).height()
h_live = QImage(str(live)).height()
assert h_review > 200 # readout footer present
assert h_live > h_review # extra live row appended
def test_export_image_highlights_hottest_and_coldest(tmp_path):
"""The exported map rings the hottest sensor in the high color and the
coldest in the low color. With no bands set every marker fill is the
in-range green, so any high/low-colored pixels must come from the rings."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.sensors = [
{"label": "1", "x": -50.0, "y": 0.0},
{"label": "2", "x": 0.0, "y": 0.0},
{"label": "3", "x": 50.0, "y": 0.0},
]
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
img = QImage(str(out))
found = {img.pixelColor(x, y).name()
for y in range(img.height()) for x in range(img.width())}
assert "#ef4444" in found # hottest ring (sensorHigh)
assert "#5b9df5" in found # coldest ring (sensorLow)
def test_show_extremes_toggle_gates_peak_rings(tmp_path):
"""showExtremes (Display setting) hides the hot/cold rings when off.
Same pixel probe as the highlight test: with rings off, no high/low
colored pixels should appear anywhere."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
assert item.showExtremes is True # rings on by default
item.setWidth(200)
item.setHeight(200)
item.sensors = [
{"label": "1", "x": -50.0, "y": 0.0},
{"label": "2", "x": 0.0, "y": 0.0},
{"label": "3", "x": 50.0, "y": 0.0},
]
item.values = [10.0, 20.0, 30.0]
item.showExtremes = False
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
img = QImage(str(out))
found = {img.pixelColor(x, y).name()
for y in range(img.height()) for x in range(img.width())}
assert "#ef4444" not in found
assert "#5b9df5" not in found
def test_readout_text_full_stats():
"""Export footer readout carries every READOUT stat with sensor indices.
Worked example: [10, 20, 30] min 10 (#1), max 30 (#3), diff 20,
avg 20, σ = sqrt(200/3) 8.16, 3σ 24.49.
"""
from pygui.backend.visualization.wafer_map_item import readout_text
assert readout_text([10.0, 20.0, 30.0]) == (
"Sensors: 3 Min: 10.00°C (#1) Max: 30.00°C (#3) "
"Diff: 20.00 Avg: 20.00 σ: 8.16 3σ: 24.49"
)
def test_readout_text_empty():
from pygui.backend.visualization.wafer_map_item import readout_text
assert readout_text([]) == ""
def test_export_image_no_data_fails(tmp_path):
"""No file loaded / no stream played -> nothing meaningful to export."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is False
assert not out.exists()