修复 Autocal 压头配置、姿态保存与复核性能

阻止不完整标定配置生成 Z 目标,同步当前测点姿态,并将复核文件校验移到后台。通过 476 项全量测试、53 项最终定向复验和代码检查。
This commit is contained in:
ykcheungab
2026-10-15 07:51:39 +08:00
parent bcc55a3b97
commit ad049d12e4
16 changed files with 388 additions and 38 deletions
@@ -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))