From b200f3d55ced088b89aada84f6a5599920c137fc Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 11 Oct 2026 19:03:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(autocal):=20=E6=B7=BB=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E5=AE=9A=E8=AE=B0=E5=BD=95=E6=80=BB=E8=A7=88=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E7=82=B9=E8=AE=A1=E5=88=92=E5=AF=B9=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集中显示采集覆盖、加载卸载响应与已确认法向灵敏度,并保存计划和复核快照。通过44项相关测试、已有视频会话检查及代码和文档检查。 --- apps/autocal/config/locales/en_US.json | 38 +++ apps/autocal/config/locales/zh_CN.json | 38 +++ .../docs/calibration-data-format.zh.md | 25 +- .../src/orisys_autocal/record_overview.py | 106 +++++++ .../src/orisys_autocal/session_review.py | 1 + .../orisys_autocal/ui/calibration_panel.py | 4 +- .../ui/record_overview_dialog.py | 278 ++++++++++++++++++ .../ui/session_review_dialog.py | 4 +- apps/autocal/tests/test_manual_trial.py | 7 + apps/autocal/tests/test_record_overview.py | 173 +++++++++++ 10 files changed, 663 insertions(+), 11 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/record_overview.py create mode 100644 apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py create mode 100644 apps/autocal/tests/test_record_overview.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 94ef9d3..81bc04c 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -490,6 +490,44 @@ "depth_missing": "No valid depth/deformation for this frame", "flow_missing": "No valid displacement for this frame" }, + "overview": { + "title": "Calibration record overview", + "notice": "Inspect acquired sites and normal response; click UV points or table rows. Circles: confirmed windows; squares: candidates. Trial shades differ. The fit uses all eligible loading data for the site and is not refitted by the trial filter.", + "coverage": "Acquisition status", + "sensitivity": "Normal sensitivity", + "import_plan": "Import planned-point CSV…", + "clear_plan": "Clear plan comparison", + "load": "Load saved review…", + "save": "Save new overview and review", + "summary": "Acquired {collected}; planned {planned}; missing {missing}; incomplete attempts {incomplete}; pending review {pending}; reviewed {reviewed}; outside plan {extra}.", + "no_plan": "Without a plan, missing sites cannot be determined. Plan order is separate from acquisition site_id.", + "uncollected": "Not acquired", + "incomplete": "Has incomplete attempts", + "pending": "Pending review", + "reviewed": "Reviewed", + "confirmed": "Confirmed", + "candidate": "Candidate", + "coverage_map": "Red: incomplete attempts; orange: pending; green: reviewed; grey: not acquired", + "sensitivity_map": "Confirmed sensitivity: blue→red {low:.4g}→{high:.4g} raw/N
Grey: no confirmed fit", + "details": "Inspect signal / review windows…", + "trial_filter": "Displayed trial", + "all_trials": "All trials", + "site_fit": "Site loading fit (all eligible trials)", + "response_title": "Normal response: reference force → raw signal", + "selected_fit": "Site exploratory sensitivity {sensitivity} raw/N ({basis}); fit range and trial count are in the table. Physical signal/reference validation is still required.", + "no_windows": "No complete hold windows for this site/trial. Incomplete trials are excluded from fits; raw files are retained.", + "col_order": "Plan order", + "col_site": "Acquired site", + "col_uv": "U / V (px)", + "col_xyz": "Sensor XYZ (mm)", + "col_status": "Status", + "col_trials": "Complete / all trials", + "col_windows": "Confirmed / complete windows", + "col_levels": "Confirmed loading levels (N)", + "col_sensitivity": "Sensitivity (raw/N) / basis", + "col_range": "Fit range (N)", + "col_fit_trials": "Fit trials" + }, "quality": { "open": "Repeatability and validation…", "title": "Site quality analysis", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 8013355..702a3d2 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -490,6 +490,44 @@ "depth_missing": "本帧无有效深度/形变信号", "flow_missing": "本帧无有效位移信号" }, + "overview": { + "title": "标定记录总览", + "notice": "查看实际采集覆盖与法向响应;点击 UV 点或表格联动。圆点为已确认窗口,方点为候选窗口;各 trial 用不同明暗度。拟合线为本测点全部符合条件的加载数据,筛选 trial 不会重新拟合。", + "coverage": "采集状态", + "sensitivity": "法向灵敏度", + "import_plan": "导入计划测点 CSV…", + "clear_plan": "清除计划对照", + "load": "载入已保存复核…", + "save": "另存总览与复核", + "summary": "已采集 {collected} 点;计划 {planned} 点;未采 {missing};有未完成试验 {incomplete};待复核 {pending};已复核 {reviewed};计划外 {extra}。", + "no_plan": "未导入计划表,无法判断漏采点;序号仅用于计划对照,不作为采集 site_id。", + "uncollected": "尚未采集", + "incomplete": "有未完成试验", + "pending": "待复核", + "reviewed": "已复核", + "confirmed": "已确认", + "candidate": "候选", + "coverage_map": "红:有未完成试验;橙:待复核;绿:已复核;灰:未采", + "sensitivity_map": "已确认灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N
灰:无已确认拟合", + "details": "查看信号/复核窗口…", + "trial_filter": "显示试验", + "all_trials": "全部 trial", + "site_fit": "测点加载拟合(全部符合条件的 trial)", + "response_title": "法向响应:参考力 → 原始信号", + "selected_fit": "本测点初步灵敏度 {sensitivity} raw/N({basis});拟合范围和 trial 数见表格。原始信号与实际参考力仍须实机验证。", + "no_windows": "所选点/trial 无完整保持窗口;未完成试验不参与拟合,原始文件仍保留。", + "col_order": "计划序号", + "col_site": "采集测点", + "col_uv": "U / V (px)", + "col_xyz": "传感器 XYZ (mm)", + "col_status": "状态", + "col_trials": "完成/全部 trial", + "col_windows": "确认/完整窗口", + "col_levels": "已确认加载档位 (N)", + "col_sensitivity": "灵敏度 (raw/N)/依据", + "col_range": "拟合范围 (N)", + "col_fit_trials": "拟合 trial 数" + }, "quality": { "open": "重复性与验证…", "title": "测点质量分析", diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index 83ceda1..63cef46 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -1,6 +1,6 @@ # Autocal 标定操作与数据格式草稿 -状态:2026-10-11 修订,待实机验收。阶段一仅针对 finger。多测点会话、手动加载/卸载保持、视频和相机输入、模拟页测点操作表导出已接通;已可重开会话查看保持窗口、加载/卸载曲线及 UV 测点初步灵敏度;FT 同步、图像基准快照及正式灵敏度结果仍待实现。软件检查不代表设备或标定精度通过。 +状态:2026-10-11 修订,阶段一仅针对 finger,待实机验收。已接通手动加载/卸载、多测点采集、记录总览和窗口复核;FT 同步及正式灵敏度结果仍待实现。 ## 范围与坐标 @@ -34,11 +34,11 @@ “提前结束(保留未完成数据)”立即停止本次试验;不删除数据,不续录旧 trial。视频播放结束或写入停止会中止当前试验。关闭程序时结束当前试验并关闭会话;异常退出留下的 `in_progress` 不代表已完成。目前不能恢复旧会话继续采集。 -保持倒计时只结束档位窗口,整段录像到完成或提前结束才停止。到时不等于稳定;`stability_verified=0`,正式分析仍须复核窗口。初始和最终零载当前使用相同保持时间,后续可根据恢复实测调整。 +倒计时只结束档位窗口,完成或提前结束才停录像。到时不等于稳定;`stability_verified=0`,仍须复核。初始和最终零载保持时间相同,后续按恢复实测调整。 ## 模拟页与操作表 -模拟页和标定页共用 UV 映射。在模拟页选点、调整列表顺序后点击“导出测点操作表…”,得到 UTF-8 CSV。操作员按表中 U/V 手动复现;当前不提供序列导入。三维位置被手工改动而与原像素不一致时拒绝导出,须重新在图上选点。 +模拟页和标定页共用 UV 映射。选点、排序后“导出测点操作表…”得到 UTF-8 CSV;操作员按 U/V 手动复现,不自动执行序列。三维位置与原像素不一致时拒绝导出,须重新选点。 | 操作表字段 | 含义 | |---|---| @@ -69,7 +69,7 @@ finger_F001_<时间>_<标识>/ ... ``` -10 个点、每点 3 次正常循环共 30 个 trial 目录;中止和重试各占一次,档位数不增加目录数。 +10 点各做 3 次共 30 个 trial;中止和重试各占一次,档位数不增加目录数。 | 文件 | 内容及写入时机 | |---|---| @@ -80,6 +80,7 @@ finger_F001_<时间>_<标识>/ | `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 | | `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 | | `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 | +| 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 | | `derived/review_<时间>_<标识>/baseline_.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable | | `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 | @@ -107,11 +108,21 @@ finger_F001_<时间>_<标识>/ 主时间是电脑取得该帧时绑定的单调时间,UTC 用于追溯;处理完成时间不能代替采集时间。FT/ECAT 后续接入同源接收时间,按时间关联,不按行号配对;低延迟不等于零延迟。旧视频的 `host_replay` 是本次解码时间,只能调试,不能恢复原实验与参考设备的物理同步。 -点击“查看标定数据…”选择会话根目录;在独立窗口切换测点,检查每档有效样本、均值、标准差和质量说明。初步拟合仅使用初始零载及加载分支的窗口均值,各窗口等权;卸载点保留用于对照,不混入加载拟合。缺样本、档位或信号变化时不生成系数。UV 图仅显示已测点,不外推整面;拟合 RMSE 不是独立验证误差。 +点击“查看标定数据…”选会话目录,进入记录总览;点击 UV 点或表格,联动法向响应和 trial 筛选,再“查看信号/复核窗口…”。 -采集页持续显示 FN/FX/FY 原始信号及最近 60 秒曲线;已有标定的轴另显示 N,未标定的轴不将 raw 转称为 N。无效帧在曲线中断开;停止或超过 1 秒没有新帧时明确提示。此处没有实时 FT 参考力,人工填写的参考力仍是独立声明。 +| 总览 | 规则 | +|---|---| +| 计划对照 | 导入模拟页 CSV;核对映射、原点、轴向、尺寸及三维位置,按 U/V 匹配;标记漏采和计划外点,同点重复序号合并 | +| 采集状态 | 未采、有未完成试验、待复核、已复核;完成次数及已确认窗口数分别列出,不把未完成 trial 用于拟合 | +| 法向响应 | 横轴实际参考 N,纵轴 FN_raw 均值;区分加载/卸载及候选/已确认,可筛选 trial;拟合仍基于本点全部合格加载窗口 | +| 灵敏度图 | 仅已确认拟合按 raw/N 着色,其余灰色;不插值,表中另列拟合范围及 trial 数 | +| 保存恢复 | “另存总览与复核”保存计划快照及窗口;载入 review.json 同时恢复总览,无 overview.json 时仅恢复复核 | -实时 UV 图有“真实背景”“位移箭头”“深度”三个独立开关;取消真实背景显示纯色画布,深度颜色可叠加在两种背景上。深度色阶按当前帧有效 UV 区域内的有限值最小/最大值自动调整,并显示范围;同一颜色在不同帧不代表相同数值,算法单位不代表毫米。缺失或过期字段不绘制,显示原因。显示切换不改变数据和录像,也不旋转 UV 坐标。 +拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。 + +采集页显示 FN/FX/FY raw 及最近 60 秒曲线;已有标定的轴另显示 N。无效帧断开,停止或超过 1 秒无新帧时提示。人工参考力不是实时 FT。 + +实时 UV 图有真实背景、位移箭头、深度三个开关;关闭背景显示纯色画布。深度按当前帧有效 UV 有限值范围自适应,显示范围;跨帧同色不代表同值,算法单位不是毫米。缺失或过期字段不绘制并提示。显示不改变录像及坐标。 复核时选中表格中的保持段,检查下方原始样本图;起点、终点以该段保持开始为 0 秒,只能在原保持段内调整。勾选“已检查此段稳定”,点击“应用窗口并重算”,最后“另存分析结果”。至少有两个不同载荷的已确认加载/零载窗口时,显示已确认窗口拟合;不足时明确显示候选拟合。人工确认只表示看过稳定性,不证明参考力准确或物理同步。 diff --git a/apps/autocal/src/orisys_autocal/record_overview.py b/apps/autocal/src/orisys_autocal/record_overview.py new file mode 100644 index 0000000..8b62862 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/record_overview.py @@ -0,0 +1,106 @@ +"""Coverage and planned-point comparison over the existing review contract.""" + +import csv +import hashlib +import json +import math +from pathlib import Path + +from .calibration_session import write_json +from .session_review import load_review, save_review + + +def plan_points(plan: dict, definition: dict) -> list[dict]: + points = {} + origin_x, origin_y = definition["origin_raster_xy_px"] + height, width = definition["raster_size_hw"] + orders = set() + for row in plan["rows"]: + if (row["mapping_sha256"] != definition["mapping_sha256"] + or row["u_positive"] != definition["u_positive"] + or row["v_positive"] != definition["v_positive"] + or (int(row["origin_raster_x_px"]), int(row["origin_raster_y_px"])) != (origin_x, origin_y) + or (int(row["raster_height_px"]), int(row["raster_width_px"])) != (height, width)): + raise ValueError("planned points use a different UV mapping, origin or axes") + u, v, order = int(row["u_px"]), int(row["v_px"]), int(row["order"]) + position = [float(row[f"{axis}_sensor_mm"]) for axis in "xyz"] + if (not 0 <= origin_x + u < width or not 0 <= origin_y - v < height + or not all(math.isfinite(x) for x in position) or order <= 0 or order in orders): + raise ValueError("invalid planned point or duplicate order") + orders.add(order) + point = points.setdefault((u, v), {"uv_xy_px": [u, v], "position_sensor_mm": position, "orders": []}) + if point["position_sensor_mm"] != position: + raise ValueError("one UV point has inconsistent planned 3D coordinates") + point["orders"].append(order) + if not points: + raise ValueError("the planned-point file contains no points") + return sorted(points.values(), key=lambda p: min(p["orders"])) + + +def read_plan(path: Path, definition: dict) -> dict: + with path.open(encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + plan = {"source": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "rows": rows} + plan_points(plan, definition) + return plan + + +def overview_rows(review: dict, plan: dict | None = None) -> list[dict]: + planned = plan_points(plan, review["uv_definition"]) if plan is not None else [] + planned_uv = {tuple(p["uv_xy_px"]) for p in planned} + actual = {tuple(site["uv_xy_px"]): site for site in review["sites"]} + rows = [] + for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]: + site = actual.get(tuple(point["uv_xy_px"])) + if site is not None and point.get("orders") and site.get("position_sensor_mm"): + if not all(math.isclose(a, b, rel_tol=0, abs_tol=1e-6) + for a, b in zip(point["position_sensor_mm"], site["position_sensor_mm"])): + raise ValueError("planned and recorded 3D coordinates do not match") + trials = [t for t in review["trials"] if site is not None and t.get("site_id") == site["site_id"]] + complete = [t for t in trials if t["status"] == "complete"] + windows = site["windows"] if site else [] + confirmed = [w for w in windows if w["stability_verified"] and w["status"] == "candidate"] + all_reviewed = bool(complete) and all( + sum(w["trial_id"] == t["trial_id"] for w in confirmed) + == len(t.get("plan", {}).get("steps", [w for w in windows if w["trial_id"] == t["trial_id"]])) + and any(w["trial_id"] == t["trial_id"] for w in confirmed) for t in complete) + state = ("uncollected" if not trials else "incomplete" if len(complete) != len(trials) + else "reviewed" if all_reviewed else "pending") + fit = (site["confirmed_fit"] or site["fit"]) if site else None + training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate" + and (not site["confirmed_fit"] or w["stability_verified"])] if site else [] + rows.append({ + "site_id": site["site_id"] if site else None, "uv_xy_px": point["uv_xy_px"], + "position_sensor_mm": site.get("position_sensor_mm") if site else point["position_sensor_mm"], + "orders": point.get("orders", []), "planned": bool(point.get("orders")), + "status": state, "trials": len(trials), "complete_trials": len(complete), + "confirmed_windows": len(confirmed), "windows": len(windows), + "confirmed_levels_n": sorted({w["reference_n"] for w in confirmed if w["branch"] in {"zero", "load"}}), + "fit": fit, "fit_basis": ("confirmed" if site["confirmed_fit"] else "candidate") if fit else "none", + "fit_trials": len({w["trial_id"] for w in training}) if fit else 0, + "fit_range_n": [min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)] if fit else None, + }) + return rows + + +def save_overview(review: dict, plan: dict | None) -> Path: + rows = overview_rows(review, plan) + path = save_review(review) + write_json(path.parent / "overview.json", { + "format_version": "autocal-overview-1.0", "session_id": review["session_id"], + "plan": plan, "sites": rows, + }) + return path + + +def load_overview(directory: Path, path: Path) -> tuple[dict, dict | None]: + review = load_review(directory, path) + overview_path = path.parent / "overview.json" + plan = None + if overview_path.is_file(): + saved = json.loads(overview_path.read_text(encoding="utf-8")) + if saved["format_version"] != "autocal-overview-1.0" or saved["session_id"] != review["session_id"]: + raise ValueError("overview identity mismatch") + plan = saved["plan"] + overview_rows(review, plan) + return review, plan diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py index 75f77bb..cedec4b 100644 --- a/apps/autocal/src/orisys_autocal/session_review.py +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -223,6 +223,7 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict: "source_directory": str(root), "session_status": manifest["status"], "status": "exploratory", "sites": sites, "issues": issues, "selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs, + "trials": manifest["trials"], "uv_definition": manifest["uv_definition"], } diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index 0e22a48..acdb6f1 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -351,7 +351,7 @@ class CalibrationPanel(QWidget): self._sync() def _review(self) -> None: - from .session_review_dialog import SessionReviewDialog + from .record_overview_dialog import RecordOverviewDialog session = self.trial.session directory = QFileDialog.getExistingDirectory( @@ -360,7 +360,7 @@ class CalibrationPanel(QWidget): ) if directory: try: - dialog = SessionReviewDialog(Path(directory), self) + dialog = RecordOverviewDialog(Path(directory), self) dialog.exec() except (OSError, ValueError, KeyError, TypeError) as exc: self.result_label.setText(t("calibration.error").format(detail=exc)) diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py new file mode 100644 index 0000000..df39989 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py @@ -0,0 +1,278 @@ +"""Recorded-site coverage and normal response, with optional operator CSV plan.""" + +from pathlib import Path + +from orisys_app_shared.i18n import t +from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import ( + QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, +) + +from ..record_overview import load_overview, overview_rows, read_plan, save_overview +from ..session_review import review_session +from .session_review_dialog import SessionReviewDialog + + +class RecordOverviewDialog(QDialog): + def __init__(self, directory: Path, parent=None): + super().__init__(parent) + self.directory = directory + self.review = review_session(directory) + self.plan = None + self.rows = [] + self.setWindowTitle(t("overview.title")) + self.resize(1240, 850) + layout = QVBoxLayout(self) + self.notice = QLabel(t("overview.notice"), self) + self.notice.setWordWrap(True) + layout.addWidget(self.notice) + toolbar = QHBoxLayout() + self.map_mode = QComboBox(self) + self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")]) + toolbar.addWidget(self.map_mode) + for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan), + ("load", self._load), ("save", self._save)): + button = QPushButton(t("overview." + name), self) + button.setObjectName("overview_" + name) + button.clicked.connect(slot) + toolbar.addWidget(button) + layout.addLayout(toolbar) + self.summary = QLabel(self) + self.summary.setWordWrap(True) + layout.addWidget(self.summary) + charts = QHBoxLayout() + self.uv_chart, self.response_chart = QChart(), QChart() + self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart) + for view in (self.uv_view, self.response_view): + view.setRenderHint(QPainter.RenderHint.Antialiasing) + charts.addWidget(self.uv_view, 1) + charts.addWidget(self.response_view, 2) + layout.addLayout(charts, 2) + self.table = QTableWidget(0, 11, self) + self.table.setObjectName("overviewSites") + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.table.setHorizontalHeaderLabels([t("overview.col_" + key) for key in ( + "order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.table, 1) + selection = QHBoxLayout() + self.trials = QComboBox(self) + self.trials.setObjectName("overviewTrial") + self.details_button = QPushButton(t("overview.details"), self) + selection.addWidget(QLabel(t("overview.trial_filter"), self)) + selection.addWidget(self.trials, 1) + selection.addWidget(self.details_button) + layout.addLayout(selection) + self.selected_summary = QLabel(self) + self.selected_summary.setWordWrap(True) + layout.addWidget(self.selected_summary) + self.message = QLabel(self) + self.message.setWordWrap(True) + layout.addWidget(self.message) + self.table.itemSelectionChanged.connect(self._select_site) + self.map_mode.currentIndexChanged.connect(self._show_uv) + self.trials.currentIndexChanged.connect(self._show_response) + self.details_button.clicked.connect(self._details) + self._refresh() + + def _refresh(self): + selected = self.table.currentRow() + self.rows = overview_rows(self.review, self.plan) + self.table.blockSignals(True) + self.table.setRowCount(len(self.rows)) + for index, row in enumerate(self.rows): + fit = row["fit"] + sensitivity = (f"{fit['sensitivity_raw_per_n']:.6g} · " + t("overview." + row["fit_basis"])) if fit else "—" + values = [", ".join(map(str, row["orders"])) or "—", row["site_id"] or "—", + ", ".join(map(str, row["uv_xy_px"])), + ", ".join(f"{x:.3f}" for x in row["position_sensor_mm"]) if row["position_sensor_mm"] else "—", + t("overview." + row["status"]), f"{row['complete_trials']}/{row['trials']}", + f"{row['confirmed_windows']}/{row['windows']}", + ", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity, + "–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])] + for column, value in enumerate(values): + self.table.setItem(index, column, QTableWidgetItem(value)) + self.table.blockSignals(False) + collected = sum(row["trials"] > 0 for row in self.rows) + self.summary.setText(t("overview.summary").format( + collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—", + missing=sum(r["status"] == "uncollected" for r in self.rows), + incomplete=sum(r["status"] == "incomplete" for r in self.rows), + pending=sum(r["status"] == "pending" for r in self.rows), + reviewed=sum(r["status"] == "reviewed" for r in self.rows), + extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—", + ) + ("\n" + t("overview.no_plan") if self.plan is None else "")) + self.message.setText(t("review.issues").format(count=len(self.review["issues"]))) + if self.rows: + self.table.selectRow(min(max(selected, 0), len(self.rows) - 1)) + self._select_site() + + def _select_site(self): + index = self.table.currentRow() + self.trials.blockSignals(True) + self.trials.clear() + self.trials.addItem(t("overview.all_trials"), None) + if index >= 0: + site_id = self.rows[index]["site_id"] + for trial in self.review["trials"]: + if site_id is not None and trial.get("site_id") == site_id: + self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"]) + self.trials.blockSignals(False) + self._show_uv() + self._show_response() + + @staticmethod + def _clear_chart(chart): + chart.removeAllSeries() + for axis in chart.axes(): + axis.hide() + chart.removeAxis(axis) + axis.deleteLater() + + @staticmethod + def _axes(chart): + points = [point for series in chart.series() for point in series.points()] + for horizontal in (True, False): + axis = QValueAxis() + values = [p.x() if horizontal else p.y() for p in points] + if values: + low, high = min(values), max(values) + margin = max((high - low) * .1, .1) + axis.setRange(low - margin, high + margin) + chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft) + for series in chart.series(): + series.attachAxis(axis) + + def _show_uv(self): + self._clear_chart(self.uv_chart) + sensitivity = self.map_mode.currentIndex() == 1 + values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]] + low, high = (min(values), max(values)) if values else (0, 0) + colors = {"uncollected": "#929aa7", "incomplete": "#ca4c46", "pending": "#d8911d", "reviewed": "#29835f"} + for index, row in enumerate(self.rows): + color = QColor(colors[row["status"]]) + if sensitivity: + color = QColor("#929aa7") + if row["fit_basis"] == "confirmed": + value = row["fit"]["sensitivity_raw_per_n"] + ratio = (value - low) / (high - low) if high > low else .5 + color = QColor.fromHsvF((1 - ratio) * .65, .8, .85) + series = QScatterSeries() + series.setColor(color) + series.setBorderColor(QColor("#151515") if index == self.table.currentRow() else color) + series.setMarkerSize(15 if index == self.table.currentRow() else 10) + series.append(*row["uv_xy_px"]) + series.clicked.connect(lambda _point, target=index: self.table.selectRow(target)) + self.uv_chart.addSeries(series) + self._axes(self.uv_chart) + definition = self.review["uv_definition"] + origin = definition.get("origin_raster_xy_px") + shape = definition.get("raster_size_hw") + for axis in self.uv_chart.axes(Qt.Orientation.Horizontal): + axis.setTitleText("U (px)") + if origin and shape: + axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5) + for axis in self.uv_chart.axes(Qt.Orientation.Vertical): + axis.setTitleText("V (px)") + if origin and shape: + axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5) + self.uv_chart.legend().hide() + self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high) + if sensitivity else t("overview.coverage_map")) + + def _show_response(self): + self._clear_chart(self.response_chart) + index = self.table.currentRow() + site_id = self.rows[index]["site_id"] if index >= 0 else None + site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None) + trial_id = self.trials.currentData() + windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else [] + self.details_button.setEnabled(site is not None and bool(windows)) + for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})): + for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")): + for confirmed in (True, False): + points = [w for w in windows if w["trial_id"] == trial and w["branch"] == branch + and w["status"] == "candidate" and w["stability_verified"] == confirmed] + if not points: + continue + series = QScatterSeries() + series.setName(trial + " · " + t("calibration." + branch) + " · " + t("overview.confirmed" if confirmed else "overview.candidate")) + series.setMarkerShape(QScatterSeries.MarkerShape.MarkerShapeCircle if confirmed else QScatterSeries.MarkerShape.MarkerShapeRectangle) + series.setColor(QColor(color).lighter(100 + 20 * (trial_index % 4))) + series.setMarkerSize(10 if confirmed else 7) + for window in points: + series.append(window["reference_n"], window["raw_mean"]) + self.response_chart.addSeries(series) + fit = (site["confirmed_fit"] or site["fit"]) if site else None + if fit and windows: + training = [w for w in site["windows"] if w["branch"] in {"zero", "load"} + and w["status"] == "candidate" and (not site["confirmed_fit"] or w["stability_verified"])] + line = QLineSeries() + line.setName(t("overview.site_fit")) + for load in (min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)): + line.append(load, (load - fit["offset_n"]) / fit["gain_n_per_raw"]) + self.response_chart.addSeries(line) + self._axes(self.response_chart) + for axis in self.response_chart.axes(Qt.Orientation.Horizontal): + axis.setTitleText(t("review.force_axis")) + for axis in self.response_chart.axes(Qt.Orientation.Vertical): + axis.setTitleText(t("review.raw_axis")) + self.response_chart.setTitle(t("overview.response_title")) + self.response_chart.legend().setAlignment(Qt.AlignmentFlag.AlignRight) + self.selected_summary.setText(t("overview.selected_fit").format( + sensitivity=f"{fit['sensitivity_raw_per_n']:.6g}" if fit else "—", + basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—", + ) if windows else t("overview.no_windows")) + + def _import_plan(self): + path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)") + if path: + try: + plan = read_plan(Path(path), self.review["uv_definition"]) + overview_rows(self.review, plan) + self.plan = plan + self._refresh() + except (OSError, ValueError, KeyError, TypeError) as exc: + self.message.setText(t("calibration.error").format(detail=exc)) + + def _clear_plan(self): + self.plan = None + self._refresh() + + def _details(self): + row = self.rows[self.table.currentRow()] + index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"]) + dialog = SessionReviewDialog(self.directory, self, review=self.review) + dialog.sites.setCurrentIndex(index) + trial = self.trials.currentData() + if trial: + windows = dialog.review["sites"][index]["windows"] + match = next((i for i, w in enumerate(windows) if w["trial_id"] == trial), None) + if match is not None: + dialog.table.selectRow(match) + dialog.exec() + self.review = dialog.review + self._refresh() + + def _save(self): + try: + path = save_overview(self.review, self.plan) + self.message.setText(t("review.saved").format(path=path)) + except (OSError, ValueError, KeyError, TypeError) as exc: + self.message.setText(t("calibration.error").format(detail=exc)) + + def _load(self): + path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)") + if path: + try: + review, plan = load_overview(self.directory, Path(path)) + self.review, self.plan = review, plan + self._refresh() + except (OSError, ValueError, KeyError, TypeError) as exc: + self.message.setText(t("calibration.error").format(detail=exc)) diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py index a93d262..44692a0 100644 --- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py @@ -19,11 +19,11 @@ from ..session_review import review_session, save_review, load_review class SessionReviewDialog(QDialog): """Open persisted data independently of the acquisition page's state.""" - def __init__(self, directory: Path, parent=None) -> None: + def __init__(self, directory: Path, parent=None, *, review: dict | None = None) -> None: super().__init__(parent) self.setWindowTitle(t("review.title")) self.resize(1080, 760) - self.review = review_session(directory) + self.review = review if review is not None else review_session(directory) self.directory = directory layout = QVBoxLayout(self) self.notice = QLabel(t("review.notice"), self) diff --git a/apps/autocal/tests/test_manual_trial.py b/apps/autocal/tests/test_manual_trial.py index d115251..f1a75ed 100644 --- a/apps/autocal/tests/test_manual_trial.py +++ b/apps/autocal/tests/test_manual_trial.py @@ -238,6 +238,13 @@ def test_operator_export_reproduces_uv_sites_and_rejects_edited_xyz(qt_app, flat panel.table.setCurrentCell(1, 1) panel.move_selected(-1) destination = panel.export_sites(tmp_path / "sites.csv") + from orisys_autocal.record_overview import read_plan, plan_points + from orisys_autocal.sensor.uv_flat import mapping_sha256 + + definition = {"origin_raster_xy_px": uv_origin(flat), "raster_size_hw": [flat.height, flat.width], + "mapping_sha256": mapping_sha256(flat), "u_positive": "right", "v_positive": "up"} + imported = plan_points(read_plan(destination, definition), definition) + assert [point["uv_xy_px"] for point in imported] == [[1, 2], [-1, -1]] with destination.open(encoding="utf-8-sig", newline="") as handle: rows = list(csv.DictReader(handle)) assert [(int(r["u_px"]), int(r["v_px"])) for r in rows] == [(1, 2), (-1, -1)] diff --git a/apps/autocal/tests/test_record_overview.py b/apps/autocal/tests/test_record_overview.py new file mode 100644 index 0000000..dd4458d --- /dev/null +++ b/apps/autocal/tests/test_record_overview.py @@ -0,0 +1,173 @@ +"""Recorded coverage, plan identity and the normal-response operator journey.""" + +import csv +import json +from copy import deepcopy + +import pytest +from PySide6.QtWidgets import QPushButton + +from test_session_review import repeated_session as repeated_session, session as session +from orisys_autocal.calibration_session import write_json +from orisys_autocal.record_overview import load_overview, overview_rows, read_plan, save_overview +from orisys_autocal.session_review import review_session + + +@pytest.fixture +def overview_session(repeated_session): + root, selections = repeated_session + manifest = json.loads((root / "session.json").read_text(encoding="utf-8")) + definition = {"mapping_sha256": "test-map", "origin_raster_xy_px": [5, 8], + "raster_size_hw": [12, 12], "u_positive": "right", "v_positive": "up", "unit": "px"} + manifest["uv_definition"] = definition + for index, site in enumerate(manifest["sites"]): + site["position_sensor_mm"] = [index + 1., 2., 3.] + for trial in manifest["trials"]: + if trial["status"] == "complete": + path = root / trial["directory"] / "session.json" + metadata = json.loads(path.read_text(encoding="utf-8")) + metadata["uv_definition"] = definition + write_json(path, metadata) + trial["plan"] = {"steps": list(range(5))} + manifest["sites"].append({"site_id": "site-003", "uv_xy_px": [3, 2], "position_sensor_mm": [3., 2., 3.]}) + manifest["trials"].append({"trial_id": "trial-005", "site_id": "site-003", "status": "incomplete"}) + write_json(root / "session.json", manifest) + rows = [] + for order, (u, v), position in ((1, (-3, 4), [2., 2., 3.]), + (2, (1, -2), [1., 2., 3.]), + (3, (-1, 1), [4., 2., 3.])): + rows.append({"order": order, "site_id": f"site-{order:03d}", "u_px": u, "v_px": v, + "x_sensor_mm": position[0], "y_sensor_mm": position[1], "z_sensor_mm": position[2], + "origin_raster_x_px": 5, "origin_raster_y_px": 8, + "raster_width_px": 12, "raster_height_px": 12, "mapping_sha256": "test-map", + "u_positive": "right", "v_positive": "up"}) + plan_path = root / "operator-plan.csv" + with plan_path.open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + return root, selections, plan_path + + +def test_overview_distinguishes_coverage_without_matching_plan_ids(overview_session): + root, selections, plan_path = overview_session + review = review_session(root, selections=selections) + plan = read_plan(plan_path, review["uv_definition"]) + rows = overview_rows(review, plan) + assert [r["site_id"] for r in rows] == ["site-002", "site-001", None, "site-003"] + assert [r["status"] for r in rows] == ["reviewed", "reviewed", "uncollected", "incomplete"] + assert rows[1]["complete_trials"] == 2 and rows[1]["fit_trials"] == 2 + assert rows[1]["confirmed_windows"] == 10 and rows[1]["fit_range_n"] == [0, 10] + assert rows[2]["fit"] is None and rows[3]["windows"] == 0 + assert overview_rows(review_session(root), plan)[0]["status"] == "pending" + saved = save_overview(review, plan) + restored, stored_plan = load_overview(root, saved) + assert overview_rows(restored, stored_plan) == rows + assert stored_plan["sha256"] == plan["sha256"] + + +@pytest.mark.parametrize("key,value", [("mapping_sha256", "other"), ("origin_raster_x_px", 6), + ("v_positive", "down"), ("u_px", 999), ("x_sensor_mm", "nan")]) +def test_plan_refuses_mapping_or_coordinate_mismatch(overview_session, key, value): + root, selections, plan_path = overview_session + review = review_session(root, selections=selections) + plan = read_plan(plan_path, review["uv_definition"]) + plan["rows"][0][key] = value + with pytest.raises(ValueError): + overview_rows(review, plan) + + +def test_plan_merges_repeated_uv_points_and_refuses_conflicting_positions(overview_session): + root, selections, plan_path = overview_session + review = review_session(root, selections=selections) + plan = read_plan(plan_path, review["uv_definition"]) + plan["rows"].append({**plan["rows"][0], "order": "4"}) + assert overview_rows(review, plan)[0]["orders"] == [1, 4] + plan["rows"][0]["x_sensor_mm"] = "9" + with pytest.raises(ValueError): + overview_rows(review, plan) + plan["rows"].pop() + with pytest.raises(ValueError, match="3D"): + overview_rows(review, plan) + + +def test_overview_requires_all_planned_hold_windows_for_reviewed_status(overview_session): + root, selections, _ = overview_session + review = review_session(root, selections=selections) + altered = deepcopy(review) + altered["trials"][0]["plan"]["steps"].append(5) + assert overview_rows(altered)[0]["status"] == "pending" + assert overview_rows(review)[0]["status"] == "reviewed" + + +def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overview_session, monkeypatch, tmp_path): + from orisys_autocal.ui import record_overview_dialog + from orisys_autocal.ui.session_review_dialog import SessionReviewDialog + + root, selections, plan_path = overview_session + dialog = record_overview_dialog.RecordOverviewDialog(root) + dialog.review = review_session(root, selections=selections) + dialog._refresh() + monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(plan_path), "")) + dialog.findChild(QPushButton, "overview_import_plan").click() + assert dialog.table.rowCount() == 4 + assert dialog.table.item(0, 1).text() == "site-002" + dialog.uv_chart.series()[1].clicked.emit(dialog.uv_chart.series()[1].at(0)) + assert dialog.table.currentRow() == 1 + assert len(dialog.uv_chart.axes()) == 2 + assert all(len(s.attachedAxes()) == 2 for s in dialog.uv_chart.series()) + assert dialog.trials.count() == 3 + dialog.trials.setCurrentIndex(1) + names = [s.name() for s in dialog.response_chart.series()] + assert any("trial-001" in name for name in names) + assert not any("trial-004" in name for name in names) + assert len(dialog.response_chart.axes()) == 2 + assert all(len(s.attachedAxes()) == 2 for s in dialog.response_chart.series()) + load_series = next(s for s in dialog.response_chart.series() if s.count() == 2 and "trial-001" in s.name()) + assert [(load_series.at(i).x(), load_series.at(i).y()) for i in range(2)] == [(5, 11), (10, 21)] + opened = [] + monkeypatch.setattr(SessionReviewDialog, "exec", lambda child: opened.append(child)) + dialog.details_button.click() + assert opened[0].sites.currentIndex() == 0 + assert opened[0].review["selections"] == selections + dialog.table.selectRow(2) + assert not dialog.details_button.isEnabled() + dialog.table.selectRow(3) + dialog.trials.setCurrentIndex(1) + assert not dialog.response_chart.series() + assert not dialog.details_button.isEnabled() + dialog.table.selectRow(2) + dialog.map_mode.setCurrentIndex(1) + assert len(dialog.uv_chart.series()) == 4 + assert dialog.uv_chart.series()[0].color().name() != "#929aa7" + assert dialog.uv_chart.series()[2].color().name() == "#929aa7" + assert dialog.uv_chart.series()[3].color().name() == "#929aa7" + dialog.show() + qt_app.processEvents() + assert dialog.grab().save(str(tmp_path / "overview-sensitivity.png")) + dialog.table.selectRow(1) + dialog.map_mode.setCurrentIndex(0) + qt_app.processEvents() + assert dialog.grab().save(str(tmp_path / "overview-response.png")) + dialog.findChild(QPushButton, "overview_save").click() + saved = next(root.glob("derived/*/review.json")) + dialog.findChild(QPushButton, "overview_clear_plan").click() + assert dialog.table.rowCount() == 3 + monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), "")) + dialog.findChild(QPushButton, "overview_load").click() + assert dialog.table.rowCount() == 4 + dialog.close() + + +def test_calibration_review_action_opens_record_overview(make_window, overview_session, monkeypatch): + from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog + from orisys_autocal.ui.calibration_panel import QFileDialog + + root, _, _ = overview_session + opened = [] + monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root)) + monkeypatch.setattr(RecordOverviewDialog, "exec", lambda dialog: opened.append(dialog)) + window = make_window() + window.calibration_panel.review_button.click() + assert len(opened) == 1 and len(opened[0].rows) == 3 + window.close()