feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab

This commit is contained in:
jack
2026-07-07 15:05:22 -07:00
parent 1e03227788
commit 92f130b3bd
8 changed files with 798 additions and 428 deletions
@@ -35,6 +35,7 @@ class SessionController(QObject):
recordingChanged = Signal()
sensorsChanged = Signal()
loadedFileChanged = Signal()
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
@@ -90,6 +91,11 @@ class SessionController(QObject):
self._received_count: int = 0
self._error_count: int = 0
# Comparison cache for dynamic overlap mapping
self._compare_recs_a: list = []
self._compare_recs_b: list = []
self._compare_alignment_map: dict[int, int] = {}
# Restore persisted state if available
self._load_settings(settings or {})
@@ -269,24 +275,25 @@ class SessionController(QObject):
frames = []
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records)
else:
try:
try:
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records)
else:
data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc)
return
if data is None or not data.sensors:
log.warning("Could not parse %s", file_path)
return
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if data is None or not data.sensors:
self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
return
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if not sensors or not frames:
log.warning("No sensors or data in %s", file_path)
if not sensors or not frames:
self.loadFileError.emit("No sensors or frames found in data file.")
return
except Exception as exc:
log.warning("Could not parse file %s: %s", file_path, exc)
self.loadFileError.emit(f"Load error: {exc}")
return
wafer_id = ""
@@ -360,6 +367,9 @@ class SessionController(QObject):
})
return
self._compare_recs_a = recs_a
self._compare_recs_b = recs_b
len_a, len_b = len(recs_a), len(recs_b)
# DTW can only align frames both runs actually have; the longer
# run's extra tail is still returned for display, just unaligned.
@@ -370,6 +380,7 @@ class SessionController(QObject):
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
# Mean temporal shift along the DTW path: positive means run B
# reaches the same profile features later than run A.
@@ -459,6 +470,25 @@ class SessionController(QObject):
"error": str(exc)
})
@Slot(int, result="QVariantList")
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
if not self._compare_recs_a or not self._compare_recs_b:
return []
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
if idx_a not in self._compare_alignment_map:
# DTW path only covers indices up to the shorter run's length —
# past that, Run B has no data at this frame, not a clampable one.
return []
idx_b = self._compare_alignment_map[idx_a]
values_a = self._compare_recs_a[idx_a].values
values_b = self._compare_recs_b[idx_b].values
n = min(len(values_a), len(values_b))
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
# ---- splitting: threshold-based segmentation ----
@Slot(str, float)
@slot_error_boundary
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import math
from typing import Optional
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
@@ -190,9 +191,14 @@ class TrendChartItem(QQuickPaintedItem):
lo = min(self._data)
hi = max(self._data)
if hi - lo < 1e-9:
return lo - 5.0, hi + 5.0
buf = (hi - lo) * 0.1
return lo - buf, hi + buf
lo, hi = lo - 5.0, hi + 5.0
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
# while streaming and only moves when data crosses a step boundary —
# raw min/max rescaled every frame, which read as an unstable chart.
raw_step = (hi - lo) / 4
mag = 10 ** math.floor(math.log10(raw_step))
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
return math.floor(lo / step) * step, math.ceil(hi / step) * step
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1:
@@ -234,7 +240,7 @@ class TrendChartItem(QQuickPaintedItem):
t = i / rows
y_val = y_max - t * (y_max - y_min)
y_px = plot_rect.top() + t * plot_rect.height()
label = f"{y_val:.1f}"
label = f"{y_val:g}"
painter.drawText(QRectF(0, y_px - 7, label_w, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label)
@@ -250,12 +256,17 @@ class TrendChartItem(QQuickPaintedItem):
for i in range(cols + 1):
idx = round((i / cols) * (n - 1))
x_px = self._x_to_px(idx, n, plot_rect)
align = (Qt.AlignmentFlag.AlignLeft if i == 0
else Qt.AlignmentFlag.AlignRight if i == cols
else Qt.AlignmentFlag.AlignHCenter)
painter.drawText(QRectF(x_px - 20, y_px, 40, 14),
align | Qt.AlignmentFlag.AlignTop,
str(idx))
if i == 0:
rect = QRectF(x_px, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignLeft
elif i == cols:
# Right-anchor inside the plot so the label never clips off-widget
rect = QRectF(x_px - 40, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignRight
else:
rect = QRectF(x_px - 20, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignHCenter
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx))
def _draw_line(
self,
@@ -347,6 +347,10 @@ class WaferMapItem(QQuickPaintedItem):
self._thickness_heatmap = None
return
field = refine_field(field)
# ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
@@ -467,6 +471,10 @@ class WaferMapItem(QQuickPaintedItem):
self._heatmap = None
return
field = refine_field(field)
# ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
@@ -609,11 +617,11 @@ class WaferMapItem(QQuickPaintedItem):
font_scale = 0.85
id_font = QFont()
id_font.setPointSize(max(4, int(r * font_scale)))
id_font.setPointSize(max(9, int(r * font_scale)))
id_font.setBold(True)
temp_font = QFont()
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
temp_font.setPointSize(max(9, int(r * font_scale)))
band_color = {
"in_range": self._in_range_color,