fix: resolve CSV data truncation in save_to_csv by correctly identifying column widths and remove redundant logic in ZWaferParser
This commit is contained in:
@@ -104,9 +104,6 @@ class ZWaferParser:
|
|||||||
elif key.lower() == "wafer id":
|
elif key.lower() == "wafer id":
|
||||||
wafer_data.serial = value
|
wafer_data.serial = value
|
||||||
|
|
||||||
if found_kv and wafer_data.date == datetime.min:
|
|
||||||
wafer_data.date = datetime.min
|
|
||||||
|
|
||||||
return found_kv
|
return found_kv
|
||||||
|
|
||||||
# ===== Sensor Layout Parsing =====
|
# ===== Sensor Layout Parsing =====
|
||||||
|
|||||||
@@ -274,7 +274,13 @@ def save_to_csv(
|
|||||||
filename = f"{serial_number}-{timestamp}.csv"
|
filename = f"{serial_number}-{timestamp}.csv"
|
||||||
filepath = os.path.join(output_dir, filename)
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
num_cols = csv_column_count(family_code)
|
# Header count = max of display column count and actual data width.
|
||||||
|
# csv_column_count gives the display sensor count (e.g. 48 for P),
|
||||||
|
# but the binary parser may yield more readings (244 for P). Use the
|
||||||
|
# actual data width so no sensor values are silently dropped.
|
||||||
|
display_cols = csv_column_count(family_code)
|
||||||
|
data_width = max((len(r) for r in data), default=0)
|
||||||
|
num_cols = max(display_cols, data_width)
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
# Header row: Sensor1,Sensor2,...
|
# Header row: Sensor1,Sensor2,...
|
||||||
headers = [f"Sensor{j + 1}" for j in range(num_cols)]
|
headers = [f"Sensor{j + 1}" for j in range(num_cols)]
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
ParseFrame = Callable[[bytes, int], Frame]
|
ParseFrame = Callable[[bytes, int], Frame]
|
||||||
|
|
||||||
|
# Max consecutive resync attempts before giving up on a corrupt stream.
|
||||||
|
_MAX_RESYNC_ATTEMPTS = 50
|
||||||
|
|
||||||
class StreamReader:
|
class StreamReader:
|
||||||
|
|
||||||
def __init__(self, transport, parse_frame: ParseFrame,
|
def __init__(self, transport, parse_frame: ParseFrame,
|
||||||
@@ -103,6 +106,7 @@ class StreamReader:
|
|||||||
def _run_binary(self, initial_bytes: bytes) -> None:
|
def _run_binary(self, initial_bytes: bytes) -> None:
|
||||||
log.info("StreamReader: starting binary stream parsing")
|
log.info("StreamReader: starting binary stream parsing")
|
||||||
buf = bytearray(initial_bytes)
|
buf = bytearray(initial_bytes)
|
||||||
|
resync_attempts = 0
|
||||||
|
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
# Find sync marker in buffer.
|
# Find sync marker in buffer.
|
||||||
@@ -115,6 +119,7 @@ class StreamReader:
|
|||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
if synced:
|
if synced:
|
||||||
|
resync_attempts = 0
|
||||||
buf = buf[idx:]
|
buf = buf[idx:]
|
||||||
# Accumulate header (5 bytes after sync).
|
# Accumulate header (5 bytes after sync).
|
||||||
while len(buf) < 7 and not self._stop.is_set():
|
while len(buf) < 7 and not self._stop.is_set():
|
||||||
@@ -152,6 +157,17 @@ class StreamReader:
|
|||||||
buf = buf[total_packet_len:]
|
buf = buf[total_packet_len:]
|
||||||
else:
|
else:
|
||||||
# Resync: keep only trailing 0xAA if present, then read more.
|
# Resync: keep only trailing 0xAA if present, then read more.
|
||||||
|
resync_attempts += 1
|
||||||
|
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
||||||
|
log.error(
|
||||||
|
"StreamReader: giving up after %d resync attempts "
|
||||||
|
"(no 0xAA 0x88 sync marker found in stream)",
|
||||||
|
resync_attempts,
|
||||||
|
)
|
||||||
|
self._on_error(
|
||||||
|
TimeoutError("Binary stream resync failed: no sync marker")
|
||||||
|
)
|
||||||
|
return
|
||||||
if len(buf) > 0 and buf[-1] == 0xAA:
|
if len(buf) > 0 and buf[-1] == 0xAA:
|
||||||
buf = bytearray([0xAA])
|
buf = bytearray([0xAA])
|
||||||
else:
|
else:
|
||||||
|
|||||||
+32
-12
@@ -266,20 +266,25 @@ class TestSaveToCsv:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert "P12345" in result
|
assert "P12345" in result
|
||||||
|
|
||||||
def test_csv_has_sensor_headers(self, tmp_path):
|
def test_csv_header_wins_when_data_wider_than_display(self, tmp_path):
|
||||||
# Header count is driven by csv_column_count(family), not data width.
|
# Header count = max(csv_column_count, data width).
|
||||||
# P family → 48 sensors; F family → 22 sensors; X family → 80 sensors.
|
# When data has 60 columns and P display is 48, header is 60 — no truncation.
|
||||||
cases = [("P", 48), ("F", 22), ("X", 80), ("B", 29)]
|
data = [[f"{i}.0" for i in range(60)]]
|
||||||
for family, expected_cols in cases:
|
result = save_to_csv(data, "P", "P00001", str(tmp_path))
|
||||||
data = [["25.0", "24.5"]]
|
assert result is not None
|
||||||
result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
|
|
||||||
assert result is not None, f"save_to_csv returned None for {family}"
|
|
||||||
headers = open(result).readline().strip().split(",")
|
headers = open(result).readline().strip().split(",")
|
||||||
assert (
|
assert len(headers) == 60, f"data width 60 should win over display 48"
|
||||||
len(headers) == expected_cols
|
|
||||||
), f"{family}: expected {expected_cols} headers, got {len(headers)}"
|
|
||||||
assert headers[0] == "Sensor1"
|
assert headers[0] == "Sensor1"
|
||||||
assert headers[-1] == f"Sensor{expected_cols}"
|
assert headers[-1] == "Sensor60"
|
||||||
|
|
||||||
|
def test_csv_uses_display_count_when_data_is_narrower(self, tmp_path):
|
||||||
|
# When data has fewer columns than the display count, pad to display.
|
||||||
|
data = [["25.0"]]
|
||||||
|
result = save_to_csv(data, "X", "X00001", str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
headers = open(result).readline().strip().split(",")
|
||||||
|
# X display = 80, data = 1 → headers = 80
|
||||||
|
assert len(headers) == 80
|
||||||
|
|
||||||
def test_csv_row_count_matches_data(self, tmp_path):
|
def test_csv_row_count_matches_data(self, tmp_path):
|
||||||
data = [["25.0"], ["24.5"], ["23.0"]]
|
data = [["25.0"], ["24.5"], ["23.0"]]
|
||||||
@@ -302,3 +307,18 @@ class TestSaveToCsv:
|
|||||||
data = [["25.0"]]
|
data = [["25.0"]]
|
||||||
result = save_to_csv(data, "P", "P00001", "/\x00invalid\x00path")
|
result = save_to_csv(data, "P", "P00001", "/\x00invalid\x00path")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
def test_preserves_all_columns_when_data_wider_than_display(self, tmp_path):
|
||||||
|
# Bug fix: when data has more columns than csv_column_count(family),
|
||||||
|
# all data columns must be preserved (no silent truncation).
|
||||||
|
data = [[f"{i}.0" for i in range(244)]]
|
||||||
|
result = save_to_csv(data, "P", "P00001", str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
with open(result) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
headers = lines[0].strip().split(",")
|
||||||
|
data_row = lines[1].strip().split(",")
|
||||||
|
# All 244 columns should be preserved, not truncated to 48
|
||||||
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user