feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user