diff --git a/src/pygui/ISC/Tabs/DataTab.qml b/src/pygui/ISC/Tabs/DataTab.qml index 812b7e3..55c5afc 100644 --- a/src/pygui/ISC/Tabs/DataTab.qml +++ b/src/pygui/ISC/Tabs/DataTab.qml @@ -57,7 +57,7 @@ Item { if (root.runAWaferType === "" || root.runBWaferType === "") return "Recording has no wafer family — register it as a master file (Settings → Master Files)" if (root.runAWaferType !== root.runBWaferType) - return "Wafer family mismatch: " + root.runAWaferType + " vs " + root.runBWaferType + return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType return "" } property real warpingDistance: -1 @@ -292,9 +292,9 @@ Item { ComparisonSidePanel { id: sidePanel Layout.fillWidth: false - Layout.preferredWidth: 280 - Layout.minimumWidth: 280 - Layout.maximumWidth: 280 + Layout.preferredWidth: Theme.rightRailWidth + Layout.minimumWidth: Theme.rightRailWidth + Layout.maximumWidth: Theme.rightRailWidth Layout.fillHeight: true warpingDistance: root.warpingDistance frameOffset: root.frameOffset diff --git a/src/pygui/ISC/Tabs/GraphTab.qml b/src/pygui/ISC/Tabs/GraphTab.qml index 44e5db3..2bd6f5b 100644 --- a/src/pygui/ISC/Tabs/GraphTab.qml +++ b/src/pygui/ISC/Tabs/GraphTab.qml @@ -1,12 +1,13 @@ import QtQuick import QtQuick.Layouts import ISC -import ISC.Wafer +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. See specs/plans/2026-07-10-graph-tab.md and +// 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 @@ -38,13 +39,11 @@ Item { border.width: 1 radius: Theme.radiusMd - GraphQuickItem { + RunChart { id: chart anchors.fill: parent anchors.margins: 8 title: "Sensor Temperature Over Time" - xLabel: "Measurement Interval" - yLabel: "Temperature (°C)" } } } diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index f6c1420..885913b 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -182,26 +182,6 @@ Item { } } - Rectangle { - id: replayChartPane - Layout.fillWidth: true - Layout.fillHeight: false - Layout.preferredHeight: 260 - Layout.minimumHeight: 180 - visible: streamController.mode !== "live" && streamController.loadedFile !== "" - color: Theme.cardBackground - border.color: Theme.cardBorder - border.width: 1 - radius: Theme.radiusMd - - ReplayChart { - anchors.fill: parent - anchors.margins: 8 - sensorNames: streamController.graphSensorNames ? streamController.graphSensorNames.split(",") : [] - seriesData: JSON.parse(streamController.graphSeriesJson || "[]") - } - } - Item { Layout.fillWidth: true visible: transportBar.hasContent @@ -213,19 +193,18 @@ Item { } } - // Readout panel — floating wrapper box - Rectangle { - Layout.preferredWidth: 220 + // Readout panel — scrolls instead of clipping so the last card + // (Thresholds) is always reachable even if content outgrows + // the available height (e.g. once E-C delta rows appear). + ScrollView { + Layout.preferredWidth: Theme.rightRailWidth Layout.fillHeight: true - color: "transparent" - border.color: "transparent" - border.width: 0 clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ReadoutPanel { id: readoutPanel - anchors.fill: parent - anchors.margins: 0 + width: parent.width hasThicknessData: waferView.hasThickness onExportRequested: function(filePath, extra) { waferView.exportImage(filePath, extra); diff --git a/src/pygui/ISC/Tabs/components/RailActionButton.qml b/src/pygui/ISC/Tabs/components/RailActionButton.qml index 9eeff00..47a1207 100644 --- a/src/pygui/ISC/Tabs/components/RailActionButton.qml +++ b/src/pygui/ISC/Tabs/components/RailActionButton.qml @@ -12,6 +12,9 @@ Rectangle { property string label: "" property string iconSource: "" 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 : root.destructive ? Theme.statusErrorColor : Theme.headingColor @@ -20,7 +23,7 @@ Rectangle { implicitHeight: Theme.sideButtonHeight radius: 8 - color: mouseArea.containsMouse || mouseArea.pressed + color: root.toggled || mouseArea.containsMouse || mouseArea.pressed ? Theme.sideActiveBackground : "transparent" border.width: 0 diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 3a58edb..9f33009 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -1,13 +1,14 @@ import QtQuick import QtQuick.Controls -import QtQuick.Controls.impl import QtQuick.Dialogs import QtQuick.Layouts import ISC // ===== Readout Panel ===== -// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS. -// Cards match the left-rail SOURCE / CONNECTION FLOW style. +// Right-rail panel with three bento-tile sections: READOUT, DISPLAY, +// 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 { id: root spacing: 6 @@ -21,49 +22,6 @@ ColumnLayout { signal exportRequested(string filePath, string extra) signal thicknessFileChosen(string filePath) - component PanelCheckBox: CheckBox { - id: toggle - indicator: Rectangle { - implicitWidth: 18 - implicitHeight: 18 - x: toggle.leftPadding - y: parent.height / 2 - height / 2 - radius: Theme.radiusXs - color: toggle.checked ? Theme.primaryAccent : "transparent" - border.width: Theme.borderThin - border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder - - Behavior on color { - ColorAnimation { duration: Theme.durationFast } - } - Behavior on border.color { - ColorAnimation { duration: Theme.durationFast } - } - - 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 { implicitWidth: badgeLabel.implicitWidth + 10 implicitHeight: 10 @@ -89,349 +47,79 @@ ColumnLayout { } } - Rectangle { + // ── READOUT ───────────────────────────────────────────────────── + PanelBox { Layout.fillWidth: true - implicitHeight: readoutCard.implicitHeight + 24 - color: Theme.sidePanelBackground - radius: Theme.sidePanelRadius - border.color: Theme.sideBorder - border.width: 1 + Layout.preferredHeight: readoutCol.implicitHeight + 24 ColumnLayout { - id: readoutCard - anchors { - left: parent.left - right: parent.right - top: parent.top - topMargin: 12 - } - spacing: 0 + id: readoutCol + anchors.fill: parent + anchors.margins: 12 + spacing: 8 - // 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 - } + SectionTitle { text: "READOUT" } - // Min Temp Row - Item { - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "MIN TEMP" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Row { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - 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 - anchors.bottom: parent.bottom - anchors.bottomMargin: 2 - } - } + ReadoutStat { + label: "Min Temp" + value: s.min !== undefined + ? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "") + : "—" + valueColor: Theme.sensorLow } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder + ReadoutStat { + label: "Max Temp" + value: s.max !== undefined + ? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "") + : "—" + valueColor: Theme.sensorHigh } - - // Max Temp Row - Item { - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "MAX TEMP" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Row { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - 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 - anchors.bottom: parent.bottom - anchors.bottomMargin: 2 - } - } + ReadoutStat { + label: "Diff" + value: s.diff !== undefined ? s.diff : "—" + valueColor: Theme.diffAccent } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder + ReadoutStat { + label: "Average" + value: s.avg !== undefined ? s.avg : "—" } - - // Differential Row - Item { - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "DIFF" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Label { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: s.diff !== undefined ? s.diff : "—" - color: Theme.diffAccent - font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontLg - font.bold: true - } + ReadoutStat { + label: "Sigma (Σ)" + value: s.sigma !== undefined ? s.sigma : "—" } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder + ReadoutStat { + label: "3Σ Value" + value: s.threeSigma !== undefined ? s.threeSigma : "—" } - - // Average Row - Item { - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "AVERAGE" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Label { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - 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 - Item { - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "SIGMA (\u03A3)" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Label { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - 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 - Item { - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "3\u03A3 VALUE" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Label { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: s.threeSigma !== undefined ? s.threeSigma : "—" - color: Theme.headingColor - font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontLg - font.bold: true - } - } - - Rectangle { + ReadoutStat { visible: s.ecMinDelta !== undefined - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder + label: "E-C Δ Min" + value: s.ecMinDelta !== undefined + ? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")" + : "—" } - - // Edge-Center \u0394 min Row (per-family pair tables — see ADR-0002) - Item { - visible: s.ecMinDelta !== undefined - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "E-C \u0394 MIN" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Label { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: s.ecMinDelta !== undefined - ? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "\u2192#" + s.ecMinCenterIndex + ")" - : "—" - color: Theme.headingColor - font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - } - - Rectangle { + ReadoutStat { visible: s.ecMaxDelta !== undefined - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder - } - - // Edge-Center \u0394 max Row - Item { - visible: s.ecMaxDelta !== undefined - Layout.fillWidth: true - implicitHeight: 32 - Label { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: "E-C \u0394 MAX" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Label { - anchors.right: parent.right - anchors.rightMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: s.ecMaxDelta !== undefined - ? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "\u2192#" + s.ecMaxCenterIndex + ")" - : "—" - color: Theme.headingColor - font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } + label: "E-C Δ Max" + value: s.ecMaxDelta !== undefined + ? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")" + : "—" } } } - Rectangle { + // ── DISPLAY ───────────────────────────────────────────────────── + PanelBox { Layout.fillWidth: true - implicitHeight: displayCard.implicitHeight + 24 - color: Theme.sidePanelBackground - radius: Theme.sidePanelRadius - border.color: Theme.sideBorder - border.width: 1 + Layout.preferredHeight: displayCard.implicitHeight + 24 ColumnLayout { id: displayCard - anchors { - left: parent.left - right: parent.right - top: parent.top - margins: 12 - } + anchors.fill: parent + anchors.margins: 12 spacing: 8 - Label { - text: "DISPLAY" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.letterSpacing: 1.5 - font.bold: true - Layout.bottomMargin: 4 - } + SectionTitle { text: "DISPLAY" } PanelCheckBox { id: thicknessToggle @@ -491,7 +179,7 @@ ColumnLayout { font.pixelSize: Theme.fontSm Layout.preferredWidth: 52 } - Slider { + PanelSlider { id: heatmapSlider from: 0 to: 1 @@ -502,19 +190,6 @@ ColumnLayout { visible: heatmapSlider.hovered 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 { text: Math.round(heatmapSlider.value * 100) + "%" @@ -528,33 +203,18 @@ ColumnLayout { } } - Rectangle { + // ── THRESHOLDS ────────────────────────────────────────────────── + PanelBox { Layout.fillWidth: true - implicitHeight: thresholdCard.implicitHeight + 24 - color: Theme.sidePanelBackground - radius: Theme.sidePanelRadius - border.color: Theme.sideBorder - border.width: 1 + Layout.preferredHeight: thresholdCard.implicitHeight + 24 ColumnLayout { id: thresholdCard - anchors { - left: parent.left - right: parent.right - top: parent.top - margins: 12 - } + anchors.fill: parent + anchors.margins: 12 spacing: 6 - Label { - text: "THRESHOLDS" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.letterSpacing: 1.5 - font.bold: true - Layout.bottomMargin: 4 - } + SectionTitle { text: "THRESHOLDS" } Label { text: "Set Point (°C)" diff --git a/src/pygui/ISC/Tabs/components/ReplayChart.qml b/src/pygui/ISC/Tabs/components/RunChart.qml similarity index 89% rename from src/pygui/ISC/Tabs/components/ReplayChart.qml rename to src/pygui/ISC/Tabs/components/RunChart.qml index 4a3fc22..ddfce60 100644 --- a/src/pygui/ISC/Tabs/components/ReplayChart.qml +++ b/src/pygui/ISC/Tabs/components/RunChart.qml @@ -4,19 +4,19 @@ import QtQuick.Layouts import ISC import ISC.Wafer -// ===== Replay Chart ===== +// ===== Run Chart ===== // Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs). -// Embedded inline in WaferMapTab's replay slot rather than a separate popup -// window. 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. +// 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 @@ -30,7 +30,6 @@ Item { id: chart anchors.fill: parent showMinMaxMarkers: true - title: "Replay Chart" xLabel: "Measurement Interval" yLabel: "Temperature (°C)" } diff --git a/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml b/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml index 44141e2..746c09c 100644 --- a/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml +++ b/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml @@ -57,6 +57,17 @@ ColumnLayout { onClicked: deviceController.eraseMemory( 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) + } } } diff --git a/src/pygui/ISC/Tabs/components/qmldir b/src/pygui/ISC/Tabs/components/qmldir index 54ae126..510d446 100644 --- a/src/pygui/ISC/Tabs/components/qmldir +++ b/src/pygui/ISC/Tabs/components/qmldir @@ -23,4 +23,4 @@ EditableScrubStat 1.0 EditableScrubStat.qml ComparisonChartCard 1.0 ComparisonChartCard.qml OverlapMapCard 1.0 OverlapMapCard.qml ComparisonSidePanel 1.0 ComparisonSidePanel.qml -ReplayChart 1.0 ReplayChart.qml \ No newline at end of file +RunChart 1.0 RunChart.qml \ No newline at end of file diff --git a/src/pygui/ISC/Theme.qml b/src/pygui/ISC/Theme.qml index 24f1c07..478affb 100644 --- a/src/pygui/ISC/Theme.qml +++ b/src/pygui/ISC/Theme.qml @@ -206,6 +206,7 @@ QtObject { readonly property int panelPadding: 12 // Side rail layout readonly property int sideRailWidth: 300 + readonly property int rightRailWidth: 280 readonly property int sideRailMargin: 16 readonly property int sideRailSpacing: 16 readonly property int sideButtonHeight: 44 diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index b3b049a..de1bdb9 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -18,9 +18,11 @@ from pygui.backend.models.data_model import TemperatureTableModel from pygui.backend.utils import slot_error_boundary from pygui.backend.visualization.graph_view import GraphView from pygui.serialcomm.data_parser import ( + convert_to_debug_temperatures, convert_to_temperatures, parse_binary_data, remove_trailing_zeros, + save_debug_csv, save_to_csv, ) from pygui.serialcomm.serial_port import WaferInfo @@ -555,10 +557,29 @@ class DeviceController(QObject): self._debugFinished.emit({"error": "F1 read failed"}) 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({ "success": True, "sensor_bytes": len(sensor_data), "debug_bytes": len(debug_data), + "csv_path": csv_path, }) except Exception as exc: log.exception("Debug worker crashed: %s", exc) @@ -572,10 +593,11 @@ class DeviceController(QObject): self._set_connection_status("Connected") sensor_bytes = result.get("sensor_bytes", 0) debug_bytes = result.get("debug_bytes", 0) - self._append_log( - f"Debug read complete: {sensor_bytes} sensor bytes, " - f"{debug_bytes} debug bytes" - ) + csv_path = result.get("csv_path", "") + msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes" + if csv_path: + msg += f", CSV written to {csv_path}" + self._append_log(msg) else: self._set_connection_status("Disconnected") err_msg = result.get("error", "Debug read failed") diff --git a/src/pygui/serialcomm/data_parser.py b/src/pygui/serialcomm/data_parser.py index bd6bd5e..092aab2 100644 --- a/src/pygui/serialcomm/data_parser.py +++ b/src/pygui/serialcomm/data_parser.py @@ -107,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float: 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: """Convert a singi hale 4-char hex string to a float temperature.""" bits = _hex_to_binary(hex_str) @@ -225,6 +242,15 @@ def convert_to_temperatures( 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: """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: log.error("CSV save failed: %s", exc) 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 diff --git a/tests/test_data_parser.py b/tests/test_data_parser.py index 6c4d9c8..35c6a71 100644 --- a/tests/test_data_parser.py +++ b/tests/test_data_parser.py @@ -3,11 +3,14 @@ import pytest from pygui.serialcomm.data_parser import ( + _convert_debug_temp, _convert_hex_to_temp, + convert_to_debug_temperatures, convert_to_temperatures, csv_column_count, parse_binary_data, remove_trailing_zeros, + save_debug_csv, save_to_csv, ) @@ -321,3 +324,97 @@ class TestSaveToCsv: 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 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() diff --git a/tests/test_device_controller.py b/tests/test_device_controller.py index 7eaee48..d01b635 100644 --- a/tests/test_device_controller.py +++ b/tests/test_device_controller.py @@ -133,6 +133,64 @@ def test_read_debug_handler(controller): assert controller.connectionStatus == "Connected" 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): res = controller.getChartData() assert res == {"success": False}