feat: add file comparison, data splitting, and sensor modification functionality to SessionController
This commit is contained in:
@@ -40,6 +40,8 @@ class SessionController(QObject):
|
||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||
# trend: per-frame avg for live graph
|
||||
trendData = Signal(str) # JSON array of floats
|
||||
comparisonResult = Signal(object) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||
splitResult = Signal(object) # dict with success, segments
|
||||
# private: marshal a worker-thread frame onto the main thread
|
||||
_liveFrame = Signal(object) # Frame
|
||||
_liveError = Signal() # worker-thread error ping
|
||||
@@ -305,6 +307,169 @@ class SessionController(QObject):
|
||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||
self.settingsChanged.emit()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def unloadFile(self) -> None:
|
||||
"""Clear the loaded file, resetting the player and frame states."""
|
||||
self._player.load([])
|
||||
self._model.reset()
|
||||
self._stats_tracker.reset()
|
||||
self._loaded_file = ""
|
||||
self.loadedFileChanged.emit()
|
||||
self.sensorsChanged.emit()
|
||||
self.settingsChanged.emit()
|
||||
|
||||
# ---- comparison: DTW between two CSV files ----
|
||||
@Slot(str, str)
|
||||
@slot_error_boundary
|
||||
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||
"""Run DTW comparison between two CSV files and emit result."""
|
||||
from pygui.backend.comparison import compare_runs
|
||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||
|
||||
try:
|
||||
if is_official_csv(file_a):
|
||||
_, recs_a = read_official_csv(file_a)
|
||||
else:
|
||||
recs_a = read_data_records(file_a)
|
||||
|
||||
if is_official_csv(file_b):
|
||||
_, recs_b = read_official_csv(file_b)
|
||||
else:
|
||||
recs_b = read_data_records(file_b)
|
||||
if not recs_a or not recs_b:
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": "No data in one or both files"
|
||||
})
|
||||
return
|
||||
|
||||
# Use first 100 frames of each for comparison (avoid O(n²) blowup)
|
||||
max_frames = min(100, len(recs_a), len(recs_b))
|
||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
|
||||
for i in range(max_frames)]
|
||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
|
||||
for i in range(max_frames)]
|
||||
|
||||
result = compare_runs(series_a, series_b)
|
||||
|
||||
# Max sensor deviation: walk the DTW-aligned frame pairs and take
|
||||
# the largest per-sensor abs difference across all sensors, not
|
||||
# just the sensor[0] series used for alignment.
|
||||
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
|
||||
max_sensor_deviation = 0.0
|
||||
for i, j in zip(result["index_a"], result["index_b"]):
|
||||
if i >= max_frames or j >= max_frames:
|
||||
continue
|
||||
values_a = recs_a[i].values
|
||||
values_b = recs_b[j].values
|
||||
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
||||
diff = abs(values_a[s] - values_b[s])
|
||||
if diff > max_sensor_deviation:
|
||||
max_sensor_deviation = diff
|
||||
|
||||
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
||||
# of both files' wafer type).
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.data.data_records import is_official_csv, read_official_csv
|
||||
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
sensor_layout: list[dict] = []
|
||||
sensor_diff: list[float] = []
|
||||
wafer_shape = "round"
|
||||
wafer_size = 300.0
|
||||
try:
|
||||
if is_official_csv(file_a):
|
||||
sensors, _ = read_official_csv(file_a)
|
||||
stem = Path(file_a).stem
|
||||
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||
else:
|
||||
parsed, _ = ZWaferParser().parse(file_a)
|
||||
sensors = parsed.sensors if parsed else []
|
||||
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
|
||||
|
||||
if sensors:
|
||||
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||
last_a = recs_a[max_frames - 1].values
|
||||
last_b = recs_b[max_frames - 1].values
|
||||
n = min(len(sensors), len(last_a), len(last_b))
|
||||
sensor_layout = [
|
||||
{
|
||||
"label": s.label,
|
||||
"x": s.x,
|
||||
"y": s.y,
|
||||
"side": getattr(s, "side", "right"),
|
||||
"offset_x": getattr(s, "offset_x", 0.0),
|
||||
"offset_y": getattr(s, "offset_y", 0.0),
|
||||
}
|
||||
for s in sensors[:n]
|
||||
]
|
||||
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
|
||||
except Exception as layout_exc:
|
||||
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
|
||||
|
||||
self.comparisonResult.emit({
|
||||
"success": True,
|
||||
"distance": result["distance"],
|
||||
"warping_path": result["warping_path"][:50], # limit path size
|
||||
"max_sensor_deviation": max_sensor_deviation,
|
||||
"series_a": series_a,
|
||||
"series_b": series_b,
|
||||
"sensor_layout": sensor_layout,
|
||||
"sensor_diff": sensor_diff,
|
||||
"wafer_shape": wafer_shape,
|
||||
"wafer_size": wafer_size,
|
||||
})
|
||||
except Exception as exc:
|
||||
log.warning("Comparison failed: %s", exc)
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
# ---- splitting: threshold-based segmentation ----
|
||||
@Slot(str, float)
|
||||
@slot_error_boundary
|
||||
def splitData(self, file_path: str, threshold: float) -> None:
|
||||
"""Segment temperature profile into Ramp/Soak/Cool phases."""
|
||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||
from pygui.backend.splitter import segment_profile
|
||||
|
||||
try:
|
||||
if is_official_csv(file_path):
|
||||
_, records = read_official_csv(file_path)
|
||||
else:
|
||||
records = read_data_records(file_path)
|
||||
if not records:
|
||||
self.splitResult.emit({
|
||||
"success": False,
|
||||
"error": "No data in file"
|
||||
})
|
||||
return
|
||||
|
||||
# Extract average temperatures (use first sensor as proxy)
|
||||
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
||||
segments = segment_profile(avg_temps, threshold)
|
||||
|
||||
self.splitResult.emit({
|
||||
"success": True,
|
||||
"segments": [{
|
||||
"label": seg.label,
|
||||
"start_frame": seg.start_frame,
|
||||
"end_frame": seg.end_frame,
|
||||
"avg_temp": seg.avg_temp
|
||||
} for seg in segments]
|
||||
})
|
||||
except Exception as exc:
|
||||
log.warning("Split failed: %s", exc)
|
||||
self.splitResult.emit({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def play(self) -> None:
|
||||
@@ -597,3 +762,52 @@ class SessionController(QObject):
|
||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||
|
||||
|
||||
self.liveStatsChanged.emit()
|
||||
|
||||
# ---- recording ----
|
||||
@Slot(str, str)
|
||||
@slot_error_boundary
|
||||
def startRecording(self, path: str, serial: str = "") -> None:
|
||||
self._recorder.start(path, self._sensors, serial)
|
||||
self.recordingChanged.emit()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def stopRecording(self) -> None:
|
||||
if self._recorder.is_recording:
|
||||
self._recorder.stop()
|
||||
self.recordingChanged.emit()
|
||||
|
||||
@Slot(int, float)
|
||||
@slot_error_boundary
|
||||
def replaceSensor(self, index: int, value: float) -> None:
|
||||
"""Override sensor `index` to display `value` every frame."""
|
||||
self._sensor_editor.set_replacement(index, value)
|
||||
self._reprocess_current()
|
||||
|
||||
@Slot(int, float)
|
||||
@slot_error_boundary
|
||||
def offsetSensor(self, index: int, delta: float) -> None:
|
||||
"""Shift sensor `index` by `delta` every frame."""
|
||||
self._sensor_editor.set_offset(index, delta)
|
||||
self._reprocess_current()
|
||||
|
||||
@Slot(int)
|
||||
@slot_error_boundary
|
||||
def clearSensorEdit(self, index: int) -> None:
|
||||
"""Remove all overrides for sensor `index`."""
|
||||
self._sensor_editor.clear(index)
|
||||
self._reprocess_current()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def clearSensorEdits(self) -> None:
|
||||
"""Remove all sensor overrides."""
|
||||
self._sensor_editor.clear()
|
||||
self._reprocess_current()
|
||||
|
||||
def _flush_trend(self) -> None:
|
||||
import json
|
||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user