拆分收尾:标定归属规则移入 CalibrationService(app_window 2340→2304 行)

- What: `apps/studio/src/orisys_studio/calibration_service.py` 新增三条**无状态**规则,
  它们原来长在窗口里:`identity_of(path)`(这份系数文件声称属于哪台设备——用 SDK 自己的
  解析器读,与设备将要加载的是同一份文档;读不出来就什么都不声称)、`same_path(a, b)`
  (同一个配置路径的不同写法不算不一致;两个空值也不算)、
  `target_mismatch(path, *, sensor_type, config_ref)`(在确认框之前就说清楚"这份标定
  看起来不属于这台设备"的理由)。服务本来就是这个文件工作的所有者,规则回到了它这里,
  并且不需要窗口就能测。
- What: 窗口保留 `calibration_identity` / `calibration_target_mismatch` 两个转发
  (tests 与 tools/ui_check.py 通过窗口驱动它们),删除 `_same_path`。
  `_on_calibration_apply` 仍留在窗口:它要弹确认框、要把路径交给采集 worker,那是交互
  与会话,不是关于文件的规则;`_calibration_summary`、`_drain_calibration_events`、
  `_drain_reset_events`、`_on_calibration_point/cancelled/exported` 同理(面板与状态栏
  的胶水)。
- What: 新增 apps/studio/tests/test_calibration_service.py:4 个用例——身份读出
  sensor_type/sn/config_ref、读不出来的文件既无身份也无"不匹配"指控(不能把"不知道"
  当成"不一致")、另一台设备/另一份配置会被逐条报出而同一台不会、同一路径的不同写法
  不算不一致而非路径值算。
- Why: 这是 app_window 拆分计划里最后一块「有独立规则可搬」的部分。搬完之后
  "这份标定是谁的"只有一处实现,窗口只剩对话与转发。
- Verification: pytest shared/tests apps/studio/tests(offscreen)636 passed / 0 failed /
  15 skipped(原 632 + 新增 4);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(同一份实现 + 一层转发)。
- Not in this change: 预览(_start_preview/_stop_preview/_on_preview_tick)与 2D 渲染
  (_render_run_image/_show_image_frame/_depth_range)仍留在窗口——它们是面板与相机的
  胶水,不属于任何一个已拆出的变更驱动;已知缺陷(本次未修,另行处理):worker 的
  on_reset_applied 从未接到 recording.note_reset,真机上按"清零"不会记录确认事件。
This commit is contained in:
ykcheungab
2026-09-15 09:53:47 +08:00
parent 0a42a452dd
commit c62452ec4b
3 changed files with 155 additions and 46 deletions
+10 -46
View File
@@ -46,6 +46,7 @@ from orisys_app_shared.record.sample import to_record_sample
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
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.display_prefs import DisplayPrefsController from orisys_studio.display_prefs import DisplayPrefsController
from orisys_studio.view_layout import ViewLayoutController from orisys_studio.view_layout import ViewLayoutController
from orisys_studio import session_metadata from orisys_studio import session_metadata
@@ -1509,57 +1510,20 @@ class StudioMainWindow(QMainWindow):
self.recorder.log_event("calibration_exported", str(path)) self.recorder.log_event("calibration_exported", str(path))
self.statusBar().showMessage(t("cal.export_only", path=Path(path).name)) self.statusBar().showMessage(t("cal.export_only", path=Path(path).name))
# ------------------------------------------------- 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.
def calibration_identity(self, path: str) -> dict: def calibration_identity(self, path: str) -> dict:
"""What device a coefficient file says it belongs to. return CalibrationService.identity_of(path)
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.
"""
try:
import orisys.calibration as sdk_calibration
document = sdk_calibration.load(path, strict=True)
except Exception: # noqa: BLE001 - reported by the apply path itself
logger.exception("Could not read calibration identity from %s", path)
return {}
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 _same_path(first: str, second: str) -> bool:
if not first or not second:
return True # nothing to compare: not a mismatch
if first == second:
return True
try:
return Path(first).resolve() == Path(second).resolve()
except Exception: # noqa: BLE001 - a non-path value simply differs
return False
def calibration_target_mismatch( def calibration_target_mismatch(
self, path: str, *, sensor_type: str, config_ref: str self, path: str, *, sensor_type: str, config_ref: str
) -> list[str]: ) -> list[str]:
"""Reasons this calibration does not obviously belong to this device. return CalibrationService.target_mismatch(
path, sensor_type=sensor_type, config_ref=config_ref
Applying coefficients that were measured on a different sensor silently )
changes what every recorded newton value means, so the mismatch is stated
before the confirmation, not discovered later.
"""
recorded = self.calibration_identity(path)
reasons: list[str] = []
recorded_type = recorded.get("sensor_type") or ""
if recorded_type and sensor_type and recorded_type != sensor_type:
reasons.append(f"sensor_type: {recorded_type} != {sensor_type}")
recorded_config = recorded.get("config_ref") or ""
if recorded_config and not self._same_path(recorded_config, config_ref):
reasons.append(f"config_ref: {recorded_config} != {config_ref}")
return reasons
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. """Push freshly exported coefficients into the device — on confirmation.
@@ -128,3 +128,67 @@ class CalibrationService:
sensor_type=str(sensor_type or "box"), sensor_type=str(sensor_type or "box"),
local_bundle_dir=local_bundle_dir, local_bundle_dir=local_bundle_dir,
) )
# ------------------------------------------------- who a file belongs to
# The rules below take no state: they answer "what does this file claim, and does it
# 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.
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.
"""
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
logger.exception("Could not read calibration identity from %s", path)
return {}
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 same_path(first: str, second: str) -> bool:
"""Whether two configuration references name the same file.
Two empty values are "the same" on purpose: nothing to compare is not a
mismatch, and refusing to apply an unlabelled calibration would be inventing a
disagreement.
"""
if not first or not second:
return True
if first == second:
return True
try:
return Path(first).resolve() == Path(second).resolve()
except Exception: # noqa: BLE001 - a non-path value simply differs
return False
@classmethod
def target_mismatch(
cls, path: str | Path, *, sensor_type: str, config_ref: str
) -> list[str]:
"""Reasons this calibration does not obviously belong to this device.
Applying coefficients that were measured on a different sensor silently changes
what every recorded newton value means, so the mismatch is stated before the
confirmation, not discovered later.
"""
recorded = cls.identity_of(path)
reasons: list[str] = []
recorded_type = recorded.get("sensor_type") or ""
if recorded_type and sensor_type and recorded_type != sensor_type:
reasons.append(f"sensor_type: {recorded_type} != {sensor_type}")
recorded_config = recorded.get("config_ref") or ""
if recorded_config and not cls.same_path(recorded_config, config_ref):
reasons.append(f"config_ref: {recorded_config} != {config_ref}")
return reasons
@@ -0,0 +1,81 @@
"""Acceptance for the calibration service's rules about *whose* a file is.
The window used to own these: reading a coefficient file's claimed identity and deciding
whether it obviously belongs to the device on the bench. They are pure rules about files,
so they now live with the rest of the calibration file work — and can be tested without
building a window. The end-to-end apply path is still covered by ``test_ui_smoke``.
"""
from __future__ import annotations
import json
from pathlib import Path
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
build_export_model,
)
from orisys_studio.calibration_service import CalibrationService
def _calibration_file(directory: Path, *, sensor_type: str, config_ref: str) -> Path:
"""A coefficient document that names the device it was measured on."""
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-A"}
document["pipeline"] = {"config_ref": config_ref}
path = directory / f"{sensor_type}_calibration.json"
path.write_text(json.dumps(document), encoding="utf-8")
return path
def test_the_identity_names_the_sensor_and_the_configuration(tmp_path):
path = _calibration_file(tmp_path, sensor_type="box", config_ref="D:/lab/a/sensor.json")
identity = CalibrationService.identity_of(path)
assert identity["sensor_type"] == "box"
assert identity["sn"] == "SN-A"
assert identity["config_ref"] == "D:/lab/a/sensor.json"
def test_a_file_that_cannot_be_read_claims_nothing(tmp_path):
"""No identity must mean "no mismatch claim", not "mismatch"."""
broken = tmp_path / "not-a-calibration.json"
broken.write_text("this is not a coefficient document\n", encoding="utf-8")
assert CalibrationService.identity_of(broken) == {}
assert CalibrationService.target_mismatch(
broken, sensor_type="finger", config_ref="D:/lab/b/sensor.json"
) == []
def test_another_devices_calibration_is_reported_with_its_reasons(tmp_path):
path = _calibration_file(tmp_path, sensor_type="box", config_ref="D:/lab/a/sensor.json")
reasons = CalibrationService.target_mismatch(
path, sensor_type="finger", config_ref="D:/lab/b/sensor.json"
)
assert any("sensor_type" in reason for reason in reasons), reasons
assert any("config_ref" in reason for reason in reasons), reasons
# The same device and configuration is not a mismatch.
assert (
CalibrationService.target_mismatch(
path, sensor_type="box", config_ref="D:/lab/a/sensor.json"
)
== []
)
def test_the_same_configuration_named_differently_is_not_a_mismatch(tmp_path):
"""A path is compared as a path: the apply path must not cry wolf over spelling."""
assert CalibrationService.same_path("", "D:/lab/a/sensor.json") is True
assert CalibrationService.same_path("D:/lab/a/sensor.json", "") is True
assert CalibrationService.same_path("D:/lab/a/sensor.json", "D:/lab/./a/sensor.json")
# ...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