修复 Autocal 压头配置、姿态保存与复核性能
阻止不完整标定配置生成 Z 目标,同步当前测点姿态,并将复核文件校验移到后台。通过 476 项全量测试、53 项最终定向复验和代码检查。
This commit is contained in:
@@ -326,6 +326,20 @@ class Campaign:
|
||||
raise ValueError("the solved pose is outside the stage travel; it is stored but not commanded")
|
||||
return {key: float(pose[key]) for key in ("x", "y", "yaw", "pitch", "z")}
|
||||
|
||||
def store_solved_pose(self, site_id: str, pose: dict) -> None:
|
||||
"""Update the current site pose without rewriting past repeat records."""
|
||||
if self.is_active and site_id != self.pending_alignment_site:
|
||||
raise RuntimeError("cannot change a pose during an active trial")
|
||||
site = self.sites[self._index(site_id)]
|
||||
record = None if self._document is None else next(
|
||||
(row for row in self._document["sites"] if row["site_id"] == site_id), None)
|
||||
if site.pose == pose and (record is None or record["pose"] == pose):
|
||||
return
|
||||
site.pose = dict(pose)
|
||||
if record is not None:
|
||||
record["pose"] = site.pose
|
||||
self._save()
|
||||
|
||||
def set_force_rotation(self, values: list[float]) -> None:
|
||||
"""Store the force-sensor-to-tool rotation. There is no default matrix."""
|
||||
self._rotation = parse_rotation(list(values))
|
||||
@@ -623,6 +637,13 @@ class Campaign:
|
||||
elapsed = (time.perf_counter_ns() - self._hold_started_ns) / 1e9
|
||||
return max(0.0, self._hold_s - elapsed)
|
||||
|
||||
@property
|
||||
def pending_alignment_site(self) -> str:
|
||||
"""The next point awaiting alignment between recorded trials."""
|
||||
if self.phase != "liftoff" or self._single_site:
|
||||
return ""
|
||||
return self.sites[self._site_index].site_id
|
||||
|
||||
def progress(self) -> dict:
|
||||
site = self.sites[self._site_index] if self.sites and self._site_index < len(self.sites) else None
|
||||
step = self._steps[self._step_index] if self._step_index < len(self._steps) else None
|
||||
|
||||
@@ -274,7 +274,6 @@ _MERGED_BLOCKS = (
|
||||
"safety_limits",
|
||||
"sensor_setup",
|
||||
"sequence_profile",
|
||||
"press_axis",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -520,6 +520,61 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def reselect_review(result: dict, selections: dict) -> dict:
|
||||
"""Adjust loaded hold traces; saving still verifies all acquired files afresh."""
|
||||
from copy import deepcopy
|
||||
|
||||
updated = dict(result)
|
||||
updated["sites"] = [{**site, "windows": [dict(window) for window in site["windows"]]}
|
||||
for site in result["sites"]]
|
||||
used = set()
|
||||
for site in updated["sites"]:
|
||||
for window in site["windows"]:
|
||||
key = f"{window['trial_id']}:{window['step_index']}"
|
||||
choice = selections.get(key)
|
||||
start, end = window["hold_start_host_mono_ns"], window["hold_end_host_mono_ns"]
|
||||
verified = False
|
||||
if choice is not None:
|
||||
used.add(key)
|
||||
start, end = int(choice["t_start_host_mono_ns"]), int(choice["t_end_host_mono_ns"])
|
||||
verified = choice["stability_verified"] is True
|
||||
if not window["hold_start_host_mono_ns"] <= start < end <= window["hold_end_host_mono_ns"]:
|
||||
raise ValueError(f"selection outside recorded hold: {key}")
|
||||
trace = [(stamp, value) for stamp, _, value in window["trace"] if start <= stamp < end]
|
||||
raw = np.array([value for _, value in trace], dtype=float)
|
||||
if verified and raw.size < 2:
|
||||
raise ValueError(f"not enough valid samples to confirm: {key}")
|
||||
status = window["status"]
|
||||
if status in {"candidate", "insufficient_samples"}:
|
||||
status = "candidate" if raw.size >= 2 else "insufficient_samples"
|
||||
window.update(
|
||||
t_start_host_mono_ns=start, t_end_host_mono_ns=end,
|
||||
duration_s=(end - start) / 1e9, samples=int(raw.size),
|
||||
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 - start) / 1e9 for stamp, _ in trace]), raw, 1)[0])
|
||||
if raw.size >= 2 and len({stamp for stamp, _ in trace}) >= 2 else None,
|
||||
stability_verified=verified, status=status,
|
||||
)
|
||||
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
|
||||
and w["status"] == "candidate"]
|
||||
site.update(fit=_fit(training),
|
||||
confirmed_fit=_fit([w for w in training if w["stability_verified"]]),
|
||||
quality=_quality(site["windows"]))
|
||||
if updated.get("source_format") == MULTIPOINT_FORMAT:
|
||||
site.update(_multipoint_site(site, site["windows"]))
|
||||
site["blockers"] = sorted({
|
||||
*[b for b in site["blockers"] if not b.startswith("unverified_window:") and b != "no_fit"],
|
||||
*[f"unverified_window:{w['step_index']}" for w in site["windows"] if not w["stability_verified"]],
|
||||
*(["no_fit"] if site["fit"] is None else []),
|
||||
})
|
||||
if used != set(selections):
|
||||
raise ValueError("selection refers to an unavailable hold window")
|
||||
updated["selections"] = deepcopy(selections)
|
||||
return updated
|
||||
|
||||
|
||||
def load_review(directory: Path, review_path: Path) -> dict:
|
||||
"""Recompute saved selections only when the acquired inputs still match."""
|
||||
saved = json.loads(review_path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -353,8 +353,7 @@ class MultipointPanel(QWidget):
|
||||
|
||||
def _store_solved_pose(self, site_id: str, pose: dict) -> None:
|
||||
"""Record a fresh solve of a point without retaking the point itself."""
|
||||
site = next(item for item in self.campaign.sites if item.site_id == site_id)
|
||||
site.pose = pose
|
||||
self.campaign.store_solved_pose(site_id, pose)
|
||||
|
||||
def _require_same_point(self, site, point, normal) -> None:
|
||||
"""Refuse a point the current map no longer puts where the record says.
|
||||
|
||||
@@ -8,7 +8,7 @@ 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, QPointF
|
||||
from PySide6.QtCore import Qt, QPointF, Slot
|
||||
from PySide6.QtGui import QColor, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
from .review_job import ReviewJob
|
||||
|
||||
|
||||
class RecordOverviewPage(QWidget):
|
||||
@@ -32,6 +33,7 @@ class RecordOverviewPage(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.overview = None
|
||||
self._job = None
|
||||
self.directory = Path.cwd()
|
||||
self.content_layout = QVBoxLayout(self)
|
||||
self.open_button = QPushButton(self)
|
||||
@@ -66,8 +68,31 @@ class RecordOverviewPage(QWidget):
|
||||
self.load_session(Path(path))
|
||||
|
||||
def load_session(self, directory: Path):
|
||||
if self._job is not None:
|
||||
return
|
||||
self._loading_directory = directory
|
||||
def read():
|
||||
review = review_session(directory)
|
||||
diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial)
|
||||
for trial in review["trials"]}
|
||||
return review, diagnostics
|
||||
self.open_button.setEnabled(False)
|
||||
self.message.setText(t("review.checking"))
|
||||
self.message.show()
|
||||
self._job = ReviewJob(read, self._loaded)
|
||||
self._job.start()
|
||||
|
||||
@Slot(object, object)
|
||||
def _loaded(self, value, error):
|
||||
self._job = None
|
||||
self.open_button.setEnabled(True)
|
||||
if error is not None:
|
||||
self.message.setText(t("calibration.error").format(detail=error))
|
||||
self.message.show()
|
||||
return
|
||||
directory = self._loading_directory
|
||||
try:
|
||||
overview = RecordOverviewDialog(directory, self)
|
||||
overview = RecordOverviewDialog(directory, self, review=value[0], diagnostics=value[1])
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
self.message.show()
|
||||
@@ -95,11 +120,13 @@ class RecordOverviewDialog(QDialog):
|
||||
if self.isWindow():
|
||||
super().reject()
|
||||
|
||||
def __init__(self, directory: Path, parent=None):
|
||||
def __init__(self, directory: Path, parent=None, *, review=None, diagnostics=None):
|
||||
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._job = None
|
||||
self.review = review if review is not None else review_session(directory)
|
||||
self.diagnostics = diagnostics if diagnostics is not None else {
|
||||
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
|
||||
@@ -520,25 +547,39 @@ class RecordOverviewDialog(QDialog):
|
||||
self._refresh()
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
path = save_overview(self.review, self.plan)
|
||||
review, plan = self.review, self.plan
|
||||
self._start_job("save", lambda: save_overview(review, plan))
|
||||
|
||||
@Slot(object, object)
|
||||
def _job_finished(self, value, error):
|
||||
self._job = None
|
||||
self.setEnabled(True)
|
||||
if error is not None:
|
||||
self.message.setText(t("calibration.error").format(detail=error))
|
||||
self.message.show()
|
||||
elif self._job_kind == "save":
|
||||
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()
|
||||
self.message.setToolTip(str(value))
|
||||
else:
|
||||
self.review, self.plan = value
|
||||
self._dirty = False
|
||||
self._refresh()
|
||||
|
||||
def _start_job(self, kind, action):
|
||||
if self._job is not None:
|
||||
return
|
||||
self._job_kind = kind
|
||||
self.setEnabled(False)
|
||||
self.message.setText(t("review.checking"))
|
||||
self.message.show()
|
||||
self._job = ReviewJob(action, self._job_finished)
|
||||
self._job.start()
|
||||
|
||||
def _load(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
|
||||
if path:
|
||||
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()
|
||||
directory = self.directory
|
||||
self._start_job("load", lambda: load_overview(directory, Path(path)))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Run recording reads and integrity checks without blocking Qt's event loop."""
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
|
||||
|
||||
class _Result(QObject):
|
||||
ready = Signal(object, object)
|
||||
|
||||
|
||||
class ReviewJob(QRunnable):
|
||||
def __init__(self, action, receiver):
|
||||
super().__init__()
|
||||
self.action = action
|
||||
self.result = _Result()
|
||||
self.result.ready.connect(receiver)
|
||||
|
||||
def start(self):
|
||||
QThreadPool.globalInstance().start(self)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
value = self.action()
|
||||
except Exception as exc:
|
||||
# Worker failures must return to Qt so controls are always restored.
|
||||
self.result.ready.emit(None, str(exc))
|
||||
else:
|
||||
self.result.ready.emit(value, None)
|
||||
@@ -6,14 +6,15 @@ from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries
|
||||
from PySide6.QtCore import Qt, QPointF
|
||||
from PySide6.QtCore import Qt, QPointF, Slot
|
||||
from PySide6.QtGui import QColor, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget,
|
||||
)
|
||||
|
||||
from ..session_review import review_session, save_review, load_review
|
||||
from ..session_review import review_session, save_review, load_review, reselect_review
|
||||
from .review_job import ReviewJob
|
||||
from .chart_helpers import add_value_axes, clear_chart
|
||||
|
||||
|
||||
@@ -30,6 +31,8 @@ class SessionReviewDialog(QDialog):
|
||||
"#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}")
|
||||
self.review = review if review is not None else review_session(directory)
|
||||
self.directory = directory
|
||||
self._job = None
|
||||
self._job_kind = ""
|
||||
layout = QVBoxLayout(self)
|
||||
self.notice = QLabel(t("review.notice"), self)
|
||||
self.notice.setWordWrap(True)
|
||||
@@ -227,9 +230,7 @@ class SessionReviewDialog(QDialog):
|
||||
}
|
||||
row = self.table.currentRow()
|
||||
try:
|
||||
updated = review_session(self.directory, selections=selections)
|
||||
if updated["input_sha256"] != self.review["input_sha256"]:
|
||||
raise ValueError("recorded inputs changed since this review was opened")
|
||||
updated = reselect_review(self.review, selections)
|
||||
self.review = updated
|
||||
self._show_site()
|
||||
self.table.selectRow(row)
|
||||
@@ -242,11 +243,8 @@ class SessionReviewDialog(QDialog):
|
||||
path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"),
|
||||
str(self.directory / "derived"), "JSON (*.json)")
|
||||
if path:
|
||||
try:
|
||||
self.review = load_review(self.directory, Path(path))
|
||||
self._show_site()
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
self.message.setText(str(exc))
|
||||
directory = self.directory
|
||||
self._start_job("load", lambda: load_review(directory, Path(path)))
|
||||
|
||||
def _show_uv(self, selected: int) -> None:
|
||||
clear_chart(self.uv_chart)
|
||||
@@ -280,8 +278,28 @@ class SessionReviewDialog(QDialog):
|
||||
return "—" if value is None else f"{value:.6g}"
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
path = save_review(self.review)
|
||||
self.message.setText(t("review.saved").format(path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
review = self.review
|
||||
self._start_job("save", lambda: save_review(review))
|
||||
|
||||
def _start_job(self, kind, action):
|
||||
if self._job is not None:
|
||||
return
|
||||
self._job_kind = kind
|
||||
self._job = ReviewJob(action, self._job_finished)
|
||||
self.message.setText(t("review.checking"))
|
||||
for button in (self.save_button, self.load_button, self.apply_button):
|
||||
button.setEnabled(False)
|
||||
self._job.start()
|
||||
|
||||
@Slot(object, object)
|
||||
def _job_finished(self, value, error):
|
||||
self._job = None
|
||||
for button in (self.save_button, self.load_button, self.apply_button):
|
||||
button.setEnabled(True)
|
||||
if error is not None:
|
||||
self.message.setText(t("calibration.error").format(detail=error))
|
||||
elif self._job_kind == "load":
|
||||
self.review = value
|
||||
self._show_site()
|
||||
else:
|
||||
self.message.setText(t("review.saved").format(path=value))
|
||||
|
||||
Reference in New Issue
Block a user