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
+97
View File
@@ -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()
+58
View File
@@ -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}