feat(autocal): 完善标定记录检查流程与UV轮廓显示
补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user