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:
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user