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