feat(autocal): 完善标定记录检查流程与UV轮廓显示

补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。
This commit is contained in:
ykcheungab
2026-10-11 20:15:04 +08:00
parent 432f45325f
commit d06501d7d5
17 changed files with 719 additions and 141 deletions
@@ -51,6 +51,7 @@ class CalibrationSession:
"sensor_sn": metadata["sensor_sn"],
"config_sha256": metadata["config_sha256"],
"uv_definition": metadata["uv_definition"],
"uv_outline": metadata.get("uv_outline"),
}
def add_trial(self, metadata: dict) -> dict:
@@ -0,0 +1,56 @@
"""Read trial evidence for inspection, including attempts excluded from fitting."""
import csv
import json
import math
from pathlib import Path
def trial_diagnostics(root: Path, trial: dict) -> dict:
result = {"trial_id": trial["trial_id"], "status": trial["status"],
"reason": trial.get("reason", ""), "trace": [], "references": [], "events": [],
"frames": 0, "invalid": 0, "dropped": 0, "max_gap_s": None, "errors": [], "breaks_ns": []}
path = (root / trial.get("directory", "__missing_trial__")).resolve()
if not path.is_relative_to(root.resolve()):
raise ValueError("trial directory is outside the calibration session")
def rows(name):
try:
with (path / name).open(encoding="utf-8-sig", newline="") as handle:
return list(csv.DictReader(handle))
except OSError:
result["errors"].append(name)
return []
frames = rows("frames.csv")
frame_by_id = {row["frame_id"]: row for row in frames if row.get("frame_id")}
result["frames"] = len(frames)
result["dropped"] = sum(r.get("dropped") == "1" for r in frames)
stamps = {}
try:
for row in frames:
if row.get("t_host_mono_ns") and row.get("dropped") == "0":
stamps[int(row["frame_id"])] = int(row["t_host_mono_ns"])
ordered = sorted(stamps.values())
result["max_gap_s"] = max((b - a for a, b in zip(ordered, ordered[1:])), default=0) / 1e9
for row in rows("forces.csv"):
stamp = stamps.get(int(row["frame_id"]))
raw = float(row["FN_raw"]) if row.get("FN_raw") else math.nan
if row.get("valid") != "1" or not math.isfinite(raw) or stamp is None:
result["invalid"] += 1
frame = frame_by_id.get(row["frame_id"])
if frame and frame.get("t_host_mono_ns"):
result["breaks_ns"].append(int(frame["t_host_mono_ns"]))
else:
result["trace"].append([stamp, raw])
result["trace"].sort()
result["breaks_ns"].sort()
result["references"] = rows("reference.csv")
result["events"] = rows("events.csv")
metadata = json.loads((path / "session.json").read_text(encoding="utf-8"))
result["source"] = metadata.get("input_source", {})
result["time_basis"] = metadata.get("time_basis", "")
result["reason"] = metadata.get("trial_reason") or result["reason"]
except (OSError, ValueError, KeyError, TypeError) as exc:
result["errors"].append(str(exc))
return result
@@ -0,0 +1,45 @@
"""Recorded UV region outlines; geometry recovery never substitutes a texture."""
import cv2
import numpy as np
from .export_bundle import load_bundle
from .uv_flat import mapping_sha256, uv_origin, rasterise
def outline_snapshot(flat) -> dict:
def contours(mask):
rings, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
return [cv2.approxPolyDP(ring, .5, True).reshape(-1, 2).tolist()
for ring in rings if len(ring) >= 3]
valid = flat.face_index >= 0
return {
"mapping_sha256": mapping_sha256(flat), "origin_raster_xy_px": list(uv_origin(flat)),
"raster_size_hw": list(flat.size_hw), "surface": contours(valid),
# External contours intentionally omit individual white marker cells.
"patch": contours(valid & (flat.image_rgb.min(axis=2) < 235)) if flat.textured else [],
"patch_available": bool(flat.textured),
}
def recorded_outline(review: dict) -> tuple[dict | None, str]:
definition = review["uv_definition"]
snapshot = review.get("uv_outline")
if snapshot:
if any(snapshot[key] != definition[key] for key in (
"mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
return None, "mismatch"
return snapshot, "snapshot" if snapshot["patch_available"] else "surface_only"
site = next((s for s in review["sites"] if s.get("bundle_path")), None)
if site is None:
return None, "unavailable"
try:
flat = rasterise(load_bundle(site["bundle_path"]), tuple(definition["raster_size_hw"]))
snapshot = outline_snapshot(flat)
except (OSError, ValueError, KeyError, RuntimeError):
return None, "unavailable"
if any(snapshot[key] != definition[key] for key in (
"mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
return None, "mismatch"
return snapshot, "surface_only"
@@ -200,6 +200,10 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"trace": trace,
"raw_mean": float(raw.mean()) if raw.size else None,
"raw_std": float(raw.std()) if raw.size else None,
"raw_drift_per_s": float(np.polyfit(
np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace
if selected_start <= stamp < selected_end]), raw, 1)[0])
if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None,
"stability_verified": verified,
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"status": "candidate" if raw.size >= 2 else "insufficient_samples",
@@ -224,6 +228,8 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"status": "exploratory", "sites": sites, "issues": issues,
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"],
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
}
+5
View File
@@ -84,6 +84,7 @@ class ManualTrial:
source: dict,
config_ref: str,
fps: float,
uv_outline: dict | None = None,
) -> Path:
if self.status in {"recording", "closing"}:
raise RuntimeError("a trial is already active")
@@ -141,7 +142,11 @@ class ManualTrial:
"reference_alignment_possible": self.reference_alignment_possible,
"reference_source": "manual_operator_confirmation",
"reference_file": "reference.csv",
"uv_outline": uv_outline,
}
if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key]
for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
raise ValueError("UV outline does not match the selected site mapping")
recorder = ExperimentRecorder(
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True)
)
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
)
from ..sensor.uv_flat import site_at, uv_origin
from ..sensor.uv_outline import outline_snapshot
from ..trial import ManualTrial, load_path
from .sensor_panel import FlatSurfaceView
from .live_force_view import LiveForceView
@@ -199,6 +200,7 @@ class CalibrationPanel(QWidget):
if self.trial.session is not None:
raise RuntimeError(t("calibration.close_before_mapping"))
self._flat = flat
self._uv_outline = outline_snapshot(flat)
self._bundle_path = bundle_path
self.flat_view.set_flat(flat)
origin_x, origin_y = uv_origin(flat)
@@ -275,6 +277,7 @@ class CalibrationPanel(QWidget):
source=source,
config_ref=self.source_panel.config_ref.text().strip(),
fps=mode.fps,
uv_outline=self._uv_outline,
)
self.views.setCurrentWidget(self.source_panel)
self.result_label.setText(
@@ -0,0 +1,30 @@
"""Shared value axes for record and hold inspection charts."""
from PySide6.QtCharts import QValueAxis
from PySide6.QtCore import Qt
def clear_chart(chart):
chart.removeAllSeries()
for axis in chart.axes():
axis.hide()
chart.removeAxis(axis)
axis.deleteLater()
def add_value_axes(chart):
points = [point for series in chart.series() for point in series.points()]
font = chart.font()
font.setPixelSize(14)
for horizontal in (True, False):
axis = QValueAxis()
axis.setLabelsFont(font)
axis.setTitleFont(font)
values = [p.x() if horizontal else p.y() for p in points]
if values:
low, high = min(values), max(values)
margin = max((high - low) * .1, .1)
axis.setRange(low - margin, high + margin)
chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
for series in chart.series():
series.attachAxis(axis)
@@ -1,19 +1,29 @@
"""Recorded-site coverage and normal response, with optional operator CSV plan."""
from pathlib import Path
from datetime import datetime
import statistics
import math
from bisect import bisect_left, bisect_right
from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTabWidget,
QSizePolicy, QToolButton, QMenu,
QGraphicsSimpleTextItem,
QScrollArea, QFrame,
)
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
from ..session_review import review_session
from ..record_diagnostics import trial_diagnostics
from ..sensor.uv_outline import recorded_outline
from .session_review_dialog import SessionReviewDialog
from .chart_helpers import add_value_axes, clear_chart
class RecordOverviewPage(QWidget):
@@ -27,12 +37,20 @@ class RecordOverviewPage(QWidget):
self.open_button = QPushButton(self)
self.open_button.setObjectName("openCalibrationRecords")
self.open_button.clicked.connect(self._open)
self.open_button.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.open_button.setStyleSheet("font-size: 14px; min-height: 22px; padding: 3px 10px;")
self.path_label = QLabel(self)
self.path_label.setWordWrap(True)
self.message = QLabel(self)
self.message.setWordWrap(True)
self.content_layout.addWidget(self.open_button)
self.content_layout.addWidget(self.path_label)
self.message.hide()
self.viewer = QScrollArea(self)
self.viewer.setWidgetResizable(True)
self.viewer.setFrameShape(QFrame.Shape.NoFrame)
header = QHBoxLayout()
header.addWidget(self.open_button)
header.addWidget(self.path_label, 1)
self.content_layout.addLayout(header)
self.content_layout.addWidget(self.message)
self.content_layout.addStretch(1)
self.retranslate()
@@ -52,19 +70,23 @@ class RecordOverviewPage(QWidget):
overview = RecordOverviewDialog(directory, self)
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
return
overview.setWindowFlags(Qt.WindowType.Widget)
if self.overview is not None:
self.content_layout.removeWidget(self.overview)
self.viewer.takeWidget()
self.overview.hide()
self.overview.deleteLater()
else:
self.content_layout.takeAt(self.content_layout.count() - 1)
self.content_layout.addWidget(self.viewer, 1)
self.overview = overview
self.directory = directory
self.path_label.setText(str(directory))
self.path_label.setText(directory.name)
self.path_label.setToolTip(str(directory))
self.message.clear()
self.content_layout.addWidget(overview, 1)
self.message.hide()
self.viewer.setWidget(overview)
overview.show()
@@ -77,38 +99,79 @@ class RecordOverviewDialog(QDialog):
super().__init__(parent)
self.directory = directory
self.review = review_session(directory)
self.diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]}
self.outline, self.outline_state = recorded_outline(self.review)
self._uv_bounds = None
self._uv_label = None
self._fitting_uv = False
self._dirty = False
self._selected_site_id = None
self.plan = None
self.rows = []
self.setWindowTitle(t("overview.title"))
self.resize(1240, 850)
self.setObjectName("recordOverview")
self.setStyleSheet("#recordOverview QLabel, #recordOverview QTableWidget, #recordOverview QComboBox, "
"#recordOverview QPushButton, #recordOverview QToolButton {font-size: 14px;} "
"#recordOverview QPushButton, #recordOverview QComboBox {min-height: 22px; padding: 3px 10px;} "
"#recordOverview QHeaderView::section {font-size: 13px;}")
layout = QVBoxLayout(self)
self.notice = QLabel(t("overview.notice"), self)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(6)
self.identity = QLabel(self)
self.identity.setWordWrap(True)
layout.addWidget(self.identity)
self.notice = QLabel(self)
self.notice.setWordWrap(True)
self.notice.setToolTip(t("overview.notice"))
layout.addWidget(self.notice)
toolbar = QHBoxLayout()
self.map_mode = QComboBox(self)
self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")])
toolbar.addWidget(self.map_mode)
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan),
("load", self._load), ("save", self._save)):
button = QPushButton(t("overview." + name), self)
button.setObjectName("overview_" + name)
button.clicked.connect(slot)
toolbar.addWidget(button)
self.map_mode.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
self.tools = QToolButton(self)
self.tools.setText(t("overview.tools"))
self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = QMenu(self.tools)
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), ("load", self._load)):
action = menu.addAction(t("overview." + name))
action.setObjectName("overview_" + name)
action.triggered.connect(slot)
self.tools.setMenu(menu)
toolbar.addWidget(self.tools)
toolbar.addStretch(1)
layout.addLayout(toolbar)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
charts = QHBoxLayout()
self.uv_chart, self.response_chart = QChart(), QChart()
self.timeline_chart = QChart()
self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart)
for view in (self.uv_view, self.response_view):
self.timeline_view = QChartView(self.timeline_chart)
for view in (self.uv_view, self.response_view, self.timeline_view):
view.setRenderHint(QPainter.RenderHint.Antialiasing)
charts.addWidget(self.uv_view, 1)
charts.addWidget(self.response_view, 2)
view.chart().setMinimumSize(0, 0)
view.setMinimumHeight(260)
font = view.chart().font()
font.setPixelSize(14)
view.chart().setFont(font)
uv_column = QVBoxLayout()
uv_column.addWidget(self.uv_view, 1)
self.outline_message = QLabel(t("overview.outline_" + self.outline_state), self)
self.outline_message.setWordWrap(True)
uv_column.addWidget(self.outline_message)
charts.addLayout(uv_column, 2)
self.plots = QTabWidget(self)
self.plots.addTab(self.timeline_view, t("overview.timeline"))
self.plots.addTab(self.response_view, t("overview.response"))
charts.addWidget(self.plots, 3)
self.uv_chart.plotAreaChanged.connect(self._fit_uv_axes)
layout.addLayout(charts, 2)
self.table = QTableWidget(0, 11, self)
self.table.setObjectName("overviewSites")
self.table.setMinimumHeight(125)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
@@ -116,28 +179,54 @@ class RecordOverviewDialog(QDialog):
"order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
for column in (0, 3, 9, 10):
self.table.setColumnHidden(column, True)
layout.addWidget(self.table, 1)
selection = QHBoxLayout()
self.trials = QComboBox(self)
self.trials.setObjectName("overviewTrial")
self.details_button = QPushButton(t("overview.details"), self)
selection.addWidget(QLabel(t("overview.trial_filter"), self))
selection.addWidget(self.trials, 1)
selection.addWidget(self.trials)
selection.addStretch(1)
selection.addWidget(self.details_button)
self.save_button = QPushButton(t("overview.save"), self)
self.save_button.setObjectName("overview_save")
self.save_button.clicked.connect(self._save)
selection.addWidget(self.save_button)
layout.addLayout(selection)
self.selected_summary = QLabel(self)
self.selected_summary.setWordWrap(True)
layout.addWidget(self.selected_summary)
self.diagnostic_summary = QLabel(self)
self.diagnostic_summary.setWordWrap(True)
layout.addWidget(self.diagnostic_summary)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
self.selected_summary.hide()
self.plots.currentChanged.connect(lambda index: self.selected_summary.setVisible(index == 1))
self.table.itemSelectionChanged.connect(self._select_site)
self.map_mode.currentIndexChanged.connect(self._show_uv)
self.trials.currentIndexChanged.connect(self._show_response)
self.trials.currentIndexChanged.connect(self._show_timeline)
self.details_button.clicked.connect(self._details)
self._refresh()
def _refresh(self):
sources = sorted({d.get("source", {}).get("kind", "") for d in self.diagnostics.values()} - {""})
date = self.review.get("started_at")
if date:
try:
date = datetime.fromisoformat(date).astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
except ValueError:
pass
self.identity.setText(t("overview.identity").format(
model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—",
date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—",
status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress"
else t("calibration." + self.review["session_status"])))
self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory))
selected = self.table.currentRow()
self.rows = overview_rows(self.review, self.plan)
self.table.blockSignals(True)
@@ -152,25 +241,31 @@ class RecordOverviewDialog(QDialog):
f"{row['confirmed_windows']}/{row['windows']}",
", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity,
"–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])]
analysis = "reviewed" if row["windows"] and row["confirmed_windows"] == row["windows"] else "pending" if row["windows"] else "no_analysis"
values[4] = t("overview.analysis_" + analysis)
for column, value in enumerate(values):
self.table.setItem(index, column, QTableWidgetItem(value))
self.table.item(index, 1).setToolTip(t("overview.site_details").format(
xyz=values[3], attempts=values[5], range=values[9], trials=values[10]))
self.table.blockSignals(False)
collected = sum(row["trials"] > 0 for row in self.rows)
self.summary.setText(t("overview.summary").format(
collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—",
missing=sum(r["status"] == "uncollected" for r in self.rows),
missing=sum(r["status"] == "uncollected" for r in self.rows) if self.plan else "—",
incomplete=sum(r["status"] == "incomplete" for r in self.rows),
pending=sum(r["status"] == "pending" for r in self.rows),
reviewed=sum(r["status"] == "reviewed" for r in self.rows),
extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—",
) + ("\n" + t("overview.no_plan") if self.plan is None else ""))
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
self.message.setText(t("overview.unsaved") if self._dirty else "")
self.message.setVisible(self._dirty)
if self.rows:
self.table.selectRow(min(max(selected, 0), len(self.rows) - 1))
self._select_site()
def _select_site(self):
index = self.table.currentRow()
previous = self.trials.currentData()
self.trials.blockSignals(True)
self.trials.clear()
self.trials.addItem(t("overview.all_trials"), None)
@@ -179,33 +274,22 @@ class RecordOverviewDialog(QDialog):
for trial in self.review["trials"]:
if site_id is not None and trial.get("site_id") == site_id:
self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"])
match = self.trials.findData(previous) if site_id == self._selected_site_id else -1
self.trials.setCurrentIndex(match if match >= 0 else 1 if self.trials.count() > 1 else 0)
self._selected_site_id = site_id
self.trials.blockSignals(False)
self._show_uv()
self._show_response()
self._show_timeline()
@staticmethod
def _clear_chart(chart):
chart.removeAllSeries()
for axis in chart.axes():
axis.hide()
chart.removeAxis(axis)
axis.deleteLater()
@staticmethod
def _axes(chart):
points = [point for series in chart.series() for point in series.points()]
for horizontal in (True, False):
axis = QValueAxis()
values = [p.x() if horizontal else p.y() for p in points]
if values:
low, high = min(values), max(values)
margin = max((high - low) * .1, .1)
axis.setRange(low - margin, high + margin)
chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
for series in chart.series():
series.attachAxis(axis)
_clear_chart = staticmethod(clear_chart)
_axes = staticmethod(add_value_axes)
def _show_uv(self):
self._uv_bounds = None
if self._uv_label is not None:
self.uv_chart.scene().removeItem(self._uv_label)
self._uv_label = None
self._clear_chart(self.uv_chart)
sensitivity = self.map_mode.currentIndex() == 1
values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]]
@@ -226,21 +310,55 @@ class RecordOverviewDialog(QDialog):
series.append(*row["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.table.selectRow(target))
self.uv_chart.addSeries(series)
if index == self.table.currentRow():
self._uv_label = QGraphicsSimpleTextItem(row["site_id"] or str(row["orders"]), self.uv_chart)
self._uv_label.setFont(self.uv_chart.font())
self._uv_label.setZValue(10)
self._uv_label_point = QPointF(*row["uv_xy_px"])
self._axes(self.uv_chart)
definition = self.review["uv_definition"]
origin = definition.get("origin_raster_xy_px")
shape = definition.get("raster_size_hw")
bounds = [tuple(row["uv_xy_px"]) for row in self.rows] + [(0, 0)]
if self.outline:
ox, oy = self.outline["origin_raster_xy_px"]
for region, color in (("surface", "#687585"), ("patch", "#719fb8")):
for ring in self.outline[region]:
series = QLineSeries()
series.setColor(QColor(color))
for x, y in ring + ring[:1]:
series.append(x - ox, oy - y)
bounds.append((x - ox, oy - y))
self.uv_chart.addSeries(series)
for axis in self.uv_chart.axes():
series.attachAxis(axis)
xs, ys = zip(*bounds)
self._uv_bounds = min(xs), max(xs), min(ys), max(ys)
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)")
if origin and shape:
axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5)
axis.setLabelFormat("%.0f")
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)")
if origin and shape:
axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5)
axis.setLabelFormat("%.0f")
self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high)
if sensitivity else t("overview.coverage_map"))
self._fit_uv_axes()
def _fit_uv_axes(self, *_args):
area = self.uv_chart.plotArea()
if self._fitting_uv or self._uv_bounds is None or area.width() <= 0 or area.height() <= 0:
return
self._fitting_uv = True
try:
left, right, bottom, top = self._uv_bounds
scale = max(max(right - left, 2) / area.width(), max(top - bottom, 2) / area.height()) * 1.12
for horizontal in (True, False):
axis = self.uv_chart.axes(Qt.Orientation.Horizontal if horizontal else Qt.Orientation.Vertical)[0]
center = (left + right) / 2 if horizontal else (bottom + top) / 2
span = scale * (area.width() if horizontal else area.height())
axis.setRange(center - span / 2, center + span / 2)
if self._uv_label is not None:
self._uv_label.setPos(self.uv_chart.mapToPosition(self._uv_label_point) + QPointF(8, -20))
finally:
self._fitting_uv = False
def _show_response(self):
self._clear_chart(self.response_chart)
@@ -249,7 +367,12 @@ class RecordOverviewDialog(QDialog):
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
trial_id = self.trials.currentData()
windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else []
self.details_button.setEnabled(site is not None and bool(windows))
attempts = [t for t in self.review["trials"] if site is not None and t.get("site_id") == site_id
and (trial_id is None or t["trial_id"] == trial_id)]
self.details_button.setEnabled(bool(attempts))
self.details_button.setText(t("overview.details" if windows else "overview.inspect_incomplete"))
self.notice.setText(t("overview.guide_missing" if not attempts else "overview.guide_incomplete" if not windows
else "overview.guide_save" if self._dirty else "overview.guide_check"))
for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})):
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
for confirmed in (True, False):
@@ -286,6 +409,76 @@ class RecordOverviewDialog(QDialog):
basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—",
) if windows else t("overview.no_windows"))
def _show_timeline(self):
self._clear_chart(self.timeline_chart)
index = self.table.currentRow()
site_id = self.rows[index]["site_id"] if index >= 0 else None
trial_id = self.trials.currentData()
reports = [self.diagnostics[t["trial_id"]] for t in self.review["trials"]
if site_id is not None and t.get("site_id") == site_id
and (trial_id is None or t["trial_id"] == trial_id)]
summaries, reference_series = [], []
for report in reports:
trace = report["trace"]
summaries.append(t("overview.diagnostic").format(
trial=report["trial_id"], status=t("calibration." + report["status"]),
samples=len(trace), invalid=report["invalid"], dropped=report["dropped"],
gap=f"{report['max_gap_s']:.3g}" if report["max_gap_s"] is not None else "—",
reason=report["reason"] or "—"))
if report["errors"]:
summaries.append(t("overview.read_errors").format(detail=", ".join(report["errors"])))
if not trace:
continue
origin = trace[0][0]
gaps = [(b[0] - a[0]) / 1e9 for a, b in zip(trace, trace[1:]) if b[0] > a[0]]
limit = max(statistics.median(gaps) * 5, .25) if gaps else .25
series = None
previous = None
for stamp, raw in trace:
if series is None or (previous is not None and ((stamp - previous) / 1e9 > limit
or bisect_right(report["breaks_ns"], previous) < bisect_left(report["breaks_ns"], stamp))):
series = QLineSeries()
series.setName(report["trial_id"])
series.setColor(QColor("#167ac6"))
series.setPointsVisible(True)
self.timeline_chart.addSeries(series)
series.append((stamp - origin) / 1e9, raw)
previous = stamp
for ref in report["references"]:
try:
start, end = int(ref["t_start_host_mono_ns"]), int(ref["t_end_host_mono_ns"])
force = float(ref["F_ref_normal_N"])
except (KeyError, ValueError, TypeError):
continue
if end <= start or not math.isfinite(force):
continue
series = QLineSeries()
series.setColor(QColor("#df8b13"))
series.append((start - origin) / 1e9, force)
series.append((end - origin) / 1e9, force)
reference_series.append(series)
self._axes(self.timeline_chart)
for axis in self.timeline_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText(t("overview.time_axis"))
for axis in self.timeline_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.raw_axis"))
if reference_series:
reference_axis = QValueAxis()
reference_axis.setTitleText(t("overview.reference_axis"))
forces = [p.y() for series in reference_series for p in series.points()]
reference_axis.setRange(min(0, min(forces)), max(1, max(forces)) * 1.1)
self.timeline_chart.addAxis(reference_axis, Qt.AlignmentFlag.AlignRight)
horizontal = self.timeline_chart.axes(Qt.Orientation.Horizontal)[0]
for series in reference_series:
self.timeline_chart.addSeries(series)
series.attachAxis(horizontal)
series.attachAxis(reference_axis)
self.timeline_chart.legend().hide()
self.timeline_chart.setTitle(t("overview.timeline_title") if any(r["trace"] for r in reports) else t("overview.no_trace"))
self.selected_summary.setToolTip("\n".join(summaries))
self.diagnostic_summary.setText("\n".join(summaries) if len(reports) == 1
else t("overview.choose_trial") if reports else t("overview.guide_missing"))
def _import_plan(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)")
if path:
@@ -293,17 +486,26 @@ class RecordOverviewDialog(QDialog):
plan = read_plan(Path(path), self.review["uv_definition"])
overview_rows(self.review, plan)
self.plan = plan
self._dirty = True
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
def _clear_plan(self):
self.plan = None
self._dirty = True
self._refresh()
def _details(self):
row = self.rows[self.table.currentRow()]
index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"])
trial = self.trials.currentData()
if not any(trial is None or w["trial_id"] == trial for w in self.review["sites"][index]["windows"]):
self.plots.setCurrentIndex(0)
self.notice.setText(t("overview.guide_incomplete"))
return
before = dict(self.review["selections"])
dialog = SessionReviewDialog(self.directory, self, review=self.review)
dialog.sites.setCurrentIndex(index)
trial = self.trials.currentData()
@@ -314,14 +516,20 @@ class RecordOverviewDialog(QDialog):
dialog.table.selectRow(match)
dialog.exec()
self.review = dialog.review
self._dirty = self._dirty or before != self.review["selections"]
self._refresh()
def _save(self):
try:
path = save_overview(self.review, self.plan)
self.message.setText(t("review.saved").format(path=path))
self._dirty = False
self._show_response()
self.message.setText(t("overview.saved"))
self.message.show()
self.message.setToolTip(str(path))
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
@@ -329,6 +537,8 @@ class RecordOverviewDialog(QDialog):
try:
review, plan = load_overview(self.directory, Path(path))
self.review, self.plan = review, plan
self._dirty = False
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
@@ -10,10 +10,11 @@ from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog,
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget,
)
from ..session_review import review_session, save_review, load_review
from .chart_helpers import add_value_axes, clear_chart
class SessionReviewDialog(QDialog):
@@ -23,6 +24,10 @@ class SessionReviewDialog(QDialog):
super().__init__(parent)
self.setWindowTitle(t("review.title"))
self.resize(1080, 760)
self.setObjectName("holdInspection")
self.setStyleSheet("#holdInspection QLabel, #holdInspection QPushButton, #holdInspection QComboBox, "
"#holdInspection QTableWidget, #holdInspection QCheckBox {font-size: 14px;} "
"#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}")
self.review = review if review is not None else review_session(directory)
self.directory = directory
layout = QVBoxLayout(self)
@@ -41,6 +46,12 @@ class SessionReviewDialog(QDialog):
row.addWidget(self.save_button)
row.addWidget(self.load_button)
layout.addLayout(row)
self.pages = QTabWidget(self)
hold_page, response_page = QWidget(self), QWidget(self)
hold_layout, response_layout = QVBoxLayout(hold_page), QVBoxLayout(response_page)
self.pages.addTab(hold_page, t("overview.details"))
self.pages.addTab(response_page, t("overview.response"))
layout.addWidget(self.pages, 1)
self.summary = QLabel(self)
self.summary.setWordWrap(True)
summary_row = QHBoxLayout()
@@ -49,7 +60,7 @@ class SessionReviewDialog(QDialog):
self.quality_button.setObjectName("reviewQuality")
self.quality_button.clicked.connect(self._quality)
summary_row.addWidget(self.quality_button)
layout.addLayout(summary_row)
response_layout.addLayout(summary_row)
self.chart = QChart()
self.chart_view = QChartView(self.chart, self)
self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -59,15 +70,15 @@ class SessionReviewDialog(QDialog):
charts = QHBoxLayout()
charts.addWidget(self.uv_view, 1)
charts.addWidget(self.chart_view, 2)
layout.addLayout(charts, 1)
self.table = QTableWidget(0, 8, self)
response_layout.addLayout(charts, 1)
self.table = QTableWidget(0, 9, self)
self.table.setObjectName("reviewWindows")
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setHorizontalHeaderLabels([t(f"review.col_{key}") for key in (
"trial", "branch", "load", "samples", "mean", "std", "duration", "quality")])
"trial", "branch", "load", "samples", "mean", "std", "duration", "quality", "drift")])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.table, 1)
hold_layout.addWidget(self.table, 1)
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
editor = QHBoxLayout()
@@ -84,11 +95,11 @@ class SessionReviewDialog(QDialog):
editor.addWidget(self.end_s)
editor.addWidget(self.verified)
editor.addWidget(self.apply_button)
layout.addLayout(editor)
hold_layout.addLayout(editor)
self.trace_chart = QChart()
self.trace_view = QChartView(self.trace_chart, self)
self.trace_view.setMinimumHeight(130)
layout.addWidget(self.trace_view, 1)
self.trace_view.setMinimumHeight(240)
hold_layout.addWidget(self.trace_view, 2)
self.message = QLabel(self)
self.message.setWordWrap(True)
layout.addWidget(self.message)
@@ -102,9 +113,7 @@ class SessionReviewDialog(QDialog):
def _show_site(self) -> None:
index = self.sites.currentIndex()
self.quality_button.setEnabled(index >= 0)
self.chart.removeAllSeries()
for axis in self.chart.axes():
self.chart.removeAxis(axis)
clear_chart(self.chart)
if index < 0:
self.summary.setText(t("review.no_sites"))
return
@@ -112,14 +121,14 @@ class SessionReviewDialog(QDialog):
windows = site["windows"]
self.table.setRowCount(len(windows))
for row, window in enumerate(windows):
quality = t("review." + window["status"])
quality = t("review." + window["status"]) if window["status"] != "candidate" else t(
"review.verified" if window["stability_verified"] else "review.unverified")
if not window["reference_alignment_possible"]:
quality += " · " + t("review.replay")
quality += " · " + t("review.verified" if window["stability_verified"] else "review.unverified")
values = [window["trial_id"], t("calibration." + window["branch"]),
f"{window['reference_n']:g}", str(window["samples"]),
self._number(window["raw_mean"]), self._number(window["raw_std"]),
f"{window['duration_s']:g}", quality]
f"{window['duration_s']:g}", quality, self._number(window["raw_drift_per_s"])]
for column, value in enumerate(values):
self.table.setItem(row, column, QTableWidgetItem(value))
fit = site["confirmed_fit"] or site["fit"]
@@ -144,12 +153,11 @@ class SessionReviewDialog(QDialog):
for value in (min(raw), max(raw)):
line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"])
self.chart.addSeries(line)
self.chart.createDefaultAxes()
add_value_axes(self.chart)
for axis in self.chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText(t("review.raw_axis"))
for axis in self.chart.axes(Qt.Orientation.Vertical):
axis.setTitleText(t("review.force_axis"))
self._pad_axes(self.chart)
self.chart.setTitle(t("review.chart_title"))
self._show_uv(index)
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
@@ -172,9 +180,7 @@ class SessionReviewDialog(QDialog):
def _show_window(self):
window = self._current_window()
self.trace_chart.removeAllSeries()
for axis in self.trace_chart.axes():
self.trace_chart.removeAxis(axis)
clear_chart(self.trace_chart)
self.apply_button.setEnabled(window is not None)
if window is None:
return
@@ -199,7 +205,7 @@ class SessionReviewDialog(QDialog):
line.append((stamp - origin) / 1e9, low - margin)
line.append((stamp - origin) / 1e9, high + margin)
self.trace_chart.addSeries(line)
self.trace_chart.createDefaultAxes()
add_value_axes(self.trace_chart)
for axis in self.trace_chart.axes(Qt.Orientation.Horizontal):
axis.setRange(0, duration)
axis.setTitleText(t("review.hold_time_axis"))
@@ -243,9 +249,7 @@ class SessionReviewDialog(QDialog):
self.message.setText(str(exc))
def _show_uv(self, selected: int) -> None:
self.uv_chart.removeAllSeries()
for axis in self.uv_chart.axes():
self.uv_chart.removeAxis(axis)
clear_chart(self.uv_chart)
fits = [s["confirmed_fit"] or s["fit"] for s in self.review["sites"]]
values = [fit["sensitivity_raw_per_n"] for fit in fits if fit]
low, high = (min(values), max(values)) if values else (0, 0)
@@ -263,22 +267,14 @@ class SessionReviewDialog(QDialog):
series.append(*site["uv_xy_px"])
series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target))
self.uv_chart.addSeries(series)
self.uv_chart.createDefaultAxes()
add_value_axes(self.uv_chart)
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
axis.setTitleText("U (px)")
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
axis.setTitleText("V (px)")
self._pad_axes(self.uv_chart)
self.uv_chart.legend().hide()
self.uv_chart.setTitle(t("review.uv_title").format(low=low, high=high))
@staticmethod
def _pad_axes(chart: QChart) -> None:
for axis in chart.axes():
low, high = axis.min(), axis.max()
margin = max((high - low) * .1, .1)
axis.setRange(low - margin, high + margin)
@staticmethod
def _number(value) -> str:
return "—" if value is None else f"{value:.6g}"