统一实时标定身份解析并规范行尾

控制器与两个对话框共用 CalibrationService.read_identity,解析失败与缺 SN 分开上报;
本轮改动文件统一 LF,SN 必填回归已锁定。
定向 73 项、校准 UI 8 项与 lint 通过;未跑全量门禁。
This commit is contained in:
ykcheungab
2026-10-19 18:42:13 +08:00
parent 70b4341fd0
commit 81063213f9
13 changed files with 1095 additions and 309 deletions
+5
View File
@@ -291,6 +291,10 @@
"mode_replay_tip": "The charts read the open recording; capture and recording continue", "mode_replay_tip": "The charts read the open recording; capture and recording continue",
"mode_recomputed": "Recomputed from this video (browsing)", "mode_recomputed": "Recomputed from this video (browsing)",
"mode_recomputed_tip": "Curves come from this analysis, not forces.csv.", "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": "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", "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", "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_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_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_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": "Reference frame",
"reference_time": "{seconds} s", "reference_time": "{seconds} s",
"reference_missing": "The recording has no frame {frame}.", "reference_missing": "The recording has no frame {frame}.",
+5
View File
@@ -291,6 +291,10 @@
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续", "mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
"mode_recomputed": "从这段视频重算(浏览)", "mode_recomputed": "从这段视频重算(浏览)",
"mode_recomputed_tip": "曲线来自这次分析,不是 forces.csv。", "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": "没有力曲线 — {reason}",
"mode_no_trace_tip": "没有可画的东西,而原因是输入本身的事实,不是一种加载状态", "mode_no_trace_tip": "没有可画的东西,而原因是输入本身的事实,不是一种加载状态",
"trace_not_analysed": "这段视频还没有分析过", "trace_not_analysed": "这段视频还没有分析过",
@@ -358,6 +362,7 @@
"device_missing_sn": "这份标定文件没有 SN,无法确认它属于哪台传感器", "device_missing_sn": "这份标定文件没有 SN,无法确认它属于哪台传感器",
"device_mismatch": "标定文件的 SN 是 {sn},与填写的设备编号不一致", "device_mismatch": "标定文件的 SN 是 {sn},与填写的设备编号不一致",
"device_family": "这份标定属于 {family},不能用于 Finger", "device_family": "这份标定属于 {family},不能用于 Finger",
"device_unreadable": "这份标定文件无法读取,不能确认它属于哪台传感器({error})",
"reference": "参考帧", "reference": "参考帧",
"reference_time": "{seconds} 秒", "reference_time": "{seconds} 秒",
"reference_missing": "录制中没有第 {frame} 帧。", "reference_missing": "录制中没有第 {frame} 帧。",
+66 -193
View File
@@ -46,6 +46,7 @@ from orisys_studio.acquire_presenter import AcquirePresenter
from orisys_studio.calibration_service import CalibrationService from orisys_studio.calibration_service import CalibrationService
from orisys_studio.display_prefs import DisplayPrefsController from orisys_studio.display_prefs import DisplayPrefsController
from orisys_studio.frame_view import FrameView 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.recording import RecordingCoordinator, record_config_from_prefs
from orisys_studio.replay import ReplayEngine from orisys_studio.replay import ReplayEngine
from orisys_studio.replay_controller import ReplayController 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. #: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False self._display_paused = False
self._active_calibration_id = "" 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 #: Built once the panels exist (see below); until then the status rail
#: treats calibration as unknown. #: treats calibration as unknown.
self.acquire_presenter: AcquirePresenter | None = None 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. #: Built on first use so the pickers are parented where they belong.
self.sensor_dialog = None self.sensor_dialog = None
self.status_presenter = StatusPresenter(self.toolbar) 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. # Bind the shared mask and chart history before controllers can deliver frames.
self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel) self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel)
self.mesh3d_panel.set_domain_latch(self.frame_view.domain_mask) 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: 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 Exporting and applying are separate actions; the rule about which file the
change what a running device is using. The new file becomes the dialog's next open/apply uses lives in ``live_calibration.py``.
selected live calibration so Apply / the next Run can use it.
""" """
from orisys_studio.ui.sensor_dialog import ROLE_FILE self.live_calibration.note_exported(path)
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))
# ------------------------------------------------- live calibration choice # ------------------------------------------------- live calibration choice
def _live_calibration_prefs_key(self) -> str | None: # The rules live in ``live_calibration.py``; these names are the narrow
entry = self.sensor_picker.selected_entry() # interface the sensor dialog, the Run entry, the bare-video prompt and the UI
return None if entry is None else entry.ref # 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( def live_calibration_for(
self, ref: str self, ref: str
) -> tuple[str, Path | None, str] | None: ) -> tuple[str, Path | None, str] | None:
"""Saved live choice for one catalog ref, or ``None`` when nothing was saved. """Saved live choice for one catalog ref, or ``None`` when nothing was saved."""
return self.live_calibration.for_ref(ref)
``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 "")
def live_calibration_choice(self) -> tuple[str, Path | None, str]: def live_calibration_choice(self) -> tuple[str, Path | None, str]:
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle. """``(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 ``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. resolved when the chain opens. No saved choice is raw force for a live open.
""" """
from orisys_studio.ui.sensor_dialog import ROLE_NONE return self.live_calibration.choice()
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
def set_live_calibration_choice( def set_live_calibration_choice(
self, self,
@@ -1597,34 +1598,7 @@ class StudioMainWindow(QMainWindow):
device_id: str | None = None, device_id: str | None = None,
) -> None: ) -> None:
"""Remember the live calibration choice for the currently selected bundle.""" """Remember the live calibration choice for the currently selected bundle."""
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE self.live_calibration.save_choice(role=role, path=path, device_id=device_id)
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
def _resolved_live_calibration( def _resolved_live_calibration(
self, self,
@@ -1633,70 +1607,17 @@ class StudioMainWindow(QMainWindow):
``problem`` is non-empty when a Finger file choice fails the serial check. ``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 return self.live_calibration.resolve()
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, ""
def apply_live_calibration_choice(self) -> None: def apply_live_calibration_choice(self) -> None:
"""Remember the dialog's choice; hot-update the device when a session is open.""" """Remember the dialog's choice; hot-update the device when a session is open."""
if self.sensor_dialog is not None: self.live_calibration.apply_choice()
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)
)
# ------------------------------------------------- calibration (forwards) # ------------------------------------------------- calibration (forwards)
# "What does this coefficient file claim, and does it match the bench?" lives in # "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 # ``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 # Qt). The dialog and the UI checker read these two names; the live calibration
# rule about files. # controller calls the service directly.
def calibration_identity(self, path: str) -> dict: def calibration_identity(self, path: str) -> dict:
return CalibrationService.identity_of(path) return CalibrationService.identity_of(path)
@@ -1708,61 +1629,13 @@ class StudioMainWindow(QMainWindow):
) )
def _on_calibration_apply(self, path: str) -> None: 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 Identity and matching come from ``CalibrationService``, the confirmation is
worker is updated. A calibration that names a *different* device or the window's, and the actual hand-over is a request to the worker: the
configuration is called out explicitly and defaults to "no". recorded version still comes from the acquisition thread's confirmation.
""" """
path = str(path or self._last_calibration_export) self.live_calibration.request_apply(path)
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)
)
def _on_calibration_applied( def _on_calibration_applied(
self, path: str, version: str, frame_id: int, mono_t: float, session: int 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 # 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. # before coefficients measured on another sensor silently change every newton value.
@staticmethod @staticmethod
def identity_of(path: str | Path) -> dict: def read_identity(path: str | Path) -> tuple[dict, str]:
"""What device a coefficient file says it belongs to. """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 One strict parse answers both questions: ``error`` is the SDK's own one-line
would load. A file that cannot be read yields no identity, and the caller then reason when the document cannot be loaded, and ``""`` when it can. A document
applies it without a mismatch claim. 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: try:
import orisys.calibration as sdk_calibration import orisys.calibration as sdk_calibration
document = sdk_calibration.load(str(path), strict=True) 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) 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 {}) identity = dict(getattr(document, "identity", {}) or {})
pipeline = dict(getattr(document, "pipeline", {}) or {}) pipeline = dict(getattr(document, "pipeline", {}) or {})
return { return {
"sensor_type": str(identity.get("sensor_type") or ""), "sensor_type": str(identity.get("sensor_type") or ""),
"sn": str(identity.get("sn") or ""), "sn": str(identity.get("sn") or ""),
"config_ref": str(pipeline.get("config_ref") 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 @staticmethod
def same_path(first: str, second: str) -> bool: 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, QWidget,
) )
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.ui.fit_text import ShrinkableLabel 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 #: 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. #: sees them and a new kind is a visible fallback rather than a printed identifier.
@@ -445,7 +445,8 @@ class SensorDialog(QDialog):
return return
if path != self._device_source: if path != self._device_source:
self._device_source = path self._device_source = path
_sensor_type, sn = calibration_identity(path) identity, _error = CalibrationService.read_identity(path)
sn = str(identity.get("sn") or "")
if sn: if sn:
self._syncing = True self._syncing = True
try: try:
@@ -464,7 +465,11 @@ class SensorDialog(QDialog):
serial = self.device_edit.text().strip() serial = self.device_edit.text().strip()
if not serial: if not serial:
return t("video_analysis.device_required") 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": if sensor_type and sensor_type != "finger":
return t("video_analysis.device_family", family=sensor_type) return t("video_analysis.device_family", family=sensor_type)
if not sn: if not sn:
@@ -49,6 +49,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
) )
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.ui.fit_text import ShrinkableLabel from orisys_studio.ui.fit_text import ShrinkableLabel
#: The calibration combo's two fixed rows. The first is a *file* the profile declares; the #: 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" 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): class VideoAnalysisDialog(QDialog):
"""Ask how one bare video should be analysed, and hand back the three answers.""" """Ask how one bare video should be analysed, and hand back the three answers."""
@@ -454,7 +436,8 @@ class VideoAnalysisDialog(QDialog):
return return
if path != self._device_source: if path != self._device_source:
self._device_source = path self._device_source = path
_sensor_type, sn = calibration_identity(path) identity, _error = CalibrationService.read_identity(path)
sn = str(identity.get("sn") or "")
if sn: if sn:
self.device_edit.setText(sn) self.device_edit.setText(sn)
@@ -472,7 +455,11 @@ class VideoAnalysisDialog(QDialog):
serial = self.device_edit.text().strip() serial = self.device_edit.text().strip()
if not serial: if not serial:
return t("video_analysis.device_required") 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": if sensor_type and sensor_type != "finger":
return t("video_analysis.device_family", family=sensor_type) return t("video_analysis.device_family", family=sensor_type)
if not sn: if not sn:
+26 -5
View File
@@ -742,6 +742,31 @@ def test_analysis_setup_reopens_the_prompt_after_the_first_pass(
assert window.replay_ctl.available_result() is not None 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): 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.""" """A browsed Finger file is accepted only when the device id matches its SN."""
import cv2 import cv2
@@ -763,11 +788,7 @@ def test_a_finger_calibration_file_must_name_the_same_device(qt_app, tmp_path):
None, None,
) )
assert finger is not None, "the catalog has no startable Finger profile" assert finger is not None, "the catalog has no startable Finger profile"
path = tmp_path / "DD02-14.json" path = _coefficient_document(tmp_path, sensor_type="finger", sn="DD02-14")
path.write_text(
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
encoding="utf-8",
)
dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog) dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog)
try: try:
assert dialog.select_profile(finger.ref) 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. # ...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 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"] == ""
+451
View File
@@ -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 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): class _Host(QWidget):
"""Window stand-in with the live-calibration prefs API the dialog expects.""" """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 = _make_picker(qt_app, tmp_path, monkeypatch, build)
picker.set_sensor_type("finger") picker.set_sensor_type("finger")
host, dialog = _dialog_for(picker) host, dialog = _dialog_for(picker)
cal = tmp_path / "DD02-14.json" cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
cal.write_text(
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
encoding="utf-8",
)
try: try:
dialog.select_calibration_file(cal) dialog.select_calibration_file(cal)
# Offscreen dialogs are not shown; ``isHidden`` is the visibility flag. # 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() picker.deleteLater()
def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path): def _finger_dialog(qt_app, tmp_path, monkeypatch):
window = make_window() 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 = tmp_path / "DD02-14.json"
cal.write_text( cal.write_text(
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}), json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
encoding="utf-8", 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( finger = next(
( (
entry entry
+14 -5
View File
@@ -1,6 +1,6 @@
# Studio 软件架构 # Studio 软件架构
本文说明已接线的 S1–S2;后续工作和验收由 本文说明已接线的 S1–S3;后续工作和验收由
[分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据, [分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据,
不能用其中的行数或“已完成”判断当前代码。 不能用其中的行数或“已完成”判断当前代码。
@@ -16,6 +16,11 @@
分析结果的传感器身份来自结果 provenance,进度读取现有 `AnalysisProgress.state()`, 分析结果的传感器身份来自结果 provenance,进度读取现有 `AnalysisProgress.state()`,
不再通过 `ReplayController._progress` 访问其私有控件。 不再通过 `ReplayController._progress` 访问其私有控件。
`live_calibration.py::LiveCalibrationController` 持有实时标定选择:按 bundle 保存的选择、
profile 默认文件、Finger 序列号检查、导出后选择及确认/提交请求;文件身份与匹配复用
`CalibrationService`。窗口保留 `live_calibration_for` 等窄接口转发,供 `SensorDialog`、
运行入口、分析提示和测试使用;应用仍只是向 worker 提交请求,设备确认与记录版本仍走记录通道。
```mermaid ```mermaid
graph TD graph TD
W[StudioMainWindow 装配与接线] --> S[SessionController 会话与遥测] W[StudioMainWindow 装配与接线] --> S[SessionController 会话与遥测]
@@ -23,6 +28,8 @@ graph TD
W --> L[ViewLayoutController 布局] W --> L[ViewLayoutController 布局]
W --> D[DisplayPrefsController 显示偏好] W --> D[DisplayPrefsController 显示偏好]
W --> C[RecordingCoordinator 记录与设备确认] W --> C[RecordingCoordinator 记录与设备确认]
W --> LC[LiveCalibrationController 实时标定选择]
LC --> CF[CalibrationService 文件身份与匹配]
W --> P[StatusPresenter 状态显示] W --> P[StatusPresenter 状态显示]
F[RailState 只读显示事实] --> P F[RailState 只读显示事实] --> P
P --> T[ToolbarWidget 状态轨] P --> T[ToolbarWidget 状态轨]
@@ -38,7 +45,8 @@ graph TD
设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。 设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。
验证入口:`test_status_presenter.py` 可在没有主窗口和设备的情况下检查状态显示; 验证入口:`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 main.py
└─ app_window.py ──┬─ 外壳: brand_bar / toolbar / record_status └─ 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 · 对话框 ├─ 面板: arrow_panel(2D) · mesh3d_panel(3D) · force_plots · cal_panel · 对话框
├─ 领域服务: recording · replay · calibration_service · scene3d.{view,asset_source,coordinator,keys} ├─ 领域服务: recording · replay · calibration_service · scene3d.{view,asset_source,coordinator,keys}
└─ 共享层(无 Qt): acquisition · record · force · scene3d · camera/prefs/paths/i18n/theme/logging └─ 共享层(无 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:实时标定交互 ### S3:实时标定交互
- [ ] 提取 `live_calibration.py` 的选择保存、解析、确认及提交请求。 - [x] 提取 `live_calibration.py` 的选择保存、解析、确认及提交请求。
- [ ] 文件身份和匹配规则复用 `CalibrationService`;对话框通过窄接口访问。 - [x] 文件身份和匹配规则复用 `CalibrationService`;对话框通过窄接口访问。
- [ ] 设备确认、记录版本及采样仍走现有记录通道。 - [x] 设备确认、记录版本及采样仍走现有记录通道。
依赖 S2。验证:`test_live_calibration_dialog`、`test_calibration_service` 及确认事件测试。 依赖 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:分析配置 ### S4:分析配置
@@ -111,79 +113,48 @@ S1 首次 shared 467 passed、18 skipped、3 deselected,UI 632/0;另有关
均为 Windows/Python 3.10.19/offscreen;未验证真实设备、可见桌面或硬件 GL。 均为 Windows/Python 3.10.19/offscreen;未验证真实设备、可见桌面或硬件 GL。
未放宽断言或基线。S2 的非迁移方法 AST 一致是当时证据,后续功能提交不受其约束。 未放宽断言或基线。S2 的非迁移方法 AST 一致是当时证据,后续功能提交不受其约束。
## 当前交接:仅执行 S3 ## 当前交接:S3 返工 round 5 已交付 review
- task_id:`studio-window-refactor`;round:2;updated_at:2026-10-19,Asia/Hong_Kong。 - task_id:`studio-window-refactor`;round:5;updated_at:2026-10-19,Asia/Hong_Kong。
- status:ready;owner:Codex(交出后停止写入);next_agent:DeepSeek 执行方。 - status:review;owner:DeepSeek 执行方(已停止写入);next_agent:GPT/Claude 验收方。
- 用户要求“check existing plan and prepare to handoff to deepseek”;本轮只更新交接。 - 本轮依据:`.scratch/studio-window-refactor/rework-r5/rework-plan.md`(用户确认的返工计划)。
先前分步重构授权继续有效;转发启动指令后执行 S3,完成交付 review。 未放宽 SN 必填、未改并行任务文件、未用全量门禁证明通过;未暂存/提交/推送/切换分支。
S4–S6 留下一轮,不因 S1/S2 结构完成而声称全量验收通过。 - 授权:用户 2026-10-19 转发该返工计划与启动指令,要求按 R1→R2→R4→R3 执行、R4 先交盘点、
完成标 review 交回验收;先前 S3 授权在范围内继续有效。
### 基准与输入 ### 基准与输入
| 仓库 | 分支与 HEAD | 起始状态/本轮修改 | | 仓库 | 分支与 HEAD | 起始状态/本轮修改 |
|---|---|---| |---|---|---|
| `D:\orisys_ws\orisys-app` | dev;9d4563e9292469e9df500a7acab6d26489810455 | 索引空;finger sensor.json 已有未暂存修改;本轮仅此计划 | | `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 | 场加权计划有其他任务修改;设备包、mph、dragon-q6a 无关未跟踪;本轮不改根仓库 | | `D:\orisys_ws` | master;`9201914cbe335b6862058212af0bc2614f2b4792` | 场加权计划等无关修改;本轮不改根仓库 |
当前主窗口 2899 行、213 个方法(含属性访问器);WindowUi/WindowMenus 已接线, 工作目录为应用仓库;解释器 `.venv-build\Scripts\python.exe`(3.10.19、offscreen)。
live_calibration.py/analysis_setup.py 尚不存在。当前源码包含后续回放修复,不能回退旧窗口。 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`, 历史轮次证据:round 3 门禁 `gate-20261019T093016Z/`、round 4 返工 `handoff-20261019-174000/rework-P2.md`;
必要的 `shell_ports.py`;相关实时标定测试及新增单元测试;本计划与 `studio-architecture.md`。 详细过程在 scratch,不在本表复制。
其他阶段、SDK、shared、配置、Autocal 和功能变更均不在本轮范围。
迁移 live choice 的读取/保存、默认路径、解析、导出后选择和确认/提交请求。 ### 验收与下一步
复用同一 prefs 与保存入口,避免第二份状态;服务仍负责文件身份和匹配。
注入显式控件、回调或窄接口,不传整个窗口或使用任意属性代理。
先保留实际被 SensorDialog、运行入口、回放配置和测试调用的窗口桥;列出调用者与保留原因。
测试 monkeypatch 的转发仍须生效,不能复制旧实现来满足测试。
采集线程确认继续只写 RecordingCoordinator/SessionController,不操作 Qt; - 轮次摘要:1)S0–S2;2)准备 S3 交接;3)S3 实施;4)返工 P2;5)行尾、SN 锁定、盘点与身份解析统一(本轮)。
GUI drain 在原时机执行。导出不应用,请求不当作确认;角色 default/file/none、 - 验收者、时间、决定:轮 3 已独立复跑并返工 P2;轮 4 交付后由用户给出本轮返工计划;本轮待复核。
缺失文件、序列号检查、提示文字及 worker 请求参数均保持当前行为。 - 下一步:GPT/Claude 复核 R1–R4 证据并独立复跑 R3 测试与行尾检查,决定 done/rework。
S4–S6 仍未实施。改动保持未暂存;提交建议见 `.scratch/studio-window-refactor/commit-message.txt`,
[场加权标定任务](../../../docs/workflow/field-weighted-calibration-plan.zh.md) 当前 working, 不含并行任务文件与既有 `finger/sensor.json`。
涉及 calibration_service.py、video_analysis_dialog.py、replay_controller.py 和 shared。 - scratch:证据保留在 `.scratch/studio-window-refactor/`;任务 `.active` 检查后移除,日志不清理。
本轮把它们作为只读依赖;不改接口、不覆盖其工作。若接手时其变化影响提取或验证,
先核对该任务的交接记录与文件所有权,停止交叠部分;不得根据旧哈希恢复它们。
需要修改重叠文件或改变功能时请求决定,独立准备可继续。
默认遵循交接约定的三次失败停止规则;未设时间预算。没有提交、推送或硬件授权。
### 执行与验收
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 材料明确标记,不依赖它继续写入。