Fix the phase-1/2 review findings in recording, calibration and depth replay

What
- record: one packet->row conversion (record/sample.py). A failed compute chain
  records no force value at all, a newton value is written only for an axis that
  really has a calibration curve, and mono_t (not the wall clock) orders history
  with a per-row calibration hash.
- record lifecycle: explicit starting/recording/stopping/stopped/failed states
  owned by the writer thread; stop() reports completed=False on timeout and does
  not write ended_at; consecutive write failures stop acceptance immediately.
- record capacity: byte budget for the video queue (512 MiB default); a full
  queue sheds the *frame* and parks the tiny data row, so a video overflow no
  longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted
  as dropped_gaps / unrecorded_gaps.
- calibration identity: sha256 content hash instead of a path, minted from the
  calibration the sensor actually loaded (reported before the first frame) and
  confirmed by the acquisition thread; events are timestamped when they happen.
- calibration diagnostics: the fit summary, residual and validation error now
  come from the model that export/apply would use (piecewise_linear); validation
  points are held out of the build; repeated reference loads are merged with an
  n-weighted mean and pooled std.
- calibration record: every point keeps its full three-axis sample series
  (frame id, time, raw, newtons, valid) plus diagnostics, written to an
  independent calibration_session_*.json that can be reopened for inspection;
  save record / export coefficients / apply to device are three separate actions.
- depth replay: the cursor's frame is bound to the depth result by request
  sequence and frame id, so a moved cursor never shows another frame's map; the
  SDK is stepped from its current position instead of re-reading from the start;
  the live chain (box deformation+contact, finger unroll+tactile) is replayed
  with the exact resolved config path; algorithm state is rebuilt from a reset
  marker or from the recorded pre-roll, and says so when it cannot be.

Why
The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw
counts into the newton columns, recording a stale value after a failed compute,
losing CSV rows when the video queue filled, reporting success while a writer was
still running, diagnosing a model nobody applies, applying a calibration before
the device confirmed it, dropping the samples behind a calibration point, showing
a stale depth map under a moved cursor and recomputing depth of the wrong frame.

Verification
- D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped
  (log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept
  with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points.
- apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice).
- shared suites: 91 tests, 0 failures (new: test_record_semantics,
  test_record_lifecycle, test_calibration_model, test_calibration_record,
  test_depth_replay).
- apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness
  artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the
  shim cannot drive), unchanged from before this change.
- compileall clean; locale parity zh/en + t() key coverage: 0 failures.

Not in this change
- P2#10 disk-based browsing of a long recording and range CSV export (next
  round, per your choice).
- Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL
  three-dimensional view.
- Hardware verification: no camera attached, so the SDK's real processing chain
  and the pre-roll's effect on a live tracker were exercised with fakes only.
This commit is contained in:
ykcheungab
2026-09-13 14:55:53 +08:00
parent 9354d7be1a
commit 22cc59c402
26 changed files with 3387 additions and 290 deletions
+34 -4
View File
@@ -146,12 +146,22 @@
"depth_config_missing": "Cannot recompute: the recording has no sensor config reference",
"depth_config_file_missing": "Cannot recompute: the config file no longer exists at that path",
"depth_no_video": "Cannot recompute: this recording has no video segment",
"depth_not_recomputed": "Depth has not been recomputed for this cursor position\n(the previous frame's map is not reused)",
"depth_recomputing": "Recomputing depth for the current cursor position…",
"depth_frame_not_recorded": "This frame was not recorded: its data row was lost to a full queue and only the index row remains",
"depth_frame_not_in_video": "This frame never reached the video, so its depth cannot be recomputed",
"depth_state_across_segment": "Replay cannot carry the algorithm state across segments: this one restarts at its first frame",
"depth_state_approximate": "No reset or pre-roll history for this segment: the algorithm state is an approximation",
"depth_warnings": "Recomputation notes: {detail}",
"warnings": "Replay notes: {detail}",
"warn_config_changed": "the config differs from recording time, so the recomputed depth may differ",
"warn_sdk_changed": "the SDK version differs from recording time, so the recomputation may differ",
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
"warn_depth_recomputed": "depth comes from offline recomputation, not from stored data",
"warn_rows_skipped": "some unparsable CSV rows were skipped"
"warn_rows_skipped": "some unparsable CSV rows were skipped",
"warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")",
"warn_state_across_segment": "the algorithm state cannot be carried across a segment",
"warn_state_approximate": "the algorithm state is an approximation"
},
"record": {
"label": "Record",
@@ -180,7 +190,23 @@
"delete_row": "Delete row",
"clear_channel": "Clear channel",
"clear_all": "Clear all",
"export": "Export…", "sn": "SN",
"export": "Export coefficients…",
"export_tip": "Write the SDK coefficient file only; the running device is untouched",
"apply": "Apply to device",
"apply_tip": "Push the last export to the running device (confirmed again)",
"apply_no_export": "No exported coefficients yet — export them first",
"save_record": "Save record…",
"save_record_tip": "Save every point of this session with its full three-axis samples (audit data), independently of the coefficient file",
"save_record_empty": "No calibration points to save yet",
"save_record_ok": "Saved the calibration record to {path}\n{points} point(s), {samples} three-axis samples",
"save_record_failed": "Could not save the calibration record: {error}",
"open_record": "Open record…",
"open_record_tip": "Reopen a saved calibration session to inspect every point's samples and diagnostics",
"open_record_filter": "Calibration records (*.json)",
"open_record_ok": "Loaded {path} ({points} point(s))",
"open_record_warnings": "Note: {detail}",
"open_record_failed": "Could not open the calibration record: {error}",
"sn": "SN",
"sn_placeholder": "Serial number (optional)",
"live_raw_label": "Live raw",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
@@ -206,8 +232,10 @@
"marked_validation": "Point marked as a validation point",
"unmarked_validation": "Validation mark removed",
"fit_insufficient": "A fit needs at least 2 usable points (currently {count})",
"fit_summary": "Fit {channel}: N = {gain} × raw + {offset} ({count} points, max residual {residual} N)",
"fit_summary": "Exported model {channel}: {method} ({count} points / {breakpoints} breakpoints, max residual {residual} N)",
"fit_validation": "Independent validation on {count} point(s), max deviation {error} N",
"method_piecewise_linear": "piecewise linear",
"method_linear": "linear",
"point_title": "Calibration point {channel} @ {load} N",
"point_channel": "Loaded axis",
"point_direction": "Direction",
@@ -230,7 +258,9 @@
"apply_title": "Apply calibration",
"apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.",
"apply_skipped": "Exported; not applied to the device",
"apply_done": "Calibration applied to {sensor_type}",
"apply_pending": "Requested calibration for {sensor_type}; waiting for the device to confirm…",
"applied": "Device confirmed the calibration is in effect (version {version})",
"export_only": "Exported coefficients to {path} (not applied to the device)",
"apply_no_target": "No device to apply to (run an acquisition first)"
},
"status": {
+33 -4
View File
@@ -146,12 +146,22 @@
"depth_config_missing": "无法重算:录制未记录传感器配置",
"depth_config_file_missing": "无法重算:配置文件的原始路径已不存在",
"depth_no_video": "无法重算:该录制没有视频分段",
"depth_not_recomputed": "当前游标位置尚未重算深度\n(不会沿用其他帧的结果)",
"depth_recomputing": "正在为当前游标位置重算深度…",
"depth_frame_not_recorded": "该帧未录制:数据行在队列溢出时丢失,只有索引记录",
"depth_frame_not_in_video": "该帧未写入视频,无法重算深度",
"depth_state_across_segment": "重算无法跨分段延续算法状态:本段从段首重放",
"depth_state_approximate": "本段缺少归零或预卷历史,算法参考状态为近似重建",
"depth_warnings": "重算提示: {detail}",
"warnings": "回放提示: {detail}",
"warn_config_changed": "配置文件与录制时不一致,重算出的深度可能与当时不同",
"warn_sdk_changed": "SDK 版本与录制时不一致,重算结果可能不同",
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
"warn_depth_recomputed": "深度由离线重算得到,不是当时保存的数据",
"warn_rows_skipped": "有无法解析的 CSV 行被跳过"
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
"warn_state_across_segment": "重算不能跨分段延续算法状态",
"warn_state_approximate": "算法参考状态为近似重建"
},
"record": {
"label": "记录",
@@ -180,7 +190,22 @@
"delete_row": "删除行",
"clear_channel": "清空通道",
"clear_all": "全部清空",
"export": "导出…",
"export": "导出系数…",
"export_tip": "只写出 SDK 标定系数文件,不改变运行中的设备",
"apply": "应用到设备",
"apply_tip": "把最近导出的系数下发到运行中的设备(需再次确认)",
"apply_no_export": "还没有导出的系数文件,请先「导出系数」",
"save_record": "保存标定记录…",
"save_record_tip": "保存本次标定会话的全部点与三轴完整采样(审计数据),与系数文件相互独立",
"save_record_empty": "还没有标定点,无法保存标定记录",
"save_record_ok": "已保存标定记录 {path}\n{points} 个点,共 {samples} 条三轴采样",
"save_record_failed": "保存标定记录失败: {error}",
"open_record": "打开标定记录…",
"open_record_tip": "重新打开已保存的标定会话,检查每个点的完整采样与诊断状态",
"open_record_filter": "标定记录 (*.json)",
"open_record_ok": "已载入 {path}({points} 个点)",
"open_record_warnings": "注意: {detail}",
"open_record_failed": "打开标定记录失败: {error}",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw_label": "实时原始值",
@@ -207,8 +232,10 @@
"marked_validation": "该点已标记为验证点",
"unmarked_validation": "该点已取消验证点标记",
"fit_insufficient": "拟合需要至少 2 个有效点(当前 {count} 个)",
"fit_summary": "拟合 {channel}: N = {gain} × raw + {offset}({count} 点,最大残差 {residual} N)",
"fit_summary": "导出模型 {channel}: {method}({count} 点 / {breakpoints} 个折点,最大残差 {residual} N)",
"fit_validation": "独立验证 {count} 点,最大偏差 {error} N",
"method_piecewise_linear": "分段线性",
"method_linear": "线性",
"point_title": "标定点 {channel} @ {load} N",
"point_channel": "加载轴",
"point_direction": "方向",
@@ -231,7 +258,9 @@
"apply_title": "应用标定",
"apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。",
"apply_skipped": "已导出,未应用到设备",
"apply_done": "标定已应用到 {sensor_type}",
"apply_pending": "已请求把标定应用到 {sensor_type},等待设备确认…",
"applied": "设备已确认标定生效(版本 {version})",
"export_only": "已导出系数 {path}(未应用到设备)",
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
},
"status": {
+77
View File
@@ -166,6 +166,83 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
---
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
评审脚本 `D:/orisys_ws/_review_phase12/reproduce.py` 的十条复现条件已全部翻转,改后
输出见同目录 `reproduce_after.log`。以下按发现逐条说明改法。
### 7.1 记录语义(P1-1)
唯一转换入口 `shared/.../record/sample.py::to_record_sample`。规则:
1. 计算链失败(`valid=False` 或 `stale_forces=True`)的帧,六个力值**全部**记为不可用。
SDK 的 `info.FNORMAL` 在计算失败时仍返回上一帧的数值,记录它等于伪造测量值。
2. 只有**该轴确实加载了标定曲线**才写 N 值(`calibrated_axes` 按轴判断,而不是
`force_calibrated` 这个传感器级标志)。未标定的轴留空。
3. `mono_t` 是排序基准,墙钟只进元数据。`SdkPacket.mono_t/wall_t` 默认 `None` 而不是
`0.0`:默认 0 会让所有帧的时间相同。
4. 每行 `calibration_id` 取 **packet 自带版本**(采集线程在采集瞬间盖的哈希),
会话级版本只作回退;一份 CSV 中途换标定也说得清。
### 7.2 记录终态与队列损失(P1-5、P1-6)
- 生命周期显式化为 `starting/recording/stopping/stopped/failed`;**写线程拥有终态与最终
报告**,文件打开、写入、flush、close 全在异常边界内。连续写失败会立刻停止接收
(`rejected_after_failure` 计数),`is_recording` 随之变假 —— 不会出现 failed=True 却
仍在录制。`starting` 也算“录制中”,否则开始录制后立刻点停止会失效。
- `stop(wait=True, timeout=…)` 超时返回 `completed=False` 的报告,**不写 `ended_at`**、
不丢弃线程引用;未结束的 recorder 不允许再次 `start()`。
- 队列按**字节预算**(`max_queue_bytes`,默认 512 MiB)而不是仅按条数:1920×1080 BGR
排 2048 条副本约 11.9 GiB。预算用尽时**丢视频保数据行**;连条目队列都满时把数据行放进
独立的 `_row_backlog`(默认 8192 行),因此视频溢出不再连带丢掉 CSV。
- 真的丢掉的数据行会在 `frames.csv` 留下 `dropped=1` 的索引行,回放据此显示“该帧未录制”,
而不是拿上一帧冒充。统计区分 `dropped_data`、`dropped_video`、`dropped_gaps`
(已标记的缺口)与 `unrecorded_gaps`(连标记都放不下的)。
### 7.3 标定身份与生效确认(P1-7)
- 标定身份是**内容哈希**(`calibration_digest`,sha256),不是路径:同一路径可以重新导出
不同系数。`Sensor` 打开时自带 bundle 标定,因此采集线程在**第一帧之前**就把实际加载的
标定哈希报出来(`on_calibration_applied(path, version, frame_id)`),事件与每行版本都
来自这次确认,而不是“已下发”的意图。
- 事件在**发生时**打时间戳(`log_event(..., mono_t=…, frame_id=…)`),不再在写线程出队时
重新计时。
- 面板拆成三个独立动作:**保存标定记录 / 导出系数 / 应用到设备**。导出只写文件,
应用需要再次确认并等设备确认后才记 `calibration_applied`。
### 7.4 标定诊断与实验明细(P1-2、P1-8)
- `CalibrationRecorder.fit` 用**将要导出的那个模型**(`build_export_model` + SDK 的
`Calibration.convert`)计算残差与验证误差。分段线性模型会精确穿过它自己的标定点,
所以训练残差恒为 0 —— 真正有意义的诊断是**留出验证点**的偏差。界面按“导出模型:分段线性
(N 点 / M 个折点,最大残差)…”显示。
- 验证点**不进入**构建(`CalPanel._rebuild_store` 跳过 `is_validation`),重复参考载荷按
n 加权合并(`merge_samples`),不再“最后一个覆盖前面的”。
- `CalibrationPoint.samples` 保存该点**全部**三轴采样(帧号、相对时间、三轴 raw、三轴 N、
valid),连同排除/验证状态、方向、备注与实际时长一起写入独立的标定会话文件
(`calibration_session_*.json`,`force/calibration_session.py`),并可用「打开标定记录」
重新载入检查。界面上的降采样 trace 只是预览,不是审计数据。
### 7.5 深度重算与游标同步(P1-3、P1-4)
- **不再重开回放位置**:`DepthRecomputer` 记录当前已读到的段内帧号,请求第 2 帧再请求第 3
帧只多读 1 帧(旧实现每次都从头读,2 和 3 会落到 5);往回退才重开视频。
- 每帧都执行**实时同一条链路**:盒子 `compute_deformation + compute_contact`,手指再加上
`FingerRuntime` 的展开与有效区域(`_BoxReplay` / `_FingerReplay`)。
- `config_name` 用**完整解析后的配置路径**(`load_fixed_mode`),不再缩减成父目录名或
stem —— 那可能加载同名但不同的配置,而哈希校验还在比对原路径。
- **算法参考状态**:SDK 的跟踪器/滤波是有状态的,离线复现需要历史。两条可用依据:
1. 操作归零(`zeroing` 事件,带帧号)会清空跟踪器,因此从该帧重放是**精确**的;
2. 录制开始前采集线程保留 `PREROLL_FRAMES`(30 帧,且不超过 192 MiB)的预处理帧,
录制开始时写入 `preroll_0001.mp4`,`session.json` 记 `preroll_frames` /
`preroll_segment`,回放先把它们喂给 SDK 再进入首帧。
都不满足时(跨分段、无归零、无预卷)仍然重算,但结果带
`state_approximate` / `state_across_segment` 提示,**不假装与当时一致**。
- 回放游标与深度结果按**请求序号 + 帧号**绑定:等待期间移动游标会作废旧请求,结果回来
只渲染属于当前帧的那一张;否则面板显示「当前游标位置尚未重算深度」,绝不复用旧图。
---
## 7. 同步回放与深度重算
**决定:不保存深度。** 回放时按录像与配置**离线重算**。
+111 -28
View File
@@ -37,6 +37,7 @@ from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record.sample import to_record_sample
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
@@ -142,6 +143,10 @@ class StudioMainWindow(QMainWindow):
self._record_error_count = 0
self._dropped_reported = 0
self._active_calibration_id = ""
#: Last coefficients file written by the calibration panel (not applied).
self._last_calibration_export = ""
#: Confirmed calibration changes, handed over from the acquisition thread.
self._pending_calibration_events: list[tuple[str, str, int]] = []
self._depth_range_value: tuple[float, float] | None = None
self._last_depth_frame = None
@@ -192,6 +197,7 @@ class StudioMainWindow(QMainWindow):
self.statusBar().addPermanentWidget(self.status_chip)
self.cal_panel.status_message.connect(self.statusBar().showMessage)
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
self._set_phase("idle")
self._build_menus()
self._wire_toolbar()
@@ -230,11 +236,13 @@ class StudioMainWindow(QMainWindow):
self._box_worker = SdkAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
on_calibration_applied=self._on_calibration_applied,
)
if self._finger_worker is None:
self._finger_worker = FingerAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
on_calibration_applied=self._on_calibration_applied,
)
# Both workers publish into the same record channel.
for worker in (self._box_worker, self._finger_worker):
@@ -278,6 +286,11 @@ class StudioMainWindow(QMainWindow):
session_id=int(self.session_store.session_id),
metadata=self._build_session_metadata(config),
calibration_id=self._active_calibration_id,
calibration_version=self._active_calibration_id,
# The frames acquired just before this moment. The depth chain is
# stateful, so without them the first frames of the recording
# could never be recomputed faithfully.
preroll=self._take_preroll(),
)
except Exception as exc:
self.toolbar.set_record_checked(False)
@@ -313,6 +326,23 @@ class StudioMainWindow(QMainWindow):
)
)
def _take_preroll(self) -> list:
"""Frame history from the active worker, if it keeps one.
The buffered frames are consumed (and the buffer restarted) even when no
recording follows, so a stale history can never be attached to a later
recording.
"""
worker = self._active_worker
take = getattr(worker, "take_preroll", None)
if not callable(take):
return []
try:
return list(take())
except Exception: # noqa: BLE001 - the history is optional, not the record
logger.exception("Could not take the pre-roll buffer")
return []
def _build_session_metadata(self, config: RecordConfig) -> dict:
from orisys_app_shared import API_VERSION, __version__ as shared_version
@@ -345,6 +375,10 @@ class StudioMainWindow(QMainWindow):
"calibration": {
"config_ref": config_ref,
"force_calibrated": bool(getattr(info, "force_calibrated", False)),
# Content hash of the coefficients the device confirmed. It is
# filled in by the acquisition thread's confirmation, so it can
# be empty here; the CSV's per-row version is authoritative.
"version": self._active_calibration_id,
},
# Depth is deliberately NOT stored (decided with the team): it is a
# function of the frame and the sensor configuration, so replay
@@ -401,20 +435,15 @@ class StudioMainWindow(QMainWindow):
The plots are allowed to drop frames; this path is not. It is also the
reason calibration sampling no longer depends on the calibration panel
being visible.
The packet is converted exactly once, by the same function the CSV writer
uses, so the store and the file can never disagree about whether a value
is a measurement.
"""
sample = SessionSample(
frame_id=int(getattr(packet, "frame_id", 0)),
t_rel_s=float(packet.timestamp),
fn_raw=float(packet.normal_force_raw),
fx_raw=float(packet.shear_x_raw),
fy_raw=float(packet.shear_y_raw),
fn_n=float(packet.normal_force),
fx_n=float(packet.shear_x),
fy_n=float(packet.shear_y),
valid=bool(getattr(packet, "valid", True)),
calibration_id=self._calibration_id(),
error=str(getattr(packet, "invalid_reason", "") or ""),
record = to_record_sample(
packet, calibration_version=self._active_calibration_id
)
sample = SessionSample.from_record(record)
self.session_store.append(sample)
self.cal_recorder.feed(sample)
# Video + CSV + metadata share this one record path.
@@ -422,14 +451,9 @@ class StudioMainWindow(QMainWindow):
fps = float(getattr(packet, "fps", 0.0) or 0.0)
if fps > 0.0:
self._record_fps = fps
if not sample.valid:
if not record.valid:
self._record_error_count += 1
def _calibration_id(self) -> str:
"""Cached per session — this runs for every acquired sample."""
return self._active_calibration_id
def _wire_toolbar(self) -> None:
self.toolbar.search_clicked.connect(self._on_search_cameras)
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
@@ -886,12 +910,25 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
def _on_calibration_exported(self, path: str) -> None:
"""Apply a freshly exported calibration — only on explicit confirmation.
"""Note the coefficients that were written. Nothing is applied here.
The export no longer overwrites the device bundle, and it no longer
silently pushes the coefficients into both workers. The user is shown
the exact target device first, and only the matching worker is updated.
Exporting and applying are separate actions: writing a file must never
change what a running device is using.
"""
self._last_calibration_export = str(path)
self.recorder.log_event("calibration_exported", str(path))
self.statusBar().showMessage(t("cal.export_only", path=Path(path).name))
def _on_calibration_apply(self, path: str) -> None:
"""Push freshly exported coefficients into the device — on confirmation.
The user is shown the exact target device first, and only the matching
worker is updated.
"""
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 ""
answer = QMessageBox.question(
@@ -908,12 +945,38 @@ class StudioMainWindow(QMainWindow):
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.recorder.log_event("calibration_applied", f"{sensor_type}: {path}")
self.statusBar().showMessage(
t("cal.apply_done", sensor_type=sensor_type)
t("cal.apply_pending", sensor_type=sensor_type)
)
def _on_calibration_applied(self, path: str, version: str, frame_id: int) -> None:
"""Called from the acquisition thread once new coefficients are loaded."""
# The acquisition thread must not touch Qt, so only plain state moves here.
self._pending_calibration_events.append(
(str(path), str(version), int(frame_id))
)
def _drain_calibration_events(self) -> None:
"""Publish confirmed calibration changes on the UI thread."""
while self._pending_calibration_events:
path, version, frame_id = self._pending_calibration_events.pop(0)
# The version is the calibration's content hash: a path can be
# re-exported with different coefficients, a hash cannot.
self._active_calibration_id = version or path
self.session_store.set_calibration_id(self._active_calibration_id)
self.recorder.set_calibration_version(self._active_calibration_id)
self.recorder.log_event(
"calibration_applied",
f"{path} ({self._active_calibration_id[:12] or 'unhashed'})",
frame_id=frame_id,
)
self.statusBar().showMessage(
t("cal.applied", version=self._active_calibration_id[:12])
)
def _worker_for(self, sensor_type: str):
self._ensure_workers()
if sensor_type == "finger":
@@ -1160,8 +1223,12 @@ class StudioMainWindow(QMainWindow):
self._last_session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = sensor_type
# Fresh record channel for the new session.
self._active_calibration_id = str(config_ref)
# Fresh record channel for the new session. The calibration identity is
# unknown until the device confirms which coefficients it loaded, so it
# starts empty and is filled from that confirmation — a path is not a
# version, and the same path can hold different numbers.
self._active_calibration_id = ""
self._pending_calibration_events.clear()
self._record_error_count = 0
self._dropped_reported = 0
self._depth_range_value = None
@@ -1171,7 +1238,7 @@ class StudioMainWindow(QMainWindow):
self._stop_recording(wait=True)
self._session_counter += 1
self.session_store.reset(
session_id=self._session_counter, calibration_id=str(config_ref)
session_id=self._session_counter, calibration_id=""
)
self._ensure_workers()
self._active_worker = (
@@ -1254,11 +1321,26 @@ class StudioMainWindow(QMainWindow):
self._active_worker.reset()
self.force_plots.reset()
self._last_plot_packet_key = None
self.recorder.log_event("zeroing", "tracker reset")
# Recorded with the frame it happened at: a reset clears the SDK's
# tracker, so an offline depth replay can start here instead of
# guessing at the algorithm's earlier state.
self.recorder.log_event(
"zeroing",
"tracker reset",
frame_id=self._current_frame_id(),
)
self.statusBar().showMessage(t("status.reset_ok"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _current_frame_id(self) -> int:
"""The most recent frame id in the session store (-1 when unknown)."""
try:
frame_ids = self.session_store.data().frame_ids
except Exception: # noqa: BLE001 - an empty store is not an error here
return -1
return int(frame_ids[-1]) if len(frame_ids) else -1
def _on_worker_error(self, message: str) -> None:
self._last_session_error = message
logger.error("Acquisition error: %s", message)
@@ -1360,6 +1442,7 @@ class StudioMainWindow(QMainWindow):
self.cal_recorder.tick()
self.cal_panel.poll_recorder()
self._drain_record_alarms()
self._drain_calibration_events()
self._report_dropped_samples()
mode = self.arrow_panel.view_mode()
+114 -10
View File
@@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QFrame,
QHBoxLayout,
@@ -32,6 +33,11 @@ from orisys_app_shared.force.calibration_io import (
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_session import (
read_calibration_session,
session_filename,
write_calibration_session,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir
from orisys_app_shared.theme import color
@@ -56,8 +62,10 @@ def _mono_font() -> str:
class CalPanel(QFrame):
status_message = pyqtSignal(str)
#: Coefficients were written; the window asks the user before applying them.
#: Coefficients were written to disk. It does **not** mean they were applied.
calibration_exported = pyqtSignal(str)
#: The operator asked to push the last export into the running device.
apply_requested = pyqtSignal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -69,6 +77,8 @@ class CalPanel(QFrame):
#: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the
#: lifecycle no longer depends on this panel being visible.
self._recorder = None
#: Last successfully written coefficients file; applying is explicit.
self._last_export_path = ""
self._live_fn = 0.0
self._live_fx = 0.0
self._live_fy = 0.0
@@ -190,14 +200,19 @@ class CalPanel(QFrame):
self.btn_clear_all = QPushButton()
self.btn_toggle_excluded = QPushButton()
self.btn_toggle_validation = QPushButton()
self.btn_open_record = QPushButton()
self.btn_save_record = QPushButton()
self.btn_export = QPushButton()
self.btn_export.setObjectName("primaryButton")
self.btn_apply = QPushButton()
self.btn_apply.setObjectName("primaryButton")
for button in (
self.btn_delete,
self.btn_clear_channel,
self.btn_clear_all,
self.btn_toggle_excluded,
self.btn_toggle_validation,
self.btn_open_record,
self.btn_save_record,
):
# The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true")
@@ -207,14 +222,20 @@ class CalPanel(QFrame):
self.btn_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_open_record.clicked.connect(self._on_open_record)
self.btn_save_record.clicked.connect(self._on_save_record)
self.btn_export.clicked.connect(self._on_export)
self.btn_apply.clicked.connect(self._on_apply)
actions.addWidget(self.btn_toggle_excluded)
actions.addWidget(self.btn_toggle_validation)
actions.addWidget(self.btn_delete)
actions.addWidget(self.btn_clear_channel)
actions.addWidget(self.btn_clear_all)
actions.addStretch(1)
actions.addWidget(self.btn_open_record)
actions.addWidget(self.btn_save_record)
actions.addWidget(self.btn_export)
actions.addWidget(self.btn_apply)
root.addLayout(actions)
self._refresh_enabled()
@@ -284,8 +305,14 @@ class CalPanel(QFrame):
self.btn_delete.setText(t("cal.delete_row"))
self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_open_record.setText(t("cal.open_record"))
self.btn_save_record.setText(t("cal.save_record"))
self.btn_export.setText(t("cal.export"))
self.btn_export.setText(t("cal.export"))
self.btn_apply.setText(t("cal.apply"))
self.btn_open_record.setToolTip(t("cal.open_record_tip"))
self.btn_save_record.setToolTip(t("cal.save_record_tip"))
self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setToolTip(t("cal.apply_tip"))
self.btn_toggle_excluded.setText(t("cal.state_exclude"))
self.btn_toggle_validation.setText(t("cal.mark_validation"))
self.btn_toggle_excluded.setToolTip(t("cal.state_exclude_tip"))
@@ -352,6 +379,11 @@ class CalPanel(QFrame):
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
radio.setEnabled(not self._recording)
self.btn_export.setEnabled(not self._recording)
# Saving the audit record is independent of the coefficients, and applying
# is only meaningful once coefficients exist.
self.btn_save_record.setEnabled(not self._recording and self._has_points())
self.btn_open_record.setEnabled(not self._recording)
self.btn_apply.setEnabled(bool(self._last_export_path) and not self._recording)
if self._recording:
self.progress.setVisible(True)
record_hint = t("cal.record_disabled") if not self._running else ""
@@ -491,12 +523,14 @@ class CalPanel(QFrame):
if not fit.usable:
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
return
# The method and the breakpoint count describe the model that export and
# the device use; the least-squares pair is only an equivalent summary.
text = t(
"cal.fit_summary",
channel=self._channel.upper(),
gain=f"{fit.gain:g}",
offset=f"{fit.offset:g}",
method=t(f"cal.method_{fit.method}"),
count=fit.n_points,
breakpoints=fit.breakpoints,
residual=f"{fit.max_residual_n:.4f}",
)
if fit.validation_points:
@@ -547,23 +581,29 @@ class CalPanel(QFrame):
)
def _rebuild_store(self) -> None:
"""Rebuild the export store from the points that are still in the fit."""
"""Rebuild the export store from the points that are still in the fit.
Validation points are held out: they exist to check the model, so letting
them into the build would both corrupt it and make the "independent"
error meaningless. Repeated reference loads are merged rather than
overwritten, so every sample that was actually taken reaches the model.
"""
self._store.clear()
if self._recorder is None:
return
for point in self._recorder.all_points():
if point.excluded:
if point.excluded or point.is_validation:
continue
stats = point.loaded_stats()
if not np.isfinite(stats.mean):
continue
self._store.upsert(
self._store.aggregate(
point.channel,
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(stats.mean),
n=int(stats.n),
std=float(stats.std),
std=float(stats.std) if np.isfinite(stats.std) else 0.0,
),
)
@@ -613,10 +653,74 @@ class CalPanel(QFrame):
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
return
self._last_export_path = str(primary)
self._refresh_enabled()
msg = t("cal.export_ok", path=str(primary))
if warnings:
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
self.status_message.emit(msg)
# Ask the window to confirm the target device before applying.
# The window records which coefficients were exported. Applying them is a
# separate button, so exporting cannot silently change a running device.
self.calibration_exported.emit(str(primary))
QMessageBox.information(self, t("panels.cal"), msg)
def _on_apply(self) -> None:
if not self._last_export_path:
self.status_message.emit(t("cal.apply_no_export"))
return
self.apply_requested.emit(self._last_export_path)
def _on_save_record(self) -> None:
"""Write the session's audit record: every point and every sample."""
if self._recorder is None:
return
points = self._recorder.all_points()
if not points:
self.status_message.emit(t("cal.save_record_empty"))
return
meta = meta_from_sensor_json(self._config_ref)
meta["sensor_type"] = self._sensor_type
meta["sn"] = self.sn_edit.text().strip()
path = calibration_export_dir() / session_filename(self._sensor_type)
try:
written = write_calibration_session(path, points, meta=meta)
except Exception as exc: # noqa: BLE001 - reported to the operator
QMessageBox.warning(
self, t("panels.cal"), t("cal.save_record_failed", error=str(exc))
)
return
samples = sum(len(point.samples) for point in points)
msg = t("cal.save_record_ok", path=str(written), points=len(points), samples=samples)
self.status_message.emit(msg)
QMessageBox.information(self, t("panels.cal"), msg)
def _on_open_record(self) -> None:
"""Reopen a saved session so it can be inspected again."""
if self._recorder is None:
return
path, _filter = QFileDialog.getOpenFileName(
self,
t("cal.open_record"),
str(calibration_export_dir()),
t("cal.open_record_filter"),
)
if not path:
return
try:
record = read_calibration_session(path)
except Exception as exc: # noqa: BLE001 - reported to the operator
QMessageBox.warning(
self, t("panels.cal"), t("cal.open_record_failed", error=str(exc))
)
return
self._recorder.load_points(record.points, replace=True)
self._rebuild_store()
self._refresh_table()
self._refresh_enabled()
msg = t("cal.open_record_ok", path=record.path.name, points=len(record.points))
if record.warnings:
msg += "\n" + t("cal.open_record_warnings", detail="; ".join(record.warnings))
self.status_message.emit(msg)
def _has_points(self) -> bool:
return bool(self._recorder is not None and self._recorder.all_points())
@@ -463,18 +463,21 @@ class ForcePlotsPanel(QFrame):
if self._store is None:
return
t0, t1 = self._window
slice_ = self._store.visible(t0, t1, max_points=self._max_draw_points)
slice_ = self._store.visible(
t0, t1, max_points=self._max_draw_points, unit=self._unit
)
self._syncing = True
try:
if len(slice_) == 0:
for curve in self.curves:
curve.setData([], [])
else:
values = slice_.value_for_unit(self._unit)
# One shared rule for every view: valid *and* finite. A stale
# number left by a failed compute step must not reappear here
# after another view already excluded it.
times, values, _mask = slice_.plottable(self._unit)
for index, curve in enumerate(self.curves):
column = values[:, index]
finite = np.isfinite(column)
curve.setData(slice_.times[finite], column[finite])
curve.setData(times, values[:, index])
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
self._apply_axis_mode()
@@ -584,7 +587,9 @@ class ForcePlotsPanel(QFrame):
return
# The overview is a full-store read, so only rebuild it when it can have
# changed (window/unit/session) rather than on every pan.
slice_ = self._store.visible(first, last, max_points=self._overview_points())
slice_ = self._store.visible(
first, last, max_points=self._overview_points(), unit=self._unit
)
values = slice_.value_for_unit(self._unit)
column = values[:, 0]
finite = np.isfinite(column)
@@ -29,7 +29,12 @@ from PyQt5.QtWidgets import (
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import DepthRecomputer, RecordingReader, list_recordings
from orisys_app_shared.record import (
DepthRecomputer,
DepthResult,
RecordingReader,
list_recordings,
)
from orisys_app_shared.record.playback import FORCE_COLUMNS
from orisys_app_shared.theme import color
@@ -63,6 +68,13 @@ class PlaybackDialog(QDialog):
self._depth_thread: threading.Thread | None = None
self._depth_result = None
self._depth_error: BaseException | None = None
#: Which cursor position each depth answer belongs to. Recomputation is
#: asynchronous, so an answer can arrive after the cursor has moved; the
#: request it answers is remembered and compared, never assumed.
self._depth_request_seq = 0
self._depth_pending_seq: int | None = None
self._depth_pending_position: float | None = None
self._depth_result_seq: int | None = None
self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth)
@@ -172,6 +184,8 @@ class PlaybackDialog(QDialog):
self._render_curves()
self._refresh_warnings()
self._depth_ok = False
self._depth_result = None
self._depth_result_seq = None
self.set_position(0.0)
def _refresh_warnings(self) -> None:
@@ -197,11 +211,11 @@ class PlaybackDialog(QDialog):
for curve in self.curves:
curve.setData([], [])
return
values = info.value_for_unit(self._unit)
# Same rule as the live charts: invalid or non-finite points are not
# drawn, whichever view is asking.
times, drawn, _mask = info.plottable(self._unit)
for index, curve in enumerate(self.curves):
column = values[:, index]
finite = np.isfinite(column)
curve.setData(info.times[finite], column[finite])
curve.setData(times, drawn[:, index])
self.plot_widget.ci.layout.setRowStretchFactor(index, 1)
self.plot_widget.getItem(index, 0).setXRange(
float(info.times[0]), float(info.times[-1]), padding=0.02
@@ -238,7 +252,9 @@ class PlaybackDialog(QDialog):
for line in self.cursor_lines:
line.setValue(self._position)
self._show_video_frame()
if self._depth_ok:
if self._depth_ok or self._depth_result is not None:
# The held depth belongs to the frame it was computed for. Moving the
# cursor must not keep showing it as if it were synchronised.
self._show_depth_frame()
self.cursor_moved.emit(self._position)
@@ -287,16 +303,26 @@ class PlaybackDialog(QDialog):
return
self._depth_ok = False
self._depth_result = None
self._depth_result_seq = None
self._depth_error = None
self.depth_label.setText(t("playback.depth_running"))
position = self._position
# Bind the answer to this request: a result that arrives after the cursor
# moved is discarded rather than pasted onto the new position.
self._depth_request_seq += 1
seq = self._depth_request_seq
self._depth_pending_seq = seq
self._depth_pending_position = position
def work() -> None:
# Runs off the UI thread and touches no Qt object.
try:
self._depth_result = self._recomputer.depth_at(position)
result = self._recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll
self._depth_error = exc
return
self._depth_result = result
self._depth_result_seq = seq
self._depth_thread = threading.Thread(
target=work, name="depth-replay", daemon=True
@@ -310,19 +336,62 @@ class PlaybackDialog(QDialog):
if thread is None or thread.is_alive():
return
self._depth_poll.stop()
pending = self._depth_pending_seq
self._depth_pending_seq = None
self._depth_pending_position = None
if self._depth_error is not None:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(
t("playback.depth_failed", detail=str(self._depth_error))
)
self._depth_error = None
return
result = self._depth_result
if result is None:
if result is None or self._depth_result_seq != pending:
# A newer request (or a moved cursor) owns the pane now.
self._show_depth_frame()
return
if not self._depth_matches_cursor(result):
# The cursor moved while the SDK was reading: do not draw an answer
# for another frame, and say the pane needs recomputing.
self._show_depth_frame()
return
self._render_depth_result(result)
def _current_frame(self):
"""The recorded frame under the cursor, or None without a frame index."""
frame_at = getattr(self._reader, "frame_at", None)
if not callable(frame_at):
return None
try:
return frame_at(self._position)
except Exception: # noqa: BLE001 - a stub reader has no frame index
return None
def _depth_matches_cursor(self, result) -> bool:
"""True when ``result`` was computed for the frame under the cursor."""
frame = self._current_frame()
frame_id = getattr(result, "frame_id", None)
if frame is not None and frame_id is not None and int(frame_id) >= 0:
return int(frame_id) == int(frame.frame_id)
t_rel = getattr(result, "t_rel_s", None)
if t_rel is None:
# Nothing in the result says which frame it belongs to, so it cannot
# be shown to be stale either.
return True
if frame is not None:
return abs(float(t_rel) - float(frame.t_rel_s)) < 1e-6
return abs(float(t_rel) - self._position) < 1e-9
def _render_depth_result(self, result) -> None:
code = str(getattr(result, "error", "") or "")
if code in ("not_recomputed", "recomputing", "stale"):
# A placeholder, not a failure: the pane describes the cursor.
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t(f"playback.depth_{code}"))
return
if not result.ok:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
@@ -337,6 +406,7 @@ class PlaybackDialog(QDialog):
self.depth_label.setText(t("playback.depth_empty"))
return
self._depth_ok = True
self.depth_label.setText(self._depth_warning_text(result))
rgb = bgr[:, :, ::-1].copy()
height, width, channels = rgb.shape
qimage = QImage(
@@ -346,12 +416,40 @@ class PlaybackDialog(QDialog):
self.depth_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.depth_label.setPixmap(pixmap)
self.depth_label.setText("")
@staticmethod
def _depth_warning_text(result) -> str:
"""Say when a replay could not reproduce the live algorithm state."""
codes = tuple(getattr(result, "warnings", ()) or ())
if not codes:
return ""
return t(
"playback.depth_warnings",
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
)
def _show_depth_frame(self) -> None:
"""Re-render the last result at the new position (no SDK work)."""
if self._depth_result is not None:
self._render_depth_result(self._depth_result)
"""Draw the depth for the cursor's frame, or say it must be recomputed.
Re-drawing the held map here is what used to show a stale depth as though
it were in sync with the cursor.
"""
result = self._depth_result
if result is not None and self._depth_matches_cursor(result):
self._render_depth_result(result)
return
if (
self._depth_pending_seq is not None
and self._depth_pending_position == self._position
and self._depth_busy()
):
self._render_depth_result(
DepthResult(error="recomputing", t_rel_s=self._position)
)
return
self._render_depth_result(
DepthResult(error="not_recomputed", t_rel_s=self._position)
)
# ------------------------------------------------------------------- labels
def retranslate(self) -> None:
+30 -10
View File
@@ -1,4 +1,4 @@
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
from __future__ import annotations
@@ -309,6 +309,13 @@ def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40
def test_calibration_diagnostics_drive_the_fit(make_window):
"""The diagnostics must describe the model that would be exported.
The export builds a piecewise-linear curve, which passes through every point
it is built from — so the training residual is zero by construction and an
off-trend point can only show up as a *validation* error. A private
least-squares residual would have described a model nobody applies.
"""
window = _show(make_window(), (1400, 880))
window.cal_panel.set_running(True)
_add_calibration_point(window, load=1.0, raw=100.2)
@@ -319,23 +326,36 @@ def test_calibration_diagnostics_drive_the_fit(make_window):
recorder = window.cal_recorder
fit = recorder.fit("fn")
assert fit.usable and fit.n_points == 4
worst = fit.max_residual_n
assert worst > 0.05, "the off-trend point produced no residual"
assert fit.method == "piecewise_linear", fit.method
assert fit.breakpoints == 4, fit.breakpoints
assert fit.max_residual_n <= 1e-9, "the exported model misses its own points"
assert window.cal_panel.fit_label.text()
# Hold the off-trend point (load 2.0 at raw 400) out: the exported model is
# then built without it and the misfit is measurable.
window.cal_panel.table.setCurrentCell(1, 0)
window.cal_panel._on_toggle_validation()
held_out = recorder.fit("fn")
assert held_out.validation_points == 1
assert held_out.validation_error_n > 0.05, held_out.validation_error_n
assert recorder.points[1].is_validation
assert np.isnan(recorder.points[1].residual_n), "validation point entered the fit"
window.cal_panel._on_toggle_validation()
window.cal_panel._on_toggle_excluded()
assert recorder.fit("fn").max_residual_n < worst, "exclusion did not tighten the fit"
assert recorder.points[1].excluded
# An excluded point leaves the export store but stays on record.
assert len(window.cal_panel._store.channel("fn")) == 3
window.cal_panel.table.setCurrentCell(2, 0)
window.cal_panel._on_toggle_validation()
held_out = recorder.fit("fn")
assert held_out.validation_points == 1
assert recorder.points[2].is_validation
assert np.isnan(recorder.points[2].residual_n), "validation point entered the fit"
# The exported model no longer passes through the excluded point.
from orisys_app_shared.force.calibration_io import (
build_export_model,
predict_channel,
)
store = window.cal_panel._store
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
assert abs(predict_channel(model, "fn", 400.0) - 2.0) > 0.05
def test_calibration_point_dialog_shows_three_axes(make_window):
+48 -14
View File
@@ -608,9 +608,12 @@ def main() -> int:
"the fit summary is empty",
)
# Three more points, one of them off-trend, so the fit has a residual
# that excluding the outlier must reduce and enough left over to hold
# one out as a validation point.
# Four points, one of them off-trend (raw 400 where the others imply
# ~200). The exported model is piecewise-linear, so it passes through
# every point it is built from: the training residual is zero by
# construction and the off-trend point can only show up as a
# *validation* error. The summary must describe that model, not a
# private least-squares line.
for load, raw in ((2.0, 400.0), (3.0, 300.6), (5.0, 500.9)):
window.cal_recorder.start(
channel="fn", reference_load_n=load, duration_s=1.0
@@ -621,22 +624,24 @@ def main() -> int:
app.processEvents()
check(window.cal_panel.table.rowCount() == 4, "calibration rows missing")
worst = window.cal_recorder.fit("fn").max_residual_n
check(worst > 0.05, f"the off-trend point produced no residual: {worst}")
window.cal_panel.table.setCurrentCell(1, 0)
window.cal_panel._on_toggle_excluded()
app.processEvents()
fit = window.cal_recorder.fit("fn")
check(
window.cal_recorder.fit("fn").max_residual_n < worst,
"excluding an outlier did not tighten the fit",
fit.method == "piecewise_linear",
f"the summary is not about the exported model: {fit.method!r}",
)
check(
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
"the excluded row does not show its state",
fit.breakpoints == 4,
f"the exported model has {fit.breakpoints} breakpoints, not 4",
)
check(
fit.max_residual_n <= 1e-9,
"the exported model does not reproduce its own calibration points: "
f"max residual {fit.max_residual_n}",
)
# A validation point is held out of the fit and reported separately.
window.cal_panel.table.setCurrentCell(2, 0)
# Hold the off-trend point out: the exported model is then built
# without it and the misfit becomes visible as a validation error.
window.cal_panel.table.setCurrentCell(1, 0)
window.cal_panel._on_toggle_validation()
app.processEvents()
check(
@@ -644,6 +649,35 @@ def main() -> int:
in window.cal_panel.fit_label.text(),
"no validation summary after marking a validation point",
)
validation = window.cal_recorder.fit("fn").validation_error_n
check(
validation > 0.05,
f"the off-trend point produced no validation error: {validation}",
)
window.cal_panel._on_toggle_validation()
# Excluding a point removes it from the build, so the exported model
# no longer passes through it.
window.cal_panel._on_toggle_excluded()
app.processEvents()
check(
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
"the excluded row does not show its state",
)
from orisys_app_shared.force.calibration_io import (
build_export_model,
predict_channel,
)
store = window.cal_panel._store
model = build_export_model(
{key: store.channel(key) for key in ("fn", "fx", "fy")}
)
predicted = predict_channel(model, "fn", 400.0)
check(
abs(predicted - 2.0) > 0.05,
f"excluding a point left the exported model unchanged: {predicted}",
)
window.cal_recorder.reset_session()
window.cal_panel._refresh_table()