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:
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
valueColor: Theme.sensorLow
|
||||||
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 {
|
||||||
Rectangle {
|
label: "Max Temp"
|
||||||
Layout.fillWidth: true
|
value: s.max !== undefined
|
||||||
height: 1
|
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||||
color: Theme.cardBorder
|
: "—"
|
||||||
|
valueColor: Theme.sensorHigh
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Max Temp Row
|
label: "Diff"
|
||||||
Item {
|
value: s.diff !== undefined ? s.diff : "—"
|
||||||
Layout.fillWidth: true
|
valueColor: Theme.diffAccent
|
||||||
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 {
|
||||||
Rectangle {
|
label: "Average"
|
||||||
Layout.fillWidth: true
|
value: s.avg !== undefined ? s.avg : "—"
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Differential Row
|
label: "Sigma (Σ)"
|
||||||
Item {
|
value: s.sigma !== undefined ? s.sigma : "—"
|
||||||
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 {
|
||||||
Rectangle {
|
label: "3Σ Value"
|
||||||
Layout.fillWidth: true
|
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// 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
|
visible: s.ecMinDelta !== undefined
|
||||||
Layout.fillWidth: true
|
label: "E-C Δ Min"
|
||||||
height: 1
|
value: s.ecMinDelta !== undefined
|
||||||
color: Theme.cardBorder
|
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||||
|
: "—"
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// 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 {
|
|
||||||
visible: s.ecMaxDelta !== undefined
|
visible: s.ecMaxDelta !== undefined
|
||||||
Layout.fillWidth: true
|
label: "E-C Δ Max"
|
||||||
height: 1
|
value: s.ecMaxDelta !== undefined
|
||||||
color: Theme.cardBorder
|
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||||
}
|
: "—"
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)"
|
||||||
|
|||||||
+7
-8
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user