统一实时标定身份解析并规范行尾
控制器与两个对话框共用 CalibrationService.read_identity,解析失败与缺 SN 分开上报; 本轮改动文件统一 LF,SN 必填回归已锁定。 定向 73 项、校准 UI 8 项与 lint 通过;未跑全量门禁。
This commit is contained in:
@@ -291,6 +291,10 @@
|
||||
"mode_replay_tip": "The charts read the open recording; capture and recording continue",
|
||||
"mode_recomputed": "Recomputed from this video (browsing)",
|
||||
"mode_recomputed_tip": "Curves come from this analysis, not forces.csv.",
|
||||
"field_experiment": "Experimental model field_gain/1: weighted unloaded baseline {baseline} N ({frames} frames), mean extrapolation {extrapolated}%",
|
||||
"field_experiment_tip": "This is an experimental estimate, not a validated local pressure or total force. Model fingerprint {fingerprint}; strategy {strategy}; unloaded reference {reference}; HHD grid {grid}, downsample {downsample}",
|
||||
"field_unknown": "Unknown experiment source: this result carries no field diagnostics (probably an older cached result)",
|
||||
"field_unknown_tip": "Older caches do not record the weighted baseline or per-frame extrapolation; re-run the analysis for full diagnostics",
|
||||
"mode_no_trace": "No force trace — {reason}",
|
||||
"mode_no_trace_tip": "There is nothing to plot, and the reason is a fact about the input rather than a loading state",
|
||||
"trace_not_analysed": "this video has not been analysed yet",
|
||||
@@ -358,6 +362,7 @@
|
||||
"device_missing_sn": "This calibration file has no SN, so it cannot be matched to a sensor",
|
||||
"device_mismatch": "The calibration file's SN is {sn}, which is not the device id entered",
|
||||
"device_family": "This calibration belongs to {family}, not Finger",
|
||||
"device_unreadable": "This calibration file cannot be read, so the sensor it belongs to cannot be confirmed ({error})",
|
||||
"reference": "Reference frame",
|
||||
"reference_time": "{seconds} s",
|
||||
"reference_missing": "The recording has no frame {frame}.",
|
||||
|
||||
@@ -291,6 +291,10 @@
|
||||
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
|
||||
"mode_recomputed": "从这段视频重算(浏览)",
|
||||
"mode_recomputed_tip": "曲线来自这次分析,不是 forces.csv。",
|
||||
"field_experiment": "实验模型 field_gain/1:加权空载基线 {baseline} N({frames} 帧),平均外推 {extrapolated}%",
|
||||
"field_experiment_tip": "这是一个实验估计,不是已验证的局部压力或总力。模型指纹 {fingerprint};策略 {strategy};无载参考 {reference};HHD 网格 {grid},downsample {downsample}",
|
||||
"field_unknown": "实验来源未知:这个结果没有 field 诊断(可能是旧版本缓存)",
|
||||
"field_unknown_tip": "旧缓存不记录加权基线与逐帧外推比例;重跑分析可得到完整诊断",
|
||||
"mode_no_trace": "没有力曲线 — {reason}",
|
||||
"mode_no_trace_tip": "没有可画的东西,而原因是输入本身的事实,不是一种加载状态",
|
||||
"trace_not_analysed": "这段视频还没有分析过",
|
||||
@@ -358,6 +362,7 @@
|
||||
"device_missing_sn": "这份标定文件没有 SN,无法确认它属于哪台传感器",
|
||||
"device_mismatch": "标定文件的 SN 是 {sn},与填写的设备编号不一致",
|
||||
"device_family": "这份标定属于 {family},不能用于 Finger",
|
||||
"device_unreadable": "这份标定文件无法读取,不能确认它属于哪台传感器({error})",
|
||||
"reference": "参考帧",
|
||||
"reference_time": "{seconds} 秒",
|
||||
"reference_missing": "录制中没有第 {frame} 帧。",
|
||||
|
||||
@@ -46,6 +46,7 @@ from orisys_studio.acquire_presenter import AcquirePresenter
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.display_prefs import DisplayPrefsController
|
||||
from orisys_studio.frame_view import FrameView
|
||||
from orisys_studio.live_calibration import LiveCalibrationController
|
||||
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
||||
from orisys_studio.replay import ReplayEngine
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
@@ -211,8 +212,6 @@ class StudioMainWindow(QMainWindow):
|
||||
#: frame that was already in flight cannot repaint the panels they paused on.
|
||||
self._display_paused = False
|
||||
self._active_calibration_id = ""
|
||||
#: Last coefficients file written by the calibration panel (not applied).
|
||||
self._last_calibration_export = ""
|
||||
#: Built once the panels exist (see below); until then the status rail
|
||||
#: treats calibration as unknown.
|
||||
self.acquire_presenter: AcquirePresenter | None = None
|
||||
@@ -234,6 +233,24 @@ class StudioMainWindow(QMainWindow):
|
||||
#: Built on first use so the pickers are parented where they belong.
|
||||
self.sensor_dialog = None
|
||||
self.status_presenter = StatusPresenter(self.toolbar)
|
||||
# The live calibration choice's rules (the stored per-bundle choice, the
|
||||
# profile default, the Finger serial check) live in ``live_calibration``;
|
||||
# the window keeps the forwarders the sensor dialog, the Run entry, the
|
||||
# bare-video prompt and the UI checks name.
|
||||
self.live_calibration = LiveCalibrationController(
|
||||
prefs=self.prefs,
|
||||
selected_entry=self.sensor_picker.selected_entry,
|
||||
sensor_type=self.sensor_picker.sensor_type,
|
||||
config_ref=self._resolve_config_ref,
|
||||
status=self._live_calibration_status,
|
||||
log_event=self.recorder.log_event,
|
||||
running=lambda: self._running,
|
||||
active_worker=lambda: self._active_worker,
|
||||
worker_for=self._worker_for,
|
||||
confirm=self._confirm_live_calibration,
|
||||
dialog_problem=self._live_calibration_dialog_problem,
|
||||
select_in_dialog=self._select_calibration_in_dialog,
|
||||
)
|
||||
# Bind the shared mask and chart history before controllers can deliver frames.
|
||||
self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel)
|
||||
self.mesh3d_panel.set_domain_latch(self.frame_view.domain_mask)
|
||||
@@ -1520,60 +1537,50 @@ class StudioMainWindow(QMainWindow):
|
||||
)
|
||||
|
||||
def _on_calibration_exported(self, path: str) -> None:
|
||||
"""Note the coefficients that were written. Nothing is applied here.
|
||||
"""The panel wrote coefficients: remember the file, apply nothing.
|
||||
|
||||
Exporting and applying are separate actions: writing a file must never
|
||||
change what a running device is using. The new file becomes the dialog's
|
||||
selected live calibration so Apply / the next Run can use it.
|
||||
Exporting and applying are separate actions; the rule about which file the
|
||||
next open/apply uses lives in ``live_calibration.py``.
|
||||
"""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_FILE
|
||||
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||
|
||||
self._last_calibration_export = str(path)
|
||||
self.recorder.log_event("calibration_exported", str(path))
|
||||
export = Path(path)
|
||||
_sensor_type, sn = calibration_identity(export)
|
||||
self.set_live_calibration_choice(
|
||||
role=ROLE_FILE,
|
||||
path=export,
|
||||
device_id=sn or None,
|
||||
)
|
||||
if self.sensor_dialog is not None:
|
||||
self.sensor_dialog.select_calibration_file(export)
|
||||
self.statusBar().showMessage(t("cal.export_only", path=export.name))
|
||||
self.live_calibration.note_exported(path)
|
||||
|
||||
# ------------------------------------------------- live calibration choice
|
||||
def _live_calibration_prefs_key(self) -> str | None:
|
||||
entry = self.sensor_picker.selected_entry()
|
||||
return None if entry is None else entry.ref
|
||||
# The rules live in ``live_calibration.py``; these names are the narrow
|
||||
# interface the sensor dialog, the Run entry, the bare-video prompt and the UI
|
||||
# checks already use. Each is a read or a delegation, never a second rule.
|
||||
@property
|
||||
def _last_calibration_export(self) -> str:
|
||||
"""Last coefficients file written by the calibration panel (not applied)."""
|
||||
return self.live_calibration.last_export
|
||||
|
||||
def _live_calibration_status(self, text: str) -> None:
|
||||
self.statusBar().showMessage(str(text or ""))
|
||||
|
||||
def _live_calibration_dialog_problem(self) -> str | None:
|
||||
"""The sensor dialog's current problem, or ``None`` when it is not open."""
|
||||
dialog = self.sensor_dialog
|
||||
return None if dialog is None else dialog.choice_problem()
|
||||
|
||||
def _select_calibration_in_dialog(self, path: Path) -> None:
|
||||
if self.sensor_dialog is not None:
|
||||
self.sensor_dialog.select_calibration_file(path)
|
||||
|
||||
def _confirm_live_calibration(self, title: str, text: str) -> bool:
|
||||
"""The operator's answer to one live-calibration confirmation."""
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
title,
|
||||
text,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
return answer == QMessageBox.StandardButton.Yes
|
||||
|
||||
def live_calibration_for(
|
||||
self, ref: str
|
||||
) -> tuple[str, Path | None, str] | None:
|
||||
"""Saved live choice for one catalog ref, or ``None`` when nothing was saved.
|
||||
|
||||
``None`` is not raw force: a caller that has its own default (the analysis
|
||||
prompt) keeps that default until the sensor dialog has stored a choice.
|
||||
"""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||
|
||||
store = self.prefs.get("live_calibration")
|
||||
if not ref or not isinstance(store, dict):
|
||||
return None
|
||||
entry = store.get(ref)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
role = str(entry.get("role") or ROLE_NONE)
|
||||
if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
|
||||
role = ROLE_NONE
|
||||
path: Path | None = None
|
||||
if role == ROLE_FILE:
|
||||
raw = str(entry.get("path") or "").strip()
|
||||
candidate = Path(raw) if raw else None
|
||||
if candidate is None or not candidate.is_file():
|
||||
return ROLE_NONE, None, ""
|
||||
path = candidate
|
||||
return role, path, str(entry.get("device_id") or "")
|
||||
"""Saved live choice for one catalog ref, or ``None`` when nothing was saved."""
|
||||
return self.live_calibration.for_ref(ref)
|
||||
|
||||
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
|
||||
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
|
||||
@@ -1581,13 +1588,7 @@ class StudioMainWindow(QMainWindow):
|
||||
``file_path`` is only set for the browsed-file role; the profile default is
|
||||
resolved when the chain opens. No saved choice is raw force for a live open.
|
||||
"""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_NONE
|
||||
|
||||
key = self._live_calibration_prefs_key()
|
||||
found = None if key is None else self.live_calibration_for(key)
|
||||
if found is None:
|
||||
return ROLE_NONE, None, ""
|
||||
return found
|
||||
return self.live_calibration.choice()
|
||||
|
||||
def set_live_calibration_choice(
|
||||
self,
|
||||
@@ -1597,34 +1598,7 @@ class StudioMainWindow(QMainWindow):
|
||||
device_id: str | None = None,
|
||||
) -> None:
|
||||
"""Remember the live calibration choice for the currently selected bundle."""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||
|
||||
key = self._live_calibration_prefs_key()
|
||||
if not key:
|
||||
return
|
||||
store = self.prefs.get("live_calibration")
|
||||
if not isinstance(store, dict):
|
||||
store = {}
|
||||
self.prefs["live_calibration"] = store
|
||||
wanted = str(role or ROLE_NONE)
|
||||
if wanted not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
|
||||
wanted = ROLE_NONE
|
||||
payload: dict[str, str] = {"role": wanted}
|
||||
if wanted == ROLE_FILE and path is not None and str(path).strip():
|
||||
payload["path"] = str(path)
|
||||
if device_id and str(device_id).strip():
|
||||
payload["device_id"] = str(device_id).strip()
|
||||
store[key] = payload
|
||||
save_preferences(self.prefs)
|
||||
|
||||
def _default_live_calibration_path(self) -> Path | None:
|
||||
entry = self.sensor_picker.selected_entry()
|
||||
if entry is None or entry.profile is None:
|
||||
return None
|
||||
declared = getattr(entry.profile, "default_calibration", None)
|
||||
if declared is not None and Path(declared).is_file():
|
||||
return Path(declared)
|
||||
return None
|
||||
self.live_calibration.save_choice(role=role, path=path, device_id=device_id)
|
||||
|
||||
def _resolved_live_calibration(
|
||||
self,
|
||||
@@ -1633,70 +1607,17 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
``problem`` is non-empty when a Finger file choice fails the serial check.
|
||||
"""
|
||||
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||
|
||||
role, path, device_id = self.live_calibration_choice()
|
||||
if role == ROLE_NONE:
|
||||
return None, None, ""
|
||||
if role == ROLE_DEFAULT:
|
||||
return self._default_live_calibration_path(), None, ""
|
||||
if role != ROLE_FILE or path is None:
|
||||
return None, None, ""
|
||||
entry = self.sensor_picker.selected_entry()
|
||||
is_finger = (
|
||||
entry is not None
|
||||
and str(entry.product_family or "").strip().lower() == "finger"
|
||||
)
|
||||
if not is_finger:
|
||||
return path, None, ""
|
||||
serial = str(device_id or "").strip()
|
||||
if not serial:
|
||||
return path, None, t("video_analysis.device_required")
|
||||
sensor_type, sn = calibration_identity(path)
|
||||
if sensor_type and sensor_type != "finger":
|
||||
return path, None, t("video_analysis.device_family", family=sensor_type)
|
||||
if not sn:
|
||||
return path, None, t("video_analysis.device_missing_sn")
|
||||
if sn != serial:
|
||||
return path, None, t("video_analysis.device_mismatch", sn=sn)
|
||||
return path, serial, ""
|
||||
return self.live_calibration.resolve()
|
||||
|
||||
def apply_live_calibration_choice(self) -> None:
|
||||
"""Remember the dialog's choice; hot-update the device when a session is open."""
|
||||
if self.sensor_dialog is not None:
|
||||
problem = self.sensor_dialog.choice_problem()
|
||||
if problem:
|
||||
self.statusBar().showMessage(
|
||||
t("sensor.live_calibration_invalid", reason=problem)
|
||||
)
|
||||
return
|
||||
cal_path, device_id, problem = self._resolved_live_calibration()
|
||||
if problem:
|
||||
self.statusBar().showMessage(
|
||||
t("sensor.live_calibration_invalid", reason=problem)
|
||||
)
|
||||
return
|
||||
if cal_path is None or not self._running:
|
||||
self.statusBar().showMessage(t("sensor.live_calibration_remembered"))
|
||||
return
|
||||
worker = self._active_worker
|
||||
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||
self.statusBar().showMessage(t("cal.apply_no_target"))
|
||||
return
|
||||
if device_id and hasattr(worker, "request_calibration_zero"):
|
||||
worker.set_pending_calibration(cal_path, device_id=device_id)
|
||||
else:
|
||||
worker.set_pending_calibration(cal_path)
|
||||
self.statusBar().showMessage(
|
||||
t("sensor.live_calibration_applied", name=cal_path.name)
|
||||
)
|
||||
self.live_calibration.apply_choice()
|
||||
|
||||
# ------------------------------------------------- calibration (forwards)
|
||||
# "What does this coefficient file claim, and does it match the bench?" lives in
|
||||
# ``CalibrationService`` (with the rest of the calibration file work, and without
|
||||
# Qt). The dialog and the worker hand-over stay here: they are interaction, not a
|
||||
# rule about files.
|
||||
# Qt). The dialog and the UI checker read these two names; the live calibration
|
||||
# controller calls the service directly.
|
||||
def calibration_identity(self, path: str) -> dict:
|
||||
return CalibrationService.identity_of(path)
|
||||
|
||||
@@ -1708,61 +1629,13 @@ class StudioMainWindow(QMainWindow):
|
||||
)
|
||||
|
||||
def _on_calibration_apply(self, path: str) -> None:
|
||||
"""Push freshly exported coefficients into the device — on confirmation.
|
||||
"""Confirm and request freshly exported coefficients; the device confirms.
|
||||
|
||||
The user is shown the exact target device first, and only the matching
|
||||
worker is updated. A calibration that names a *different* device or
|
||||
configuration is called out explicitly and defaults to "no".
|
||||
Identity and matching come from ``CalibrationService``, the confirmation is
|
||||
the window's, and the actual hand-over is a request to the worker: the
|
||||
recorded version still comes from the acquisition thread's confirmation.
|
||||
"""
|
||||
path = str(path or self._last_calibration_export)
|
||||
if not path:
|
||||
self.statusBar().showMessage(t("cal.apply_no_export"))
|
||||
return
|
||||
sensor_type = self.sensor_picker.sensor_type()
|
||||
config_ref = self._resolve_config_ref() or ""
|
||||
reasons = self.calibration_target_mismatch(
|
||||
path, sensor_type=sensor_type, config_ref=config_ref
|
||||
)
|
||||
if reasons:
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("cal.apply_mismatch_title"),
|
||||
t(
|
||||
"cal.apply_mismatch",
|
||||
device=self.calibration_identity(path).get("sensor_type") or "?",
|
||||
detail="; ".join(reasons),
|
||||
sensor_type=sensor_type,
|
||||
config=config_ref,
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.recorder.log_event(
|
||||
"calibration_apply_blocked", "; ".join(reasons)
|
||||
)
|
||||
self.statusBar().showMessage(t("cal.apply_mismatch_skipped"))
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("cal.apply_title"),
|
||||
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.statusBar().showMessage(t("cal.apply_skipped"))
|
||||
return
|
||||
worker = self._worker_for(sensor_type)
|
||||
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||
self.statusBar().showMessage(t("cal.apply_no_target"))
|
||||
return
|
||||
# Only a request. The acquisition thread confirms once the coefficients
|
||||
# are actually loaded; the event and the recorded version come from there.
|
||||
worker.set_pending_calibration(path)
|
||||
self.statusBar().showMessage(
|
||||
t("cal.apply_pending", sensor_type=sensor_type)
|
||||
)
|
||||
self.live_calibration.request_apply(path)
|
||||
|
||||
def _on_calibration_applied(
|
||||
self, path: str, version: str, frame_id: int, mono_t: float, session: int
|
||||
|
||||
@@ -134,27 +134,40 @@ class CalibrationService:
|
||||
# match what is on the bench?" — which is the question the *apply* path has to settle
|
||||
# before coefficients measured on another sensor silently change every newton value.
|
||||
@staticmethod
|
||||
def identity_of(path: str | Path) -> dict:
|
||||
"""What device a coefficient file says it belongs to.
|
||||
def read_identity(path: str | Path) -> tuple[dict, str]:
|
||||
"""What device a coefficient file says it belongs to, and why not.
|
||||
|
||||
Read with the SDK's own parser so the check sees the same document the device
|
||||
would load. A file that cannot be read yields no identity, and the caller then
|
||||
applies it without a mismatch claim.
|
||||
One strict parse answers both questions: ``error`` is the SDK's own one-line
|
||||
reason when the document cannot be loaded, and ``""`` when it can. A document
|
||||
that parses without an SN yields an identity with empty fields and no error —
|
||||
"cannot be read" and "has no SN" must not be reported as the same thing.
|
||||
"""
|
||||
try:
|
||||
import orisys.calibration as sdk_calibration
|
||||
|
||||
document = sdk_calibration.load(str(path), strict=True)
|
||||
except Exception: # noqa: BLE001 - reported by the apply path itself
|
||||
except Exception as exc: # noqa: BLE001 - reported to the caller
|
||||
logger.exception("Could not read calibration identity from %s", path)
|
||||
return {}
|
||||
reason = " ".join(str(exc).split()) or type(exc).__name__
|
||||
return {}, reason
|
||||
identity = dict(getattr(document, "identity", {}) or {})
|
||||
pipeline = dict(getattr(document, "pipeline", {}) or {})
|
||||
return {
|
||||
"sensor_type": str(identity.get("sensor_type") or ""),
|
||||
"sn": str(identity.get("sn") or ""),
|
||||
"config_ref": str(pipeline.get("config_ref") or ""),
|
||||
}
|
||||
}, ""
|
||||
|
||||
@staticmethod
|
||||
def identity_of(path: str | Path) -> dict:
|
||||
"""What device a coefficient file says it belongs to.
|
||||
|
||||
Read with the SDK's own parser so the check sees the same document the device
|
||||
would load. A file that cannot be read yields no identity, and the caller then
|
||||
applies it without a mismatch claim. Callers that show *why* it could not be
|
||||
read use :meth:`read_identity` instead.
|
||||
"""
|
||||
return CalibrationService.read_identity(path)[0]
|
||||
|
||||
@staticmethod
|
||||
def same_path(first: str, second: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""The live calibration choice: which coefficients the next live run opens with.
|
||||
|
||||
The window used to answer this: the per-bundle choice stored in the preferences,
|
||||
the file a bundle's profile declares as its default, and whether a browsed Finger
|
||||
file names the device the operator typed. Those are rules about a stored choice
|
||||
and a file, not about the window, so they live here and are tested without one.
|
||||
|
||||
The preference payload is the same ``live_calibration`` mapping the sensor dialog
|
||||
has always written (one entry per catalog ref), saved through the same
|
||||
``save_preferences`` entry: extracting the rules must not create a second copy of
|
||||
the state.
|
||||
|
||||
The window keeps forwarders for the callers that name them — the sensor dialog's
|
||||
narrow choice interface, the Run entry, the bare-video prompt and the UI checks.
|
||||
They read and delegate to this controller; the controller is given the facts it
|
||||
reads and the actions it needs, not the window itself. File identity and matching
|
||||
reuse :class:`CalibrationService`, so a live serial check and the Apply path agree
|
||||
on whose file this is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
|
||||
#: The answers the live calibration choice can store, in the vocabulary the
|
||||
#: sensor dialog's combo uses as item data. This is the preference payload's
|
||||
#: schema; the dialog keeps the same strings as its own UI constants.
|
||||
ROLE_DEFAULT = "default"
|
||||
ROLE_FILE = "file"
|
||||
ROLE_NONE = "none"
|
||||
_ROLES = (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE)
|
||||
|
||||
|
||||
class LiveCalibrationController:
|
||||
"""The stored live calibration choice and the file rules around it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
prefs: dict,
|
||||
selected_entry: Callable[[], Any],
|
||||
sensor_type: Callable[[], str],
|
||||
config_ref: Callable[[], str | None],
|
||||
status: Callable[[str], None],
|
||||
log_event: Callable[[str, str], None],
|
||||
running: Callable[[], bool],
|
||||
active_worker: Callable[[], Any],
|
||||
worker_for: Callable[[str], Any],
|
||||
confirm: Callable[[str, str], bool],
|
||||
dialog_problem: Callable[[], str | None] | None = None,
|
||||
select_in_dialog: Callable[[Path], None] | None = None,
|
||||
save: Callable[[dict], None] = save_preferences,
|
||||
) -> None:
|
||||
#: The window's own preference mapping: the same object is read and saved,
|
||||
#: so this controller can never hold a stale copy of the state.
|
||||
self.prefs = prefs
|
||||
#: The catalog entry currently selected in the rail's picker (or ``None``).
|
||||
self._selected_entry = selected_entry
|
||||
#: What the run would open with, for the apply confirmation and the worker
|
||||
#: hand-over. These are reads, not state of this controller's.
|
||||
self._sensor_type = sensor_type
|
||||
self._config_ref = config_ref
|
||||
self._status = status
|
||||
self._log_event = log_event
|
||||
self._running = running
|
||||
self._active_worker = active_worker
|
||||
self._worker_for = worker_for
|
||||
#: Ask the operator ``(title, text) -> accepted``; the window owns the dialog.
|
||||
self._confirm = confirm
|
||||
#: ``None`` when no sensor dialog is open; otherwise its current problem.
|
||||
self._dialog_problem = dialog_problem
|
||||
#: How the sensor dialog is told about a just-exported file (may be absent).
|
||||
self._select_in_dialog = select_in_dialog
|
||||
self._save = save
|
||||
#: Last coefficients file written by the calibration panel (not applied).
|
||||
self.last_export = ""
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
def for_ref(self, ref: str) -> tuple[str, Path | None, str] | None:
|
||||
"""The saved choice for one catalog ref, or ``None`` when none was saved.
|
||||
|
||||
``None`` is not raw force: a caller with its own default (the analysis
|
||||
prompt) keeps that default until the sensor dialog stores a choice.
|
||||
"""
|
||||
store = self.prefs.get("live_calibration")
|
||||
if not ref or not isinstance(store, dict):
|
||||
return None
|
||||
entry = store.get(ref)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
role = str(entry.get("role") or ROLE_NONE)
|
||||
if role not in _ROLES:
|
||||
role = ROLE_NONE
|
||||
path: Path | None = None
|
||||
if role == ROLE_FILE:
|
||||
raw = str(entry.get("path") or "").strip()
|
||||
candidate = Path(raw) if raw else None
|
||||
if candidate is None or not candidate.is_file():
|
||||
# A remembered file that no longer exists is "no choice", not a
|
||||
# failure to open: the operator can still pick another one.
|
||||
return ROLE_NONE, None, ""
|
||||
path = candidate
|
||||
return role, path, str(entry.get("device_id") or "")
|
||||
|
||||
def choice(self) -> tuple[str, Path | None, str]:
|
||||
"""``(role, file_path_or_None, device_id)`` for the selected bundle.
|
||||
|
||||
``file_path`` is only set for the browsed-file role; the profile default is
|
||||
resolved when the chain opens. No saved choice is raw force for a live open.
|
||||
"""
|
||||
entry = self._selected_entry()
|
||||
ref = "" if entry is None else str(getattr(entry, "ref", "") or "")
|
||||
found = None if not ref else self.for_ref(ref)
|
||||
if found is None:
|
||||
return ROLE_NONE, None, ""
|
||||
return found
|
||||
|
||||
def default_path(self) -> Path | None:
|
||||
"""The calibration file the selected bundle's profile declares, if it exists."""
|
||||
entry = self._selected_entry()
|
||||
if entry is None or getattr(entry, "profile", None) is None:
|
||||
return None
|
||||
declared = getattr(entry.profile, "default_calibration", None)
|
||||
if declared is not None and Path(declared).is_file():
|
||||
return Path(declared)
|
||||
return None
|
||||
|
||||
# ----------------------------------------------------------------- writing
|
||||
def save_choice(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
path: Path | str | None = None,
|
||||
device_id: str | None = None,
|
||||
) -> None:
|
||||
"""Remember the live calibration choice for the selected bundle."""
|
||||
entry = self._selected_entry()
|
||||
key = "" if entry is None else str(getattr(entry, "ref", "") or "")
|
||||
if not key:
|
||||
return
|
||||
store = self.prefs.get("live_calibration")
|
||||
if not isinstance(store, dict):
|
||||
store = {}
|
||||
self.prefs["live_calibration"] = store
|
||||
wanted = str(role or ROLE_NONE)
|
||||
if wanted not in _ROLES:
|
||||
wanted = ROLE_NONE
|
||||
payload: dict[str, str] = {"role": wanted}
|
||||
if wanted == ROLE_FILE and path is not None and str(path).strip():
|
||||
payload["path"] = str(path)
|
||||
if device_id and str(device_id).strip():
|
||||
payload["device_id"] = str(device_id).strip()
|
||||
store[key] = payload
|
||||
self._save(self.prefs)
|
||||
|
||||
# ---------------------------------------------------------------- resolve
|
||||
def resolve(self) -> tuple[Path | None, str | None, str]:
|
||||
"""``(cal_path, device_id, problem)`` for the next open or a hot apply.
|
||||
|
||||
``problem`` is non-empty when a Finger file choice fails the serial check or
|
||||
cannot be parsed. Identity and matching come from
|
||||
:class:`CalibrationService`, the same rules the Apply path uses: one answer
|
||||
to "whose file is this", not two parsers. A parse failure is reported as
|
||||
itself, with the parser's reason; it is never confused with a missing SN.
|
||||
"""
|
||||
role, path, device_id = self.choice()
|
||||
if role == ROLE_NONE:
|
||||
return None, None, ""
|
||||
if role == ROLE_DEFAULT:
|
||||
return self.default_path(), None, ""
|
||||
if role != ROLE_FILE or path is None:
|
||||
return None, None, ""
|
||||
entry = self._selected_entry()
|
||||
is_finger = (
|
||||
entry is not None
|
||||
and str(getattr(entry, "product_family", "") or "").strip().lower()
|
||||
== "finger"
|
||||
)
|
||||
if not is_finger:
|
||||
return path, None, ""
|
||||
serial = str(device_id or "").strip()
|
||||
if not serial:
|
||||
return path, None, t("video_analysis.device_required")
|
||||
identity, error = CalibrationService.read_identity(path)
|
||||
if error:
|
||||
# "cannot be read" and "parsed without an SN" are different answers;
|
||||
# the service returns the parser's own one-line reason for the former.
|
||||
return path, None, t("video_analysis.device_unreadable", error=error)
|
||||
sensor_type = str(identity.get("sensor_type") or "")
|
||||
sn = str(identity.get("sn") or "")
|
||||
if sensor_type and sensor_type != "finger":
|
||||
return path, None, t("video_analysis.device_family", family=sensor_type)
|
||||
if not sn:
|
||||
return path, None, t("video_analysis.device_missing_sn")
|
||||
if sn != serial:
|
||||
return path, None, t("video_analysis.device_mismatch", sn=sn)
|
||||
return path, serial, ""
|
||||
|
||||
# ------------------------------------------------------------- exporting
|
||||
def note_exported(self, path: str | Path) -> Path:
|
||||
"""Note the coefficients that were written. Nothing is applied here.
|
||||
|
||||
Exporting and applying are separate actions: writing a file must never
|
||||
change what a running device is using. The new file becomes the dialog's
|
||||
selected live calibration so Apply / the next Run can use it.
|
||||
"""
|
||||
export = Path(path)
|
||||
self.last_export = str(export)
|
||||
self._log_event("calibration_exported", str(export))
|
||||
identity = CalibrationService.identity_of(export)
|
||||
self.save_choice(
|
||||
role=ROLE_FILE,
|
||||
path=export,
|
||||
device_id=str(identity.get("sn") or "") or None,
|
||||
)
|
||||
if self._select_in_dialog is not None:
|
||||
self._select_in_dialog(export)
|
||||
self._status(t("cal.export_only", path=export.name))
|
||||
return export
|
||||
|
||||
# --------------------------------------------------------------- applying
|
||||
def apply_choice(self) -> None:
|
||||
"""Remember the dialog's choice; hot-update the device when a session is open."""
|
||||
problem = None if self._dialog_problem is None else self._dialog_problem()
|
||||
if problem:
|
||||
self._status(t("sensor.live_calibration_invalid", reason=problem))
|
||||
return
|
||||
cal_path, device_id, problem = self.resolve()
|
||||
if problem:
|
||||
self._status(t("sensor.live_calibration_invalid", reason=problem))
|
||||
return
|
||||
if cal_path is None or not self._running():
|
||||
self._status(t("sensor.live_calibration_remembered"))
|
||||
return
|
||||
worker = self._active_worker()
|
||||
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||
self._status(t("cal.apply_no_target"))
|
||||
return
|
||||
if device_id and hasattr(worker, "request_calibration_zero"):
|
||||
worker.set_pending_calibration(cal_path, device_id=device_id)
|
||||
else:
|
||||
worker.set_pending_calibration(cal_path)
|
||||
self._status(t("sensor.live_calibration_applied", name=cal_path.name))
|
||||
|
||||
def request_apply(self, path: str | Path | None = None) -> None:
|
||||
"""Push freshly exported coefficients into the device — on confirmation.
|
||||
|
||||
The operator is shown the exact target device first, and only the matching
|
||||
worker is updated. A calibration that names a *different* device or
|
||||
configuration is called out explicitly and defaults to "no". The device is
|
||||
asked, not changed here: the acquisition thread confirms what it loaded and
|
||||
the record channel notes the version from that confirmation.
|
||||
"""
|
||||
path = str(path or self.last_export)
|
||||
if not path:
|
||||
self._status(t("cal.apply_no_export"))
|
||||
return
|
||||
sensor_type = self._sensor_type()
|
||||
config_ref = self._config_ref() or ""
|
||||
reasons = CalibrationService.target_mismatch(
|
||||
path, sensor_type=sensor_type, config_ref=config_ref
|
||||
)
|
||||
if reasons:
|
||||
recorded = CalibrationService.identity_of(path).get("sensor_type") or "?"
|
||||
accepted = self._confirm(
|
||||
t("cal.apply_mismatch_title"),
|
||||
t(
|
||||
"cal.apply_mismatch",
|
||||
device=recorded,
|
||||
detail="; ".join(reasons),
|
||||
sensor_type=sensor_type,
|
||||
config=config_ref,
|
||||
),
|
||||
)
|
||||
if not accepted:
|
||||
self._log_event("calibration_apply_blocked", "; ".join(reasons))
|
||||
self._status(t("cal.apply_mismatch_skipped"))
|
||||
return
|
||||
accepted = self._confirm(
|
||||
t("cal.apply_title"),
|
||||
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
||||
)
|
||||
if not accepted:
|
||||
self._status(t("cal.apply_skipped"))
|
||||
return
|
||||
worker = self._worker_for(sensor_type)
|
||||
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||
self._status(t("cal.apply_no_target"))
|
||||
return
|
||||
# Only a request. The acquisition thread confirms once the coefficients
|
||||
# are actually loaded; the event and the recorded version come from there.
|
||||
worker.set_pending_calibration(path)
|
||||
self._status(t("cal.apply_pending", sensor_type=sensor_type))
|
||||
@@ -42,8 +42,8 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||
|
||||
#: How the catalog's shape identifiers read here. One literal key per kind, so the locale scan
|
||||
#: sees them and a new kind is a visible fallback rather than a printed identifier.
|
||||
@@ -445,7 +445,8 @@ class SensorDialog(QDialog):
|
||||
return
|
||||
if path != self._device_source:
|
||||
self._device_source = path
|
||||
_sensor_type, sn = calibration_identity(path)
|
||||
identity, _error = CalibrationService.read_identity(path)
|
||||
sn = str(identity.get("sn") or "")
|
||||
if sn:
|
||||
self._syncing = True
|
||||
try:
|
||||
@@ -464,7 +465,11 @@ class SensorDialog(QDialog):
|
||||
serial = self.device_edit.text().strip()
|
||||
if not serial:
|
||||
return t("video_analysis.device_required")
|
||||
sensor_type, sn = calibration_identity(path)
|
||||
identity, error = CalibrationService.read_identity(path)
|
||||
if error:
|
||||
return t("video_analysis.device_unreadable", error=error)
|
||||
sensor_type = str(identity.get("sensor_type") or "")
|
||||
sn = str(identity.get("sn") or "")
|
||||
if sensor_type and sensor_type != "finger":
|
||||
return t("video_analysis.device_family", family=sensor_type)
|
||||
if not sn:
|
||||
|
||||
@@ -49,6 +49,7 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
#: The calibration combo's two fixed rows. The first is a *file* the profile declares; the
|
||||
@@ -58,25 +59,6 @@ ROLE_NONE = "none"
|
||||
ROLE_FILE = "file"
|
||||
|
||||
|
||||
def calibration_identity(path: Path) -> tuple[str, str]:
|
||||
"""``(sensor_type, sn)`` from a force-calibration file, or empty strings.
|
||||
|
||||
The SDK reads the same two places (``identity``, then ``meta``) when it checks
|
||||
that a chosen file belongs to the device the operator named.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return "", ""
|
||||
if not isinstance(data, dict):
|
||||
return "", ""
|
||||
meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
|
||||
identity = data.get("identity") if isinstance(data.get("identity"), dict) else {}
|
||||
sensor_type = str(identity.get("sensor_type") or meta.get("sensor_type") or "")
|
||||
sn = str(identity.get("sn") or meta.get("sn") or "")
|
||||
return sensor_type, sn
|
||||
|
||||
|
||||
class VideoAnalysisDialog(QDialog):
|
||||
"""Ask how one bare video should be analysed, and hand back the three answers."""
|
||||
|
||||
@@ -454,7 +436,8 @@ class VideoAnalysisDialog(QDialog):
|
||||
return
|
||||
if path != self._device_source:
|
||||
self._device_source = path
|
||||
_sensor_type, sn = calibration_identity(path)
|
||||
identity, _error = CalibrationService.read_identity(path)
|
||||
sn = str(identity.get("sn") or "")
|
||||
if sn:
|
||||
self.device_edit.setText(sn)
|
||||
|
||||
@@ -472,7 +455,11 @@ class VideoAnalysisDialog(QDialog):
|
||||
serial = self.device_edit.text().strip()
|
||||
if not serial:
|
||||
return t("video_analysis.device_required")
|
||||
sensor_type, sn = calibration_identity(path)
|
||||
identity, error = CalibrationService.read_identity(path)
|
||||
if error:
|
||||
return t("video_analysis.device_unreadable", error=error)
|
||||
sensor_type = str(identity.get("sensor_type") or "")
|
||||
sn = str(identity.get("sn") or "")
|
||||
if sensor_type and sensor_type != "finger":
|
||||
return t("video_analysis.device_family", family=sensor_type)
|
||||
if not sn:
|
||||
|
||||
@@ -742,6 +742,31 @@ def test_analysis_setup_reopens_the_prompt_after_the_first_pass(
|
||||
assert window.replay_ctl.available_result() is not None
|
||||
|
||||
|
||||
def _coefficient_document(directory: Path, *, sensor_type: str, sn: str) -> Path:
|
||||
"""A v2 coefficient document the SDK's own parser accepts (a real export shape).
|
||||
|
||||
The analysis prompt now reads a Finger file's identity through
|
||||
``CalibrationService`` — the strict parser — so a ``meta`` block in arbitrary
|
||||
JSON is not an identity.
|
||||
"""
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
build_export_model,
|
||||
)
|
||||
|
||||
store = CalSessionStore()
|
||||
store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10))
|
||||
store.aggregate("fn", CalSample(weight_n=5.0, raw_mean=500.0, n=10))
|
||||
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
|
||||
document = model.to_dict()
|
||||
document["identity"] = {"sensor_type": sensor_type, "sn": sn}
|
||||
document["pipeline"] = {"config_ref": "D:/lab/sensor.json"}
|
||||
path = directory / f"{sensor_type}-{sn}.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_a_finger_calibration_file_must_name_the_same_device(qt_app, tmp_path):
|
||||
"""A browsed Finger file is accepted only when the device id matches its SN."""
|
||||
import cv2
|
||||
@@ -763,11 +788,7 @@ def test_a_finger_calibration_file_must_name_the_same_device(qt_app, tmp_path):
|
||||
None,
|
||||
)
|
||||
assert finger is not None, "the catalog has no startable Finger profile"
|
||||
path = tmp_path / "DD02-14.json"
|
||||
path.write_text(
|
||||
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
path = _coefficient_document(tmp_path, sensor_type="finger", sn="DD02-14")
|
||||
dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog)
|
||||
try:
|
||||
assert dialog.select_profile(finger.ref)
|
||||
|
||||
@@ -79,3 +79,52 @@ def test_the_same_configuration_named_differently_is_not_a_mismatch(tmp_path):
|
||||
|
||||
# ...but it does have to be a path: a non-path value simply differs.
|
||||
assert CalibrationService.same_path("D:/lab/a/sensor.json", "not a path\x00") is False
|
||||
|
||||
|
||||
# --------------------------------------------------- one parse, both answers
|
||||
|
||||
|
||||
def test_read_identity_returns_the_identity_and_no_error(tmp_path):
|
||||
"""The strict parse answers with the identity *and* says it succeeded."""
|
||||
path = _calibration_file(tmp_path, sensor_type="box", config_ref="D:/lab/a/sensor.json")
|
||||
|
||||
identity, error = CalibrationService.read_identity(path)
|
||||
|
||||
assert error == ""
|
||||
assert identity == {
|
||||
"sensor_type": "box",
|
||||
"sn": "SN-A",
|
||||
"config_ref": "D:/lab/a/sensor.json",
|
||||
}
|
||||
# The old name keeps its behaviour and is a thin wrapper over the one parse.
|
||||
assert CalibrationService.identity_of(path) == identity
|
||||
|
||||
|
||||
def test_read_identity_keeps_the_parsers_reason_when_the_file_cannot_be_read(tmp_path):
|
||||
broken = tmp_path / "broken.json"
|
||||
broken.write_text(
|
||||
json.dumps({"schema_version": 2, "unit": "N", "channels": {"fn": "bogus"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
identity, error = CalibrationService.read_identity(broken)
|
||||
|
||||
assert identity == {}
|
||||
assert error
|
||||
assert "\n" not in error, "the reason must stay one line for the status bar"
|
||||
assert CalibrationService.identity_of(broken) == {}
|
||||
|
||||
|
||||
def test_read_identity_distinguishes_a_parsed_document_without_an_sn(tmp_path):
|
||||
"""A readable document that simply names no device is not a parse failure."""
|
||||
path = _calibration_file(tmp_path, sensor_type="box", config_ref="D:/lab/a/sensor.json")
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
document.pop("identity")
|
||||
document.pop("pipeline")
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
identity, error = CalibrationService.read_identity(path)
|
||||
|
||||
assert error == ""
|
||||
assert identity["sn"] == ""
|
||||
assert identity["sensor_type"] == ""
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""The live calibration choice's rules, tested without a window.
|
||||
|
||||
These are the facts the sensor dialog, the Run entry and the bare-video prompt
|
||||
read: what was remembered for one bundle, what the profile declares as its
|
||||
default, and whether a Finger file names the device the operator typed. The
|
||||
window now only forwards to this controller, so it is exercised with a dict and
|
||||
three lambdas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
build_export_model,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_studio.live_calibration import (
|
||||
ROLE_DEFAULT,
|
||||
ROLE_FILE,
|
||||
ROLE_NONE,
|
||||
LiveCalibrationController,
|
||||
)
|
||||
|
||||
|
||||
def _coefficients(directory: Path, *, sensor_type: str, sn: str) -> Path:
|
||||
"""A coefficient document the SDK's own loader accepts (a real export shape)."""
|
||||
store = CalSessionStore()
|
||||
store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10))
|
||||
store.aggregate("fn", CalSample(weight_n=5.0, raw_mean=500.0, n=10))
|
||||
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
|
||||
document = model.to_dict()
|
||||
document["identity"] = {"sensor_type": sensor_type, "sn": sn}
|
||||
document["pipeline"] = {"config_ref": "D:/lab/sensor.json"}
|
||||
path = directory / f"{sensor_type}_calibration.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _entry(ref: str, *, family: str = "box", default=None):
|
||||
profile = SimpleNamespace(default_calibration=default)
|
||||
return SimpleNamespace(ref=ref, product_family=family, profile=profile)
|
||||
|
||||
|
||||
class _Worker:
|
||||
"""The narrow acquisition-worker surface these paths use."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pending: list[tuple[str, str | None]] = []
|
||||
|
||||
def set_pending_calibration(self, path, *, device_id=None) -> None:
|
||||
self.pending.append((str(path), device_id))
|
||||
|
||||
|
||||
class _ZeroCapableWorker(_Worker):
|
||||
"""A worker that can also ask the device to zero before loading coefficients."""
|
||||
|
||||
def request_calibration_zero(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _Host:
|
||||
"""The callbacks the controller is injected with, and nothing else."""
|
||||
|
||||
def __init__(self, entry=None) -> None:
|
||||
self.entry = entry
|
||||
self.prefs: dict = {}
|
||||
self.saves = 0
|
||||
self.sensor_type = "box"
|
||||
self.config_ref = "D:/lab/sensor.json"
|
||||
self.running = False
|
||||
self.worker = None
|
||||
self.workers: dict = {}
|
||||
self.statuses: list[str] = []
|
||||
self.events: list[tuple[str, str]] = []
|
||||
self.answers: list[bool] = []
|
||||
self.confirmations: list[tuple[str, str]] = []
|
||||
self.dialog_problem: str | None = None
|
||||
self.selected: list[Path] = []
|
||||
|
||||
def controller(self) -> LiveCalibrationController:
|
||||
return LiveCalibrationController(
|
||||
prefs=self.prefs,
|
||||
selected_entry=lambda: self.entry,
|
||||
sensor_type=lambda: self.sensor_type,
|
||||
config_ref=lambda: self.config_ref,
|
||||
status=self.statuses.append,
|
||||
log_event=lambda name, detail: self.events.append((name, detail)),
|
||||
running=lambda: self.running,
|
||||
active_worker=lambda: self.worker,
|
||||
worker_for=lambda sensor_type: self.workers.get(
|
||||
sensor_type, self.worker
|
||||
),
|
||||
confirm=self._confirm,
|
||||
dialog_problem=lambda: self.dialog_problem,
|
||||
select_in_dialog=self.selected.append,
|
||||
save=self._save,
|
||||
)
|
||||
|
||||
def _save(self, prefs: dict) -> None:
|
||||
self.saves += 1
|
||||
assert prefs is self.prefs
|
||||
|
||||
def _confirm(self, title: str, text: str) -> bool:
|
||||
self.confirmations.append((title, text))
|
||||
return self.answers.pop(0) if self.answers else False
|
||||
|
||||
|
||||
def test_no_saved_choice_is_not_the_same_as_raw_force():
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
|
||||
assert controller.choice() == (ROLE_NONE, None, "")
|
||||
assert controller.for_ref("shipped/box") is None
|
||||
assert controller.resolve() == (None, None, "")
|
||||
|
||||
|
||||
def test_a_file_choice_is_remembered_per_bundle(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
|
||||
assert controller.for_ref("shipped/box") == (ROLE_FILE, cal, "")
|
||||
assert controller.for_ref("shipped/finger") is None
|
||||
assert host.prefs["live_calibration"]["shipped/box"]["path"] == str(cal)
|
||||
assert host.saves == 1
|
||||
|
||||
|
||||
def test_a_remembered_file_that_vanished_reads_as_no_choice(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal, device_id="SN-A")
|
||||
cal.unlink()
|
||||
|
||||
assert controller.for_ref("shipped/box") == (ROLE_NONE, None, "")
|
||||
assert controller.choice() == (ROLE_NONE, None, "")
|
||||
|
||||
|
||||
def test_a_profile_default_is_resolved_when_it_is_asked_for(tmp_path):
|
||||
default = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
host = _Host(_entry("shipped/box", default=default))
|
||||
controller = host.controller()
|
||||
|
||||
controller.save_choice(role=ROLE_DEFAULT)
|
||||
|
||||
assert controller.choice() == (ROLE_DEFAULT, None, "")
|
||||
assert controller.default_path() == default
|
||||
assert controller.resolve() == (default, None, "")
|
||||
|
||||
|
||||
def test_a_saved_choice_never_saves_one_key_for_another_bundle():
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
controller.save_choice(role=ROLE_FILE, path="D:/lab/a.json", device_id=" SN-A ")
|
||||
|
||||
payload = host.prefs["live_calibration"]["shipped/box"]
|
||||
assert payload == {"role": ROLE_FILE, "path": "D:/lab/a.json", "device_id": "SN-A"}
|
||||
assert host.saves == 1
|
||||
|
||||
|
||||
def test_an_unknown_role_is_remembered_as_raw_force():
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
|
||||
controller.save_choice(role="exported")
|
||||
|
||||
assert host.prefs["live_calibration"]["shipped/box"] == {"role": ROLE_NONE}
|
||||
|
||||
|
||||
def test_without_a_selected_bundle_nothing_is_saved():
|
||||
host = _Host(None)
|
||||
controller = host.controller()
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path="D:/lab/a.json")
|
||||
|
||||
assert "live_calibration" not in host.prefs
|
||||
assert host.saves == 0
|
||||
|
||||
|
||||
def test_a_finger_file_must_name_the_typed_serial(tmp_path):
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=cal, device_id="DD02-14")
|
||||
assert controller.resolve() == (cal, "DD02-14", "")
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=cal, device_id="OTHER")
|
||||
_path, _device, problem = controller.resolve()
|
||||
assert problem == t("video_analysis.device_mismatch", sn="DD02-14")
|
||||
|
||||
|
||||
def test_a_finger_file_without_a_typed_serial_is_refused(tmp_path):
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
|
||||
_path, _device, problem = controller.resolve()
|
||||
assert problem == t("video_analysis.device_required")
|
||||
|
||||
|
||||
def test_a_box_file_chosen_for_a_finger_is_refused(tmp_path):
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=cal, device_id="SN-A")
|
||||
|
||||
_path, _device, problem = controller.resolve()
|
||||
assert problem == t("video_analysis.device_family", family="box")
|
||||
|
||||
|
||||
def test_an_unreadable_document_is_not_reported_as_a_missing_sn(tmp_path):
|
||||
"""A parse failure is its own answer, not "this file has no SN".
|
||||
|
||||
The fixture carries the right SN in a ``meta`` block but is not a coefficient
|
||||
document the SDK accepts (no usable channel curves). A missing coordinate-map
|
||||
attachment fails strict parsing the same way; either reason must reach the
|
||||
operator instead of being reported as "no SN".
|
||||
"""
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
path = tmp_path / "not-a-coefficient-document.json"
|
||||
path.write_text(
|
||||
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=path, device_id="DD02-14")
|
||||
_path, _device, problem = controller.resolve()
|
||||
|
||||
assert problem != t("video_analysis.device_missing_sn")
|
||||
template = t("video_analysis.device_unreadable", error="\x00")
|
||||
prefix, _, suffix = template.partition("\x00")
|
||||
assert problem.startswith(prefix) and problem.endswith(suffix), problem
|
||||
detail = problem[len(prefix) : len(problem) - len(suffix)]
|
||||
assert detail.strip(), "the parser's reason was dropped"
|
||||
|
||||
|
||||
def test_a_loadable_document_without_a_serial_is_reported_as_a_missing_sn(tmp_path):
|
||||
"""Parsing succeeds and the document names no device: that is a missing SN."""
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal, device_id="DD02-14")
|
||||
|
||||
_path, _device, problem = controller.resolve()
|
||||
|
||||
assert problem == t("video_analysis.device_missing_sn")
|
||||
|
||||
|
||||
def test_the_serial_check_only_applies_to_finger(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
|
||||
assert controller.resolve() == (cal, None, "")
|
||||
|
||||
|
||||
# ------------------------------------------------------ exporting and applying
|
||||
|
||||
|
||||
def test_exporting_selects_the_file_without_applying_it(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
worker = _Worker()
|
||||
host.worker = worker
|
||||
|
||||
returned = controller.note_exported(cal)
|
||||
|
||||
assert returned == cal
|
||||
assert controller.last_export == str(cal)
|
||||
assert host.prefs["live_calibration"]["shipped/box"] == {
|
||||
"role": ROLE_FILE,
|
||||
"path": str(cal),
|
||||
"device_id": "SN-A",
|
||||
}
|
||||
assert host.selected == [cal]
|
||||
assert host.statuses == [t("cal.export_only", path=cal.name)]
|
||||
assert host.events == [("calibration_exported", str(cal))]
|
||||
# Exporting is not applying: the device was never asked to load anything.
|
||||
assert worker.pending == []
|
||||
|
||||
|
||||
def test_apply_choice_when_idle_only_remembers(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
worker = _Worker()
|
||||
host.worker = worker
|
||||
|
||||
controller.apply_choice()
|
||||
|
||||
assert host.statuses == [t("sensor.live_calibration_remembered")]
|
||||
assert worker.pending == []
|
||||
assert host.confirmations == []
|
||||
|
||||
|
||||
def test_apply_choice_hot_updates_a_running_box_session(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
worker = _Worker()
|
||||
host.worker = worker
|
||||
host.running = True
|
||||
|
||||
controller.apply_choice()
|
||||
|
||||
assert worker.pending == [(str(cal), None)]
|
||||
assert host.statuses == [
|
||||
t("sensor.live_calibration_applied", name=cal.name)
|
||||
]
|
||||
|
||||
|
||||
def test_apply_choice_passes_the_serial_to_a_finger_worker(tmp_path):
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal, device_id="DD02-14")
|
||||
worker = _ZeroCapableWorker()
|
||||
host.worker = worker
|
||||
host.running = True
|
||||
|
||||
controller.apply_choice()
|
||||
|
||||
assert worker.pending == [(str(cal), "DD02-14")]
|
||||
|
||||
|
||||
def test_apply_choice_reports_the_open_dialogs_problem(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
worker = _Worker()
|
||||
host.worker = worker
|
||||
host.running = True
|
||||
host.dialog_problem = t("video_analysis.device_required")
|
||||
|
||||
controller.apply_choice()
|
||||
|
||||
assert host.statuses == [
|
||||
t("sensor.live_calibration_invalid", reason=t("video_analysis.device_required"))
|
||||
]
|
||||
assert worker.pending == []
|
||||
|
||||
|
||||
def test_apply_choice_without_a_target_reports_it(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
controller.save_choice(role=ROLE_FILE, path=cal)
|
||||
host.running = True
|
||||
host.worker = None
|
||||
|
||||
controller.apply_choice()
|
||||
|
||||
assert host.statuses == [t("cal.apply_no_target")]
|
||||
|
||||
|
||||
def test_apply_requires_a_written_file():
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
|
||||
controller.request_apply()
|
||||
|
||||
assert host.statuses == [t("cal.apply_no_export")]
|
||||
assert host.confirmations == []
|
||||
|
||||
|
||||
def test_a_mismatched_file_needs_an_explicit_yes(tmp_path):
|
||||
host = _Host(_entry("shipped/finger", family="finger"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
worker = _Worker()
|
||||
host.workers = {"finger": worker}
|
||||
host.sensor_type = "finger"
|
||||
host.config_ref = "D:/lab/B/sensor.json"
|
||||
host.answers = [False]
|
||||
|
||||
controller.request_apply(cal)
|
||||
|
||||
assert host.confirmations and host.confirmations[0][0] == t(
|
||||
"cal.apply_mismatch_title"
|
||||
)
|
||||
assert len(host.events) == 1
|
||||
assert host.events[0][0] == "calibration_apply_blocked"
|
||||
assert "sensor_type" in host.events[0][1]
|
||||
assert host.statuses == [t("cal.apply_mismatch_skipped")]
|
||||
assert worker.pending == []
|
||||
|
||||
|
||||
def test_a_matching_file_is_confirmed_once_then_requested(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
worker = _Worker()
|
||||
host.workers = {"box": worker}
|
||||
host.sensor_type = "box"
|
||||
host.config_ref = "D:/lab/sensor.json"
|
||||
host.answers = [True]
|
||||
|
||||
controller.request_apply(cal)
|
||||
|
||||
assert len(host.confirmations) == 1
|
||||
assert host.confirmations[0][0] == t("cal.apply_title")
|
||||
assert worker.pending == [(str(cal), None)]
|
||||
assert host.statuses == [t("cal.apply_pending", sensor_type="box")]
|
||||
# A request is not a confirmation: no applied event and no version here.
|
||||
assert host.events == []
|
||||
|
||||
|
||||
def test_apply_defaults_to_the_last_exported_file(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
controller.note_exported(cal)
|
||||
worker = _Worker()
|
||||
host.workers = {"box": worker}
|
||||
host.answers = [True]
|
||||
|
||||
controller.request_apply()
|
||||
|
||||
assert worker.pending == [(str(cal), None)]
|
||||
|
||||
|
||||
def test_apply_without_a_target_worker_reports_it(tmp_path):
|
||||
host = _Host(_entry("shipped/box"))
|
||||
controller = host.controller()
|
||||
cal = _coefficients(tmp_path, sensor_type="box", sn="SN-A")
|
||||
host.answers = [True]
|
||||
host.worker = None
|
||||
|
||||
controller.request_apply(cal)
|
||||
|
||||
assert host.statuses == [t("cal.apply_no_target")]
|
||||
assert host.worker is None
|
||||
@@ -45,6 +45,42 @@ def _descriptor(profile_id: str, **overrides: object) -> dict[str, object]:
|
||||
return data
|
||||
|
||||
|
||||
def _coefficients(directory: Path, *, sensor_type: str, sn: str) -> Path:
|
||||
"""A coefficient document the SDK's own loader accepts (a real export shape).
|
||||
|
||||
The live open/apply path reads a file's identity through ``CalibrationService``
|
||||
— the same parser the device would use — so a Finger serial check needs a
|
||||
document that really is a calibration file, not a ``meta`` block in any JSON.
|
||||
"""
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
build_export_model,
|
||||
)
|
||||
|
||||
store = CalSessionStore()
|
||||
store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10))
|
||||
store.aggregate("fn", CalSample(weight_n=5.0, raw_mean=500.0, n=10))
|
||||
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
|
||||
document = model.to_dict()
|
||||
document["identity"] = {"sensor_type": sensor_type, "sn": sn}
|
||||
document["pipeline"] = {"config_ref": "D:/lab/sensor.json"}
|
||||
path = directory / f"{sensor_type}-{sn}.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _assert_unreadable(problem: str) -> str:
|
||||
"""The problem is ``device_unreadable`` with the parser's reason, not "no SN"."""
|
||||
template = t("video_analysis.device_unreadable", error="\x00")
|
||||
prefix, _, suffix = template.partition("\x00")
|
||||
assert problem != t("video_analysis.device_missing_sn")
|
||||
assert problem.startswith(prefix) and problem.endswith(suffix), problem
|
||||
detail = problem[len(prefix) : len(problem) - len(suffix)]
|
||||
assert detail.strip(), "the parser's reason was dropped"
|
||||
return detail
|
||||
|
||||
|
||||
class _Host(QWidget):
|
||||
"""Window stand-in with the live-calibration prefs API the dialog expects."""
|
||||
|
||||
@@ -233,11 +269,7 @@ def test_a_finger_file_with_a_mismatched_sn_is_not_a_valid_choice(
|
||||
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||
picker.set_sensor_type("finger")
|
||||
host, dialog = _dialog_for(picker)
|
||||
cal = tmp_path / "DD02-14.json"
|
||||
cal.write_text(
|
||||
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
|
||||
try:
|
||||
dialog.select_calibration_file(cal)
|
||||
# Offscreen dialogs are not shown; ``isHidden`` is the visibility flag.
|
||||
@@ -255,13 +287,79 @@ def test_a_finger_file_with_a_mismatched_sn_is_not_a_valid_choice(
|
||||
picker.deleteLater()
|
||||
|
||||
|
||||
def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path):
|
||||
window = make_window()
|
||||
def _finger_dialog(qt_app, tmp_path, monkeypatch):
|
||||
def build(root: Path) -> None:
|
||||
folder = root / "finger-lab"
|
||||
_write(folder / "sensor.json", CONFIG)
|
||||
_write(
|
||||
folder / "manifest.json",
|
||||
_descriptor(
|
||||
"orisys.finger.lab-r1",
|
||||
display_name="Finger lab",
|
||||
product_family="finger",
|
||||
processing_backend="uv_unroll",
|
||||
computational_assets={},
|
||||
),
|
||||
)
|
||||
|
||||
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||
picker.set_sensor_type("finger")
|
||||
host, dialog = _dialog_for(picker)
|
||||
return picker, host, dialog
|
||||
|
||||
|
||||
def test_a_finger_file_the_sdk_rejects_is_reported_as_unreadable(
|
||||
qt_app, tmp_path, monkeypatch
|
||||
):
|
||||
"""A ``meta`` SN is not an identity: the dialog precheck uses the strict parse."""
|
||||
picker, _host, dialog = _finger_dialog(qt_app, tmp_path, monkeypatch)
|
||||
cal = tmp_path / "DD02-14.json"
|
||||
cal.write_text(
|
||||
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
dialog.select_calibration_file(cal)
|
||||
dialog.device_edit.setText("DD02-14")
|
||||
_assert_unreadable(dialog.choice_problem())
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
picker.deleteLater()
|
||||
|
||||
|
||||
def test_a_finger_file_that_is_not_json_is_reported_as_unreadable(
|
||||
qt_app, tmp_path, monkeypatch
|
||||
):
|
||||
picker, _host, dialog = _finger_dialog(qt_app, tmp_path, monkeypatch)
|
||||
cal = tmp_path / "not-json.json"
|
||||
cal.write_text("not a calibration document\n", encoding="utf-8")
|
||||
try:
|
||||
dialog.select_calibration_file(cal)
|
||||
dialog.device_edit.setText("DD02-14")
|
||||
_assert_unreadable(dialog.choice_problem())
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
picker.deleteLater()
|
||||
|
||||
|
||||
def test_a_finger_file_without_an_sn_is_reported_as_missing_sn(
|
||||
qt_app, tmp_path, monkeypatch
|
||||
):
|
||||
"""Readable but nameless stays its own answer, not a parse failure."""
|
||||
picker, _host, dialog = _finger_dialog(qt_app, tmp_path, monkeypatch)
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="")
|
||||
try:
|
||||
dialog.select_calibration_file(cal)
|
||||
dialog.device_edit.setText("DD02-14")
|
||||
assert dialog.choice_problem() == t("video_analysis.device_missing_sn")
|
||||
finally:
|
||||
dialog.deleteLater()
|
||||
picker.deleteLater()
|
||||
|
||||
|
||||
def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path):
|
||||
window = make_window()
|
||||
cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
|
||||
finger = next(
|
||||
(
|
||||
entry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Studio 软件架构
|
||||
|
||||
本文说明已接线的 S1–S2;后续工作和验收由
|
||||
本文说明已接线的 S1–S3;后续工作和验收由
|
||||
[分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据,
|
||||
不能用其中的行数或“已完成”判断当前代码。
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
分析结果的传感器身份来自结果 provenance,进度读取现有 `AnalysisProgress.state()`,
|
||||
不再通过 `ReplayController._progress` 访问其私有控件。
|
||||
|
||||
`live_calibration.py::LiveCalibrationController` 持有实时标定选择:按 bundle 保存的选择、
|
||||
profile 默认文件、Finger 序列号检查、导出后选择及确认/提交请求;文件身份与匹配复用
|
||||
`CalibrationService`。窗口保留 `live_calibration_for` 等窄接口转发,供 `SensorDialog`、
|
||||
运行入口、分析提示和测试使用;应用仍只是向 worker 提交请求,设备确认与记录版本仍走记录通道。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
W[StudioMainWindow 装配与接线] --> S[SessionController 会话与遥测]
|
||||
@@ -23,6 +28,8 @@ graph TD
|
||||
W --> L[ViewLayoutController 布局]
|
||||
W --> D[DisplayPrefsController 显示偏好]
|
||||
W --> C[RecordingCoordinator 记录与设备确认]
|
||||
W --> LC[LiveCalibrationController 实时标定选择]
|
||||
LC --> CF[CalibrationService 文件身份与匹配]
|
||||
W --> P[StatusPresenter 状态显示]
|
||||
F[RailState 只读显示事实] --> P
|
||||
P --> T[ToolbarWidget 状态轨]
|
||||
@@ -38,7 +45,8 @@ graph TD
|
||||
设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。
|
||||
|
||||
验证入口:`test_status_presenter.py` 可在没有主窗口和设备的情况下检查状态显示;
|
||||
`test_ui_smoke.py`、`test_replay_wiring.py` 和 `tools/ui_check.py` 覆盖窗口集成。
|
||||
`test_live_calibration.py` 独立覆盖实时标定选择/解析/导出/应用规则;`test_ui_smoke.py`、
|
||||
`test_replay_wiring.py` 和 `tools/ui_check.py` 覆盖窗口集成。
|
||||
执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移,
|
||||
不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。
|
||||
|
||||
@@ -140,7 +148,8 @@ graph TB
|
||||
```
|
||||
main.py
|
||||
└─ app_window.py ──┬─ 外壳: brand_bar / toolbar / record_status
|
||||
├─ 控制器: session_controller · replay_controller · view_layout · display_prefs
|
||||
├─ 控制器: session_controller · replay_controller · view_layout · display_prefs · live_calibration
|
||||
├─ live_calibration 选择规则 → calibration_service(文件身份/匹配) → worker.set_pending_calibration 请求
|
||||
├─ 面板: arrow_panel(2D) · mesh3d_panel(3D) · force_plots · cal_panel · 对话框
|
||||
├─ 领域服务: recording · replay · calibration_service · scene3d.{view,asset_source,coordinator,keys}
|
||||
└─ 共享层(无 Qt): acquisition · record · force · scene3d · camera/prefs/paths/i18n/theme/logging
|
||||
@@ -339,8 +348,8 @@ graph TB
|
||||
|
||||
## 后续迁移
|
||||
|
||||
当前工作由 [分步重构计划](studio-window-refactor-plan.zh.md) 的 S2–S6 管理,
|
||||
包括界面构建、标定交互、分析配置、运行与预览以及显示入口和兼容桥收尾。
|
||||
当前工作由 [分步重构计划](studio-window-refactor-plan.zh.md) 的 S4–S6 管理,
|
||||
包括分析配置、运行与预览、显示入口和兼容桥收尾;S3 已完成,结果见计划交接。
|
||||
每一步完成后更新本节之前的当前职责;历史指标不随搬运代码自动改写。
|
||||
|
||||
---
|
||||
|
||||
@@ -52,12 +52,14 @@
|
||||
|
||||
### S3:实时标定交互
|
||||
|
||||
- [ ] 提取 `live_calibration.py` 的选择保存、解析、确认及提交请求。
|
||||
- [ ] 文件身份和匹配规则复用 `CalibrationService`;对话框通过窄接口访问。
|
||||
- [ ] 设备确认、记录版本及采样仍走现有记录通道。
|
||||
- [x] 提取 `live_calibration.py` 的选择保存、解析、确认及提交请求。
|
||||
- [x] 文件身份和匹配规则复用 `CalibrationService`;对话框通过窄接口访问。
|
||||
- [x] 设备确认、记录版本及采样仍走现有记录通道。
|
||||
|
||||
依赖 S2。验证:`test_live_calibration_dialog`、`test_calibration_service` 及确认事件测试。
|
||||
导出不等于应用,应用请求不等于设备确认,序列号不匹配的处理不变。
|
||||
实施结果(2026-10-19,round 3–5):round 5 统一身份解析并锁定 SN 必填回归
|
||||
(定向 73 passed、校准 UI 8 passed);R1–R4 见交接,未改断言或基线。
|
||||
|
||||
### S4:分析配置
|
||||
|
||||
@@ -111,79 +113,48 @@ S1 首次 shared 467 passed、18 skipped、3 deselected,UI 632/0;另有关
|
||||
均为 Windows/Python 3.10.19/offscreen;未验证真实设备、可见桌面或硬件 GL。
|
||||
未放宽断言或基线。S2 的非迁移方法 AST 一致是当时证据,后续功能提交不受其约束。
|
||||
|
||||
## 当前交接:仅执行 S3
|
||||
## 当前交接:S3 返工 round 5 已交付 review
|
||||
|
||||
- task_id:`studio-window-refactor`;round:2;updated_at:2026-10-19,Asia/Hong_Kong。
|
||||
- status:ready;owner:Codex(交出后停止写入);next_agent:DeepSeek 执行方。
|
||||
- 用户要求“check existing plan and prepare to handoff to deepseek”;本轮只更新交接。
|
||||
先前分步重构授权继续有效;转发启动指令后执行 S3,完成交付 review。
|
||||
S4–S6 留下一轮,不因 S1/S2 结构完成而声称全量验收通过。
|
||||
- task_id:`studio-window-refactor`;round:5;updated_at:2026-10-19,Asia/Hong_Kong。
|
||||
- status:review;owner:DeepSeek 执行方(已停止写入);next_agent:GPT/Claude 验收方。
|
||||
- 本轮依据:`.scratch/studio-window-refactor/rework-r5/rework-plan.md`(用户确认的返工计划)。
|
||||
未放宽 SN 必填、未改并行任务文件、未用全量门禁证明通过;未暂存/提交/推送/切换分支。
|
||||
- 授权:用户 2026-10-19 转发该返工计划与启动指令,要求按 R1→R2→R4→R3 执行、R4 先交盘点、
|
||||
完成标 review 交回验收;先前 S3 授权在范围内继续有效。
|
||||
|
||||
### 基准与输入
|
||||
|
||||
| 仓库 | 分支与 HEAD | 起始状态/本轮修改 |
|
||||
|---|---|---|
|
||||
| `D:\orisys_ws\orisys-app` | dev;9d4563e9292469e9df500a7acab6d26489810455 | 索引空;finger sensor.json 已有未暂存修改;本轮仅此计划 |
|
||||
| `D:\orisys_ws` | master;9201914cbe335b6862058212af0bc2614f2b4792 | 场加权计划有其他任务修改;设备包、mph、dragon-q6a 无关未跟踪;本轮不改根仓库 |
|
||||
| `D:\orisys_ws\orisys-app` | dev;`70b4341fd00707a3a7579d6d36ed4b090c2be068` | 索引空;工作区含并行任务未提交修改(shared analysis/acquisition、`replay_controller.py`、locales 的 field 键、`finger/sensor.json` 3→2)与本轮交付;本轮改动见下 |
|
||||
| `D:\orisys_ws` | master;`9201914cbe335b6862058212af0bc2614f2b4792` | 场加权计划等无关修改;本轮不改根仓库 |
|
||||
|
||||
当前主窗口 2899 行、213 个方法(含属性访问器);WindowUi/WindowMenus 已接线,
|
||||
live_calibration.py/analysis_setup.py 尚不存在。当前源码包含后续回放修复,不能回退旧窗口。
|
||||
工作目录为应用仓库;解释器 `.venv-build\Scripts\python.exe`(3.10.19、offscreen)。
|
||||
R1 只改行尾:计划所列 5 文件、自有文档 2 个、本轮另 3 个改动文件。R3 改
|
||||
`calibration_service.py`、`live_calibration.py`、两个对话框与 4 个测试文件;R2/R4 未改生产代码。
|
||||
文件 SHA256 见 `rework-r5/delivery-sha256.json`;其中 R1 涉及的 3 个并行任务文件
|
||||
(locales×2、sensor.json、replay_controller.py)只规范了行尾,其内容改动不属于本轮。
|
||||
|
||||
本轮工作目录为应用仓库;解释器 `D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`。
|
||||
源码、配置、测试与依赖声明 SHA256 见
|
||||
[baseline-sha256.json](../../.scratch/studio-window-refactor/handoff-20261019-171832/baseline-sha256.json);
|
||||
本轮计划哈希见同目录 `delivery-sha256.json`,不把哈希写回被哈希文件。
|
||||
同 checkout 无需补丁;跨 checkout 须提供差异、新文件及上述基准。无须原始视频或硬件。
|
||||
### 执行结果
|
||||
|
||||
### 范围、依赖与停止条件
|
||||
| 项 | 结果与证据 |
|
||||
|---|---|
|
||||
| R1 行尾 | 10 个本轮文件均 `i/lf w/lf`;5 个计划文件整文件 diff 由 3312/3215 降至 103/6,与 `--ignore-space-at-eol` 一致;转换前后对照 diff 194 行逐行相同。证据 `rework-r5/r1-eol.md` |
|
||||
| R2 回归锁定 | 5 类用例齐全(无 SN/无法解析/SN 不一致/未填设备号/非 Finger 文件),`resolve()` 无“无 SN 放行”返回;遗留 `ROLE_DEFAULT` 不校验但无 Finger 默认标定、不可达。证据 `rework-r5/r2-sn-required.md` |
|
||||
| R4 SN 盘点 | 只读:随包仅 box `force_calibration.json`(无 SN,规则只对 Finger);Finger 目录与设计导出目录无标定文件;`data/dd02-gl` 两个 Finger v3 文件带 `sn=dd02-gl` 且严格解析成功。**无 Finger 文件会被拒绝、无需补 SN**。证据 `rework-r5/sn-inventory.md` |
|
||||
| R3 统一身份解析 | 对照架构文档:UI→无 Qt 服务层是向下依赖、无环(`cal_panel` 已有同类依赖),方向合规。`CalibrationService.read_identity` 一次解析返回 `(identity, error)`,`identity_of` 变薄封装;控制器删除二次 SDK 解析与 `?` 兜底;两对话框改走同一判断;对话框错误文案与控制器一致。 |
|
||||
| R3 测试 | 控制器+服务+对话框+locale 48 passed;定向回归 73 passed;`test_ui_smoke -k calibration` 8 passed;本轮文件 lint exit 0、无 findings。证据 `rework-r5/r3-identity.md`、`r5-final-20261019-191500/` |
|
||||
| 文档检查 | `doc_lint` exit 0、无新增 findings;`doc_count` 计划 2502/3000、架构 3196/3232。证据 scratch `doc-lint.log`、`doc-count.log` |
|
||||
| 未执行 | 全量门禁(按要求不跑;上轮 studio 57% 原生挂起未解决);未验证真实设备、可见桌面或硬件 GL。 |
|
||||
|
||||
allowed_files:`apps/studio/src/orisys_studio/app_window.py`、新增 `live_calibration.py`,
|
||||
必要的 `shell_ports.py`;相关实时标定测试及新增单元测试;本计划与 `studio-architecture.md`。
|
||||
其他阶段、SDK、shared、配置、Autocal 和功能变更均不在本轮范围。
|
||||
历史轮次证据:round 3 门禁 `gate-20261019T093016Z/`、round 4 返工 `handoff-20261019-174000/rework-P2.md`;
|
||||
详细过程在 scratch,不在本表复制。
|
||||
|
||||
迁移 live choice 的读取/保存、默认路径、解析、导出后选择和确认/提交请求。
|
||||
复用同一 prefs 与保存入口,避免第二份状态;服务仍负责文件身份和匹配。
|
||||
注入显式控件、回调或窄接口,不传整个窗口或使用任意属性代理。
|
||||
先保留实际被 SensorDialog、运行入口、回放配置和测试调用的窗口桥;列出调用者与保留原因。
|
||||
测试 monkeypatch 的转发仍须生效,不能复制旧实现来满足测试。
|
||||
### 验收与下一步
|
||||
|
||||
采集线程确认继续只写 RecordingCoordinator/SessionController,不操作 Qt;
|
||||
GUI drain 在原时机执行。导出不应用,请求不当作确认;角色 default/file/none、
|
||||
缺失文件、序列号检查、提示文字及 worker 请求参数均保持当前行为。
|
||||
|
||||
[场加权标定任务](../../../docs/workflow/field-weighted-calibration-plan.zh.md) 当前 working,
|
||||
涉及 calibration_service.py、video_analysis_dialog.py、replay_controller.py 和 shared。
|
||||
本轮把它们作为只读依赖;不改接口、不覆盖其工作。若接手时其变化影响提取或验证,
|
||||
先核对该任务的交接记录与文件所有权,停止交叠部分;不得根据旧哈希恢复它们。
|
||||
需要修改重叠文件或改变功能时请求决定,独立准备可继续。
|
||||
默认遵循交接约定的三次失败停止规则;未设时间预算。没有提交、推送或硬件授权。
|
||||
|
||||
### 执行与验收
|
||||
|
||||
DeepSeek 先运行下方相同测试建立当前基线,再分“选择/解析”和“确认/请求”两批提取。
|
||||
每批检查调用方与差异、跑相同回归,补窄接口独立测试;完成更新架构现状,运行门禁。
|
||||
结果应保持旧行为、减少窗口业务逻辑、无循环依赖及新线程。独立验收方复跑关键证据。
|
||||
历史全量失败不自动豁免;本轮失败记录实际退出码、归因证据与剩余项,不降标准。
|
||||
|
||||
```powershell
|
||||
# 工作目录 D:\orisys_ws\orisys-app;每次替换为唯一 run_id
|
||||
& ./.venv-build/Scripts/python.exe -m pytest apps/studio/tests/test_live_calibration_dialog.py apps/studio/tests/test_calibration_service.py apps/studio/tests/test_model_calibration_ui.py apps/studio/tests/test_recording_recalibration.py apps/studio/tests/test_bare_video_analysis.py apps/studio/tests/test_window_surfaces.py -p no:cacheprovider --basetemp=.scratch/studio-window-refactor/pytest-<run_id> --junitxml=.scratch/studio-window-refactor/<run_id>.xml
|
||||
& ./.venv-build/Scripts/python.exe -m pytest apps/studio/tests/test_ui_smoke.py -k calibration -p no:cacheprovider --basetemp=.scratch/studio-window-refactor/pytest-confirm-<run_id>
|
||||
& ./.venv-build/Scripts/python.exe tools/lint_gate.py
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -Task studio-window-refactor -LintBase HEAD -Serial
|
||||
# 工作目录 D:\orisys_ws
|
||||
& ./orisys-app/.venv-build/Scripts/python.exe scripts/doc_lint.py --files orisys-app/docs/architecture/studio-window-refactor-plan.zh.md orisys-app/docs/architecture/studio-architecture.md
|
||||
```
|
||||
|
||||
| 本轮验收项 | 实际命令与结果 | 证据 |
|
||||
|---|---|---|
|
||||
| 当前阶段/调用方 | 只读检索与 Python AST 计数;S1/S2 已接线、S3 未实施 | 当前源码及 baseline-sha256.json |
|
||||
| 文档检查 | `scripts/doc_lint.py --files orisys-app/docs/architecture/studio-window-refactor-plan.zh.md`;退出码 0 | 本轮 scratch/doc-lint.log |
|
||||
| 文档大小 | `scripts/doc_count.py orisys-app/docs/architecture/studio-window-refactor-plan.zh.md`;预算内 | 本轮 scratch/doc-count.log |
|
||||
| 代码回归/全量门禁 | 本轮未执行,只整理交接;上方是未来命令 | 不复用历史结果冒充当前通过 |
|
||||
|
||||
轮次摘要:1,S0–S2 实施及历史限制见上;2,核对现状并准备 S3 交接,未改生产代码。
|
||||
验收者、独立复跑和决定尚未发生。下一步由 DeepSeek 核对现场、将 owner/status 更新为
|
||||
DeepSeek/working,完成后交 review;验收方决定 done/rework,不能自行宣称接受。
|
||||
本轮无后台进程;证据保留在 `.scratch/studio-window-refactor/handoff-20261019-171832/`,
|
||||
检查结束移除任务 .active,不清理日志。缺失 scratch 材料明确标记,不依赖它继续写入。
|
||||
- 轮次摘要:1)S0–S2;2)准备 S3 交接;3)S3 实施;4)返工 P2;5)行尾、SN 锁定、盘点与身份解析统一(本轮)。
|
||||
- 验收者、时间、决定:轮 3 已独立复跑并返工 P2;轮 4 交付后由用户给出本轮返工计划;本轮待复核。
|
||||
- 下一步:GPT/Claude 复核 R1–R4 证据并独立复跑 R3 测试与行尾检查,决定 done/rework。
|
||||
S4–S6 仍未实施。改动保持未暂存;提交建议见 `.scratch/studio-window-refactor/commit-message.txt`,
|
||||
不含并行任务文件与既有 `finger/sensor.json`。
|
||||
- scratch:证据保留在 `.scratch/studio-window-refactor/`;任务 `.active` 检查后移除,日志不清理。
|
||||
|
||||
Reference in New Issue
Block a user