From 81063213f971734879c141f226d799c7b7c2c7df Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 18:42:13 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AE=9E=E6=97=B6=E6=A0=87?= =?UTF-8?q?=E5=AE=9A=E8=BA=AB=E4=BB=BD=E8=A7=A3=E6=9E=90=E5=B9=B6=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E8=A1=8C=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 控制器与两个对话框共用 CalibrationService.read_identity,解析失败与缺 SN 分开上报; 本轮改动文件统一 LF,SN 必填回归已锁定。 定向 73 项、校准 UI 8 项与 lint 通过;未跑全量门禁。 --- apps/studio/config/locales/en_US.json | 5 + apps/studio/config/locales/zh_CN.json | 5 + apps/studio/src/orisys_studio/app_window.py | 259 +++------- .../src/orisys_studio/calibration_service.py | 29 +- .../src/orisys_studio/live_calibration.py | 299 ++++++++++++ .../src/orisys_studio/ui/sensor_dialog.py | 11 +- .../orisys_studio/ui/video_analysis_dialog.py | 29 +- apps/studio/tests/test_bare_video_analysis.py | 31 +- apps/studio/tests/test_calibration_service.py | 49 ++ apps/studio/tests/test_live_calibration.py | 451 ++++++++++++++++++ .../tests/test_live_calibration_dialog.py | 112 ++++- docs/architecture/studio-architecture.md | 19 +- .../studio-window-refactor-plan.zh.md | 105 ++-- 13 files changed, 1095 insertions(+), 309 deletions(-) create mode 100644 apps/studio/src/orisys_studio/live_calibration.py create mode 100644 apps/studio/tests/test_live_calibration.py diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 0728779..abfbc91 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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}.", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 87c1df2..df8e674 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -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} 帧。", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 71ae872..e918c9c 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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 diff --git a/apps/studio/src/orisys_studio/calibration_service.py b/apps/studio/src/orisys_studio/calibration_service.py index babd5c4..30594a4 100644 --- a/apps/studio/src/orisys_studio/calibration_service.py +++ b/apps/studio/src/orisys_studio/calibration_service.py @@ -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: diff --git a/apps/studio/src/orisys_studio/live_calibration.py b/apps/studio/src/orisys_studio/live_calibration.py new file mode 100644 index 0000000..b782316 --- /dev/null +++ b/apps/studio/src/orisys_studio/live_calibration.py @@ -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)) diff --git a/apps/studio/src/orisys_studio/ui/sensor_dialog.py b/apps/studio/src/orisys_studio/ui/sensor_dialog.py index 4078b35..aef580a 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_dialog.py +++ b/apps/studio/src/orisys_studio/ui/sensor_dialog.py @@ -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: diff --git a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py index 1a6de91..fdb2a81 100644 --- a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py +++ b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py @@ -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: diff --git a/apps/studio/tests/test_bare_video_analysis.py b/apps/studio/tests/test_bare_video_analysis.py index 8a89376..0a6045a 100644 --- a/apps/studio/tests/test_bare_video_analysis.py +++ b/apps/studio/tests/test_bare_video_analysis.py @@ -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) diff --git a/apps/studio/tests/test_calibration_service.py b/apps/studio/tests/test_calibration_service.py index 54f7e69..94cbe6c 100644 --- a/apps/studio/tests/test_calibration_service.py +++ b/apps/studio/tests/test_calibration_service.py @@ -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"] == "" diff --git a/apps/studio/tests/test_live_calibration.py b/apps/studio/tests/test_live_calibration.py new file mode 100644 index 0000000..078a043 --- /dev/null +++ b/apps/studio/tests/test_live_calibration.py @@ -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 diff --git a/apps/studio/tests/test_live_calibration_dialog.py b/apps/studio/tests/test_live_calibration_dialog.py index 85c0505..551d86d 100644 --- a/apps/studio/tests/test_live_calibration_dialog.py +++ b/apps/studio/tests/test_live_calibration_dialog.py @@ -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 diff --git a/docs/architecture/studio-architecture.md b/docs/architecture/studio-architecture.md index f0d4ce7..1e8f071 100644 --- a/docs/architecture/studio-architecture.md +++ b/docs/architecture/studio-architecture.md @@ -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 已完成,结果见计划交接。 每一步完成后更新本节之前的当前职责;历史指标不随搬运代码自动改写。 --- diff --git a/docs/architecture/studio-window-refactor-plan.zh.md b/docs/architecture/studio-window-refactor-plan.zh.md index fa20546..6a53d69 100644 --- a/docs/architecture/studio-window-refactor-plan.zh.md +++ b/docs/architecture/studio-window-refactor-plan.zh.md @@ -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- --junitxml=.scratch/studio-window-refactor/.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- -& ./.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` 检查后移除,日志不清理。