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
This commit is contained in:
jack
2026-07-11 18:39:29 -07:00
parent fed4d9b590
commit 9e3bec9031
13 changed files with 349 additions and 451 deletions
+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
+4 -5
View File
@@ -1,12 +1,13 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Wafer import ISC.Tabs.components
// ===== Graph Tab ===== // ===== Graph Tab =====
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static // Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
// snapshot recomputed whenever a file is loaded — not live-updating during // 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. // docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
Item { Item {
id: root id: root
@@ -38,13 +39,11 @@ Item {
border.width: 1 border.width: 1
radius: Theme.radiusMd radius: Theme.radiusMd
GraphQuickItem { RunChart {
id: chart id: chart
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: 8
title: "Sensor Temperature Over Time" title: "Sensor Temperature Over Time"
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
} }
} }
} }
+7 -28
View File
@@ -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 { Item {
Layout.fillWidth: true Layout.fillWidth: true
visible: transportBar.hasContent visible: transportBar.hasContent
@@ -213,19 +193,18 @@ Item {
} }
} }
// 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 hasThicknessData: waferView.hasThickness
onExportRequested: function(filePath, extra) { onExportRequested: function(filePath, extra) {
waferView.exportImage(filePath, extra); waferView.exportImage(filePath, extra);
@@ -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
+65 -405
View File
@@ -1,13 +1,14 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs 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 id: root
spacing: 6 spacing: 6
@@ -21,49 +22,6 @@ ColumnLayout {
signal exportRequested(string filePath, string extra) signal exportRequested(string filePath, string extra)
signal thicknessFileChosen(string filePath) 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 { component BadgePill: Rectangle {
implicitWidth: badgeLabel.implicitWidth + 10 implicitWidth: badgeLabel.implicitWidth + 10
implicitHeight: 10 implicitHeight: 10
@@ -89,349 +47,79 @@ ColumnLayout {
} }
} }
Rectangle { // ── READOUT ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: readoutCard.implicitHeight + 24 Layout.preferredHeight: readoutCol.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: readoutCard id: readoutCol
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right spacing: 8
top: parent.top
topMargin: 12
}
spacing: 0
// Header Inside the Box SectionTitle { text: "READOUT" }
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 ReadoutStat {
Item { label: "Min Temp"
Layout.fillWidth: true value: s.min !== undefined
implicitHeight: 32 ? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
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
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 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
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 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
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 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 {
visible: s.ecMinDelta !== undefined
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 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 valueColor: Theme.sensorLow
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
} }
} ReadoutStat {
label: "Max Temp"
Rectangle { value: s.max !== undefined
visible: s.ecMaxDelta !== undefined ? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
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 valueColor: Theme.sensorHigh
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
} }
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 + ")"
: "—"
} }
} }
} }
Rectangle { // ── DISPLAY ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: displayCard.implicitHeight + 24 Layout.preferredHeight: 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
@@ -491,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
@@ -502,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) + "%"
@@ -528,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)"
@@ -4,19 +4,19 @@ import QtQuick.Layouts
import ISC import ISC
import ISC.Wafer import ISC.Wafer
// ===== Replay Chart ===== // ===== Run Chart =====
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs). // Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
// Embedded inline in WaferMapTab's replay slot rather than a separate popup // Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
// window. Mouse wheel zooms around the cursor, drag pans; the strip below // the strip below shows the aggregate min/max/avg over the *visible* range
// shows the aggregate min/max/avg over the *visible* range (not the whole // (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
// run), matching PopupChartForm's recalc_stats. Cursor/playhead tracking is // playhead tracking is out of scope see specs/plans/2026-07-10-popup-chart.md
// out of scope see specs/plans/2026-07-10-popup-chart.md and // and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
// docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
Item { Item {
id: root id: root
property alias sensorNames: chart.sensorNames property alias sensorNames: chart.sensorNames
property alias seriesData: chart.seriesData property alias seriesData: chart.seriesData
property alias title: chart.title
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
@@ -30,7 +30,6 @@ Item {
id: chart id: chart
anchors.fill: parent anchors.fill: parent
showMinMaxMarkers: true showMinMaxMarkers: true
title: "Replay Chart"
xLabel: "Measurement Interval" xLabel: "Measurement Interval"
yLabel: "Temperature (°C)" yLabel: "Temperature (°C)"
} }
@@ -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)
}
} }
} }
+1 -1
View File
@@ -23,4 +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
ReplayChart 1.0 ReplayChart.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
@@ -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
@@ -555,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)
@@ -572,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")
+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
+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()
+58
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}