完善 O30 标定时钟同步与准备契约校验
This commit is contained in:
@@ -861,8 +861,9 @@ DIP 只完成模拟后续访问的正式采集/父参考回读测试,使用真
|
||||
观测后,两关节各得到 248 帧。离线反事实回放还使用新 PIP 模型重新投影原 DIP 图像,
|
||||
验证整条依赖链;不修改原零位或历史日志,不把旧断点直接重新标记为通过。
|
||||
|
||||
这项优化需要新采集的上游模型。验证整场稳定性使用 `--no-resume`;继续旧断点仍保留
|
||||
原冻结模型及不确定度,不能承诺仅重试 DIP 就会通过。正式运行命令:
|
||||
这项优化需要新采集的上游模型。新版会自动排除缺少当前准备契约的旧断点,重新采集
|
||||
完整准备链;新版本同契约断点仍支持恢复。验证独立整场运行时使用 `--no-resume`。
|
||||
正式运行命令:
|
||||
|
||||
```bash
|
||||
ros2 run linkerhand_calibration calibrate_hand \
|
||||
@@ -883,3 +884,27 @@ ros2 run linkerhand_calibration calibrate_hand \
|
||||
报告和按角色哈希绑定的证据。辅助运动、独立准备、断点与最终验收的重新拟合均读取
|
||||
这项策略;无字段的旧日志继续使用原预算。删除策略、改写策略或使训练/留出帧数超过
|
||||
对应预算会拒绝回读,不能让在线与离线分别选择不同的子集。
|
||||
|
||||
## 准备契约与时钟刷新修复(2026-09-20)
|
||||
|
||||
`session_start.preparation_contract` 记录实际准备图:单/多关节采样预算、求解参数、
|
||||
父子来源、零位路径和辅助动作。自动发现断点先比较这份契约,缺失或不同则排除该断点,
|
||||
不读入大日志后再进入原停点。节点的显式恢复入口同样校验;产物回读还检查声明的新
|
||||
契约下不存在使用旧采样预算的已接受模型。旧原始记录保持可读、不会被重写。
|
||||
`resume_selection.json` 保存候选排除原因;显式重新开始的会话阻止回溯更早的旧谱系。
|
||||
|
||||
这里采用整场重新准备,避免对冻结训练、共享 Tag 交接和派生角度进行局部替换。
|
||||
后续同契约恢复仍需基准及逐关节复核。此变更修复新旧模型混用,不代表已实现整手
|
||||
准备统一联合求解,也不保证所有现场视角都具备可观测性。
|
||||
|
||||
相机在通过原连续性检查后,根据实测相位漂移缩短下一次校时的间隔,并扣除当前锚点
|
||||
已经经历的时间。刷新目标使用原 1 ms 漂移预算的四分之一;周期限制在 20–500 ms。
|
||||
真正的时钟跳变、计数器复位、过期映射及未来/乱序图像仍按原规则拒绝。
|
||||
这解决了固定 500 ms 刷新无法适应平滑漂移的机制;三台实机的具体异常仍需新日志验收。
|
||||
|
||||
辅助旁观姿态拟合在计算前检查继承误差是否已耗尽原 1°/1 mm 预算。不可行来源记录为
|
||||
`observer_parent_precision_budget_exhausted`,不会通过重复拟合或降低门限试图挽救。
|
||||
|
||||
针对回归、真实像素回放、合成完整 JSON→URDF 链路与只读配置检查均已通过,详见
|
||||
`software_review_stability_repair/software_validation.json`。本次设备被独立的
|
||||
`linkerhand_range_calibration` 占用,未中断它,也未执行新的实机标定或发布标定产物。
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# O30 标定稳定性评估(2026-09-20)
|
||||
|
||||
当前结论:已有精度拒绝和产物发布边界应保留,但现有逐关节准备、立即冻结、
|
||||
再向下游传递的观测组织方式,尚不足以支撑稳定的一次整手标定。
|
||||
不能将局部软件回放通过或某个候选精度达标等同于整手稳定。
|
||||
|
||||
后续修复进展:已实现准备契约隔离、发布入口预算核验、按漂移调整相机刷新间隔、
|
||||
启动锚点年龄扣除及辅助观测继承预算前置检查。详见 `O30_RIGHT_CALIBRATION.md`
|
||||
末节。下文保留评估时的事实和整体目标;不能将这些局部结构修复视为所有重构或
|
||||
整手实机稳定验收已完成。当前独立范围标定占用设备,尚无新版实机验收结果。
|
||||
|
||||
## 最新实机证据
|
||||
|
||||
会话 `calibration_output/O30_RIGHT_001/20260920_175156`,复用 12 个扫描单元后,
|
||||
在小指 DIP 准备阶段以 `OBS-POSE-AMBIGUOUS-117` 暂停。
|
||||
完整证据为该目录的 `assistant_resume_review.json`、`node_status.json`、
|
||||
`raw_samples.jsonl` 第 117841 行及三个 `camera_timing_*.jsonl`。
|
||||
|
||||
| 项目 | 实测结果 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| DIP 准备图像 | 228 帧,114 训练 / 114 留出 | 增加采样已实际生效 |
|
||||
| 候选 0 轴向不确定度界 | 2.915°,门限 3° | 合格但裕量很小 |
|
||||
| 候选 1 轴向不确定度界 | 3.035° | 不能发布,但仍能解释图像 |
|
||||
| 两候选留出 RMS | 0.350 / 0.395 px | 像素拟合接近,不足以直接选三维解 |
|
||||
| 校正后的比较 p 值 | 0.09375,要求 ≤ 0.01 | 歧义拒绝符合现有契约 |
|
||||
| 继承 PIP 轴向不确定度界 | 2.274° | 占用下游大部分精度预算 |
|
||||
| 旁观辅助观测 | 两次 `observer_image_validation_failed` | 本次没有提供可用的额外约束 |
|
||||
| SDK | 约 29.8 Hz,无报告故障 | 没有证据把直接停因归为 SDK 故障 |
|
||||
|
||||
`axis_uncertainty_95_rad` 是现有字段名;实现使用局部线性化协方差的三倍标准差界,
|
||||
并保守加入继承项。它不是包含内参、标签系统偏差在内的实测绝对精度保证。
|
||||
|
||||
## 为什么此前修复后仍会停
|
||||
|
||||
1. **改动没有更新整条依赖链。** 密集采样用于新的 DIP 准备;恢复则保留已冻结的
|
||||
旧 PIP 模型。参考复核仅确认当前安装/姿态可以复用,不会把旧模型重新训练成新模型。
|
||||
因而恢复成功和下游可观测并不是同一个条件。
|
||||
2. **单视角平面标签仍有不同三维解释。** 增加同一运动弧上的帧数可以改善随机误差,
|
||||
不保证增加区分两个候选所需的信息。本次两候选都收敛,不能靠增加求解次数解决。
|
||||
也不能删掉略超精度门限、但仍可解释图像的备选候选。
|
||||
3. **辅助观测路径没有预先核算可行性。** `fit_observer_pose` 将父模型不确定度加入
|
||||
辅助相对姿态的界,再要求旋转不超过 1°。旧 PIP 已有约 2.27°,沿用这个来源时,
|
||||
即使像素验收过关也无法满足该界。本次实际首先失败于像素验收,不能把两者混为一项。
|
||||
4. **准备策略的组合复杂度超过已完成的实机覆盖。** 当前存在共享 Tag 交接、父几何
|
||||
传递、旁观辅助、侧摆辅助及进入姿态联合准备等路径,部分组合显式不支持。
|
||||
各路径局部回放通过,不构成它们在整手遮挡、回访和断点恢复中的组合保证。
|
||||
|
||||
现有基准锁定、证据哈希、训练/留出隔离、禁止失败发布和有界恢复是合理的。
|
||||
需要调整的是观测规划、冻结范围、恢复依赖和验收顺序,而不是放宽拒绝条件。
|
||||
|
||||
### 最新像素的父模型敏感性实验
|
||||
|
||||
重新估计历史 PIP 准备模型,并仅在隔离的深拷贝中重算最新一轮 228 帧 DIP 的父姿态,
|
||||
按相同门限求解:PIP 继承轴向界由约 2.274° 降为 1.574°,DIP 候选 0 的轴向界
|
||||
为 2.213°,校正 p 值为 0.002227,离线解析成功。相较原实机的 2.915° / 0.09375,
|
||||
这支持“旧父模型是本次主要限制,必须连同依赖链更新”的判断。
|
||||
|
||||
该实验使用历史 PIP 图像和最新 DIP 图像,没有实机重采,也没有重新完成当前父姿态
|
||||
准入。脚本显式使用离线准入标记,未改变原始记录,不允许发布。
|
||||
不能据此声称实机下次必过,更不能把重新计算的父模型注入旧正式产物。
|
||||
结果和复现脚本存于 `software_review_stability_architecture/latest_parent_counterfactual.json`
|
||||
及同目录 `parent_counterfactual.py`。脚本建立来源注册表时曾两次缺少依赖而被正式入口
|
||||
拒绝,补齐历史 MCP/PIP 来源后才完成实验;拒绝记录保存在该目录的验证摘要中。
|
||||
|
||||
## 独立存在的时钟问题
|
||||
|
||||
约 374 秒内,front / side / top 分别出现 33 / 31 / 27 次时钟失效并重同步;
|
||||
曝光时间序列的最大图像间隔分别约 372 / 338 / 371 ms。
|
||||
这不是此次歧义判定的直接错误码,但会削弱采集连续性,必须单独消除。
|
||||
|
||||
三相机的主机时间与设备计时差在该轮累计同向变化约 201–203 ms,提示应优先检查
|
||||
共用的主机时间基础。另做 30 秒只读探测,墙钟相对 `CLOCK_MONOTONIC_RAW` 变化
|
||||
约 -13.9 ms;主机启用了时间同步。这些现象不足以证明历史失效就是 NTP 导致,
|
||||
也不能据此直接关时间同步或扩大同步容差。
|
||||
|
||||
旧日志仅保存成功的锁存和失效原因,没有保存触发拒绝的锁存事务,缺少定位证据。
|
||||
本次补充了两个独立时钟的事务边界、被拒绝的原始锁存和旧锚点,拒绝时立即落盘。
|
||||
曝光时间映射、2 ms 锁存界、时钟连续性界和图像新鲜度界均未修改。
|
||||
对应相机时序测试 12 项通过,耗时 0.33 秒。这是诊断能力修复,不是时钟故障已解决。
|
||||
|
||||
## 应采用的流程和实施顺序
|
||||
|
||||
1. **先验收基础设施。** 固定本次控制进程的 ROS 发现范围;确保同一设备只有一个
|
||||
控制者。三相机进行覆盖预期标定时长的连续采集检查,以原始双时钟证据定位并修复
|
||||
同步异常。ROS 图隔离不能替代物理设备独占检查。
|
||||
2. **正式训练前完成整条手指的观测准备。** 每条依赖链先检查可见性、运动覆盖、
|
||||
候选可区分性和完整精度预算,再整体接受准备模型。父模型应有经过数据论证的
|
||||
下游预算,不能只按自身 3° 门限提前冻结。
|
||||
3. **独立信息不足时改变预先声明的观测设计。** 优先评估保持其他关节约束的不同
|
||||
上游姿态,必要时采用经过验证的跨视角共同观测。额外动作需纳入运动计划、
|
||||
原始证据和独立留出;不得观察留出结果后反复挑方案直到通过。
|
||||
三台相机存在并不代表当前求解已使用多视角。本轮预检中光学内参和相对外参的
|
||||
实测状态均为 `unverified`,跨视角方案必须先补足对应验证。
|
||||
4. **按依赖链管理准备版本和断点。** 准备策略升级或父模型需要更新时,在新会话中
|
||||
明确重建受影响的父子准备模型及其派生数据;保留无依赖的数据和历史原件。
|
||||
不得将新父模型与旧角度、旧零位或旧发布证据直接拼接。完全冻结后的训练版本
|
||||
不能在正式留出失败后局部重训并继续沿用同一留出验收。
|
||||
5. **统一有限恢复。** 对短暂缺帧执行有界、可回放的局部恢复;基准变化、设备故障、
|
||||
真正不可观测或证据损坏仍应终止/暂停。恢复必须由具体证据触发,同一信息不足的
|
||||
运动不能无限重走。启动索引和重求解保持在实时控制回调之外。
|
||||
6. **最后验证完整交付。** 连续完成整手训练与独立验收,验证 JSON 的证据、数值、
|
||||
单位和关节覆盖,从该 JSON 重建修正 URDF 并回读核验,最后才更新发布指针。
|
||||
末节 CAD 零位保留假设必须继续在产物中明确,不能称所有零位均已独立实测。
|
||||
|
||||
以上是结构调整的实施要求,并非这些重构已经完成。禁止用新增重试、降低门限或
|
||||
跳过候选比较来替代观测设计。
|
||||
|
||||
## 稳定性的验收定义
|
||||
|
||||
- 故障回放覆盖断帧、时钟跳变、SDK 失联、参考移动、候选歧义和恢复版本不兼容;
|
||||
暂态在规定预算内恢复,其余明确拒绝,均无错误发布。
|
||||
- 固定代码、配置和现场布置后,至少连续三次完整实机运行无需人工介入,均完成独立
|
||||
验收并生成可回读的 JSON/URDF,同时报告关节结果重复性。三次是工程验收建议,
|
||||
不是数学上的成功率证明,也未改写当前产品 `required_independent_passes: 1`。
|
||||
- 单独验证正常中断后的断点恢复与一次完整运行结果一致;不能仅验证复用单元数量。
|
||||
- 保留每次失败和软件版本;不能只汇总成功运行或把反事实重放计为实机通过。
|
||||
|
||||
当前尚未达到上述验收。本轮没有发布标定 JSON 或修正 URDF,不能宣称流程稳定或
|
||||
保证下一次一次通过。
|
||||
@@ -1,5 +1,23 @@
|
||||
# 标定测试的执行范围
|
||||
|
||||
准备契约升级用 `test_preparation_contract.py`,覆盖新旧断点分离、父子/预算/动作
|
||||
变化、历史记录保留、原会话边界、节点写入和发布入口禁止旧模型冒充新预算。
|
||||
时钟自适应刷新用 `test_camera_timing.py`,正负平滑漂移在模拟 30 Hz 的 60 秒内
|
||||
曝光时间误差保持小于原 1 ms 连续性预算,同时验证真跳变仍拒绝及启动锚点年龄。
|
||||
辅助精度预算前置用 `test_observer_pose.py` 和 `test_observer_reference.py`。
|
||||
|
||||
此次增量验证分组记录在 `software_review_stability_repair`:初始针对组 79 项、8.84 秒;
|
||||
证据/真实 PIP-DIP 回放/runner/合成完整产物组 91 项、58.99 秒;修正启动锚点刷新
|
||||
时机后的相机组 15 项、0.47 秒。组间有重复用例,不将累计次数称为一次全量回归。
|
||||
实际目录的断点选择只读验证约 17 ms,排除了旧准备契约;配置 validate-only 通过。
|
||||
这些结果不替代新版本的连续实机验收。
|
||||
|
||||
相机时钟诊断变更使用 `test_camera_timing.py`:同时覆盖原始计时单位、跳变、陈旧帧、
|
||||
重同步顺序及拒绝事务可重放。新增 `CLOCK_MONOTONIC_RAW` 仅记录诊断边界,不能
|
||||
替换 ROS 时间或放宽同步门限。2026-09-20 针对组 12 项通过,0.33 秒,记录于
|
||||
`calibration_output/O30_RIGHT_001/software_review_stability_architecture/camera_timing_tests.json`。
|
||||
这不表示实机重复失同步已经修复;整体评估见 `O30_STABILITY_ASSESSMENT_20260920.md`。
|
||||
|
||||
日常修改运行受影响用例和必要契约用例,目标为 60 秒内完成。不要因为修改了一行代码就运行
|
||||
整手拟合或全量 `colcon test`。公共产物契约变更需要覆盖相关型号,发布前再执行全量回归。
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import time
|
||||
|
||||
|
||||
CAMERA_TIMING_POLICY = "mvs_latched_exposure_time_v1"
|
||||
CLOCK_DRIFT_TOLERANCE_NS = 1_000_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -20,6 +21,8 @@ class ClockLatch:
|
||||
device_tick: int
|
||||
before_ns: int
|
||||
after_ns: int
|
||||
raw_before_ns: int | None = None
|
||||
raw_after_ns: int | None = None
|
||||
|
||||
@property
|
||||
def midpoint_ns(self):
|
||||
@@ -45,6 +48,7 @@ class DeviceFrameClock:
|
||||
self.anchor = None
|
||||
self.last_frame_tick = None
|
||||
self.last_stamp_ns = None
|
||||
self.refresh_interval_seconds = .5
|
||||
|
||||
def elapsed_ns(self, ticks):
|
||||
return round(ticks*1_000_000_000/self.ticks_per_second)
|
||||
@@ -59,9 +63,19 @@ class DeviceFrameClock:
|
||||
for current in latches:
|
||||
elapsed = self.elapsed_ns(current.device_tick-previous.device_tick)
|
||||
host_elapsed = current.midpoint_ns-previous.midpoint_ns
|
||||
tolerance = current.half_width_ns+previous.half_width_ns+1_000_000
|
||||
tolerance = current.half_width_ns+previous.half_width_ns+CLOCK_DRIFT_TOLERANCE_NS
|
||||
if elapsed < 0 or abs(elapsed-host_elapsed) > tolerance:
|
||||
raise ValueError("camera/host clock discontinuity or counter unit mismatch")
|
||||
# Refresh before measured phase drift spends the existing 1 ms
|
||||
# continuity allowance. A fixed 0.5 s period repeatedly invalidates a
|
||||
# smooth slewing host clock. Scheduling changes; admission never does.
|
||||
current = max(latches, key=lambda x: x.midpoint_ns)
|
||||
host_elapsed = current.midpoint_ns-previous.midpoint_ns
|
||||
if host_elapsed >= 50_000_000:
|
||||
drift = abs(host_elapsed-self.elapsed_ns(current.device_tick-previous.device_tick))
|
||||
rate = drift/host_elapsed
|
||||
self.refresh_interval_seconds = max(.02, min(.5,
|
||||
.25*CLOCK_DRIFT_TOLERANCE_NS/1e9/max(rate, 1e-12)))
|
||||
self.anchor = min(latches, key=lambda x: x.half_width_ns)
|
||||
|
||||
def timestamp(self, tick, *, received_ns, exposure_us):
|
||||
@@ -80,8 +94,11 @@ class DeviceFrameClock:
|
||||
class MvsCameraTiming:
|
||||
"""The camera-specific clock latch and its optional append-only journal."""
|
||||
|
||||
def __init__(self, camera, mvs, clock_ns, *, exposure_us, journal_path=""):
|
||||
def __init__(self, camera, mvs, clock_ns, *, exposure_us, journal_path="", raw_clock_ns=None):
|
||||
self.camera, self.mvs, self.clock_ns = camera, mvs, clock_ns
|
||||
# CLOCK_MONOTONIC_RAW is independent of wall-clock synchronization.
|
||||
# It is diagnostic evidence only, never a replacement ROS timestamp.
|
||||
self.raw_clock_ns = raw_clock_ns or (lambda: time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW))
|
||||
self.exposure_us = exposure_us
|
||||
self.frame_clock = None
|
||||
self.last_stamp_ns = None
|
||||
@@ -100,12 +117,14 @@ class MvsCameraTiming:
|
||||
return int(value.nCurValue)
|
||||
|
||||
def _latch(self):
|
||||
raw_before = self.raw_clock_ns()
|
||||
before = self.clock_ns()
|
||||
status = self.camera.MV_CC_SetCommandValue("DeviceTimestampLatch")
|
||||
if status != 0:
|
||||
raise ValueError(f"camera clock latch failed:0x{status:08x}")
|
||||
tick = self._integer("DeviceTimestamp")
|
||||
return ClockLatch(tick, before, self.clock_ns())
|
||||
after = self.clock_ns()
|
||||
return ClockLatch(tick, before, after, raw_before, self.raw_clock_ns())
|
||||
|
||||
def _write(self, row):
|
||||
if self.journal is not None:
|
||||
@@ -135,13 +154,29 @@ class MvsCameraTiming:
|
||||
self._write(dict(kind="camera_clock_latch_rejected", latches=[asdict(x) for x in latches]))
|
||||
self.next_refresh = time.monotonic()+.02
|
||||
return
|
||||
self.frame_clock.update(valid)
|
||||
try:
|
||||
self.frame_clock.update(valid)
|
||||
except ValueError as error:
|
||||
# Invalidation alone loses the transaction that failed. Preserve
|
||||
# both clocks and the old anchor so drift, jumps and bad device
|
||||
# counters can be distinguished without weakening the check.
|
||||
self._write(dict(kind="camera_clock_sync_rejected", policy=CAMERA_TIMING_POLICY,
|
||||
reason=str(error), ticks_per_second=self.frame_clock.ticks_per_second,
|
||||
previous_anchor=asdict(self.frame_clock.anchor) if self.frame_clock.anchor else None,
|
||||
latches=[asdict(x) for x in latches]))
|
||||
if self.journal is not None:
|
||||
self.journal.flush()
|
||||
raise
|
||||
self._write(dict(kind="camera_clock_sync", policy=CAMERA_TIMING_POLICY,
|
||||
ticks_per_second=self.frame_clock.ticks_per_second,
|
||||
refresh_interval_seconds=self.frame_clock.refresh_interval_seconds,
|
||||
latches=[asdict(x) for x in latches], anchor=asdict(self.frame_clock.anchor)))
|
||||
if self.journal is not None:
|
||||
self.journal.flush()
|
||||
self.next_refresh = time.monotonic()+.5
|
||||
# Startup may select a precise latch near the beginning of its window.
|
||||
# That anchor has already spent part of the refresh budget.
|
||||
anchor_age = (max(x.after_ns for x in latches)-self.frame_clock.anchor.midpoint_ns)/1e9
|
||||
self.next_refresh = time.monotonic()+max(0., self.frame_clock.refresh_interval_seconds-anchor_age)
|
||||
|
||||
def refresh_if_due(self):
|
||||
if self.frame_clock is None:
|
||||
|
||||
+13
-1
@@ -16,6 +16,17 @@ from .ippe import square_object_points
|
||||
from .relative import _relative_pose
|
||||
|
||||
|
||||
OBSERVER_POSE_LIMITS = (math.radians(1), .001)
|
||||
|
||||
|
||||
def require_observer_precision_budget(inherited):
|
||||
"""A parent alone must not exhaust the observer's final precision budget."""
|
||||
if (len(inherited) != 2 or any(not math.isfinite(v) or v < 0 for v in inherited)):
|
||||
raise ValueError('observer_parent_uncertainty_invalid')
|
||||
if any(value >= limit for value, limit in zip(inherited, OBSERVER_POSE_LIMITS)):
|
||||
raise ValueError('observer_parent_precision_budget_exhausted')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObserverPose:
|
||||
quaternion_xyzw: tuple
|
||||
@@ -79,6 +90,7 @@ def _relative_covariance(fit, indices, parents, parent_corners, parent_size, chi
|
||||
|
||||
def fit_observer_pose(parents, candidates, corners, matrices, size, stamps, *, inherited=(0., 0.),
|
||||
parent_corners=None, parent_size=None):
|
||||
require_observer_precision_budget(inherited)
|
||||
count = len(stamps)
|
||||
if count < 24 or tuple(stamps) != tuple(sorted(set(stamps))) or any(not c for c in candidates):
|
||||
raise ValueError("observer_independent_images_missing")
|
||||
@@ -142,7 +154,7 @@ def fit_observer_pose(parents, candidates, corners, matrices, size, stamps, *, i
|
||||
points, matrices, variance)
|
||||
bounds = [float(3*np.sqrt(max(0., np.linalg.eigvalsh(covariance[i:i+3, i:i+3])[-1])))
|
||||
+ inherited[i//3] for i in (0, 3)]
|
||||
if not all(math.isfinite(v) for v in bounds) or bounds[0] > math.radians(1) or bounds[1] > .001:
|
||||
if not all(math.isfinite(v) for v in bounds) or any(v > limit for v, limit in zip(bounds, OBSERVER_POSE_LIMITS)):
|
||||
raise ValueError("observer_pose_uncertain")
|
||||
return ObserverPose(tuple(Rotation.from_rotvec(winner.x[:3]).as_quat()), tuple(winner.x[3:]),
|
||||
*bounds, tuple(stamps[i] for i in training), tuple(stamps[i] for i in validation), adjusted)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Explicit, bounded pose excitation at each task's existing clearance pose."""
|
||||
from dataclasses import replace
|
||||
from dataclasses import asdict, replace
|
||||
import json
|
||||
|
||||
from ..core.domain.profile import ModelPreparation
|
||||
|
||||
@@ -21,6 +22,54 @@ def preparation_image_budget(profile, *, joint_count=1):
|
||||
else (128, image_parameters_for_sampling()))
|
||||
|
||||
|
||||
def preparation_contract(profile):
|
||||
"""Version the entire preparation graph independently of capture cycles.
|
||||
|
||||
A strategy upgrade changes upstream geometry as well as downstream image
|
||||
budgets. Old captures remain readable, but cannot supply frozen models to
|
||||
a new preparation graph. Legacy strategies keep their existing contract.
|
||||
"""
|
||||
if not profile.motion.preparation_witnesses:
|
||||
return None
|
||||
tasks = {}
|
||||
for task in profile.motion.tasks:
|
||||
intervals, parameters = preparation_image_budget(profile, joint_count=len(task.joints))
|
||||
tasks[task.key] = dict(joints=list(task.joints), command_intervals=intervals,
|
||||
image_parameters=asdict(parameters),
|
||||
parent_reference=asdict(task.parent_reference) if task.parent_reference else None)
|
||||
value = dict(version='dependency_bound_preparation_v1', tasks=tasks,
|
||||
zero_references={name: asdict(spec) for name, spec in profile.motion.joint_zero_references.items()},
|
||||
witnesses={name: asdict(spec) for name, spec in profile.motion.preparation_witnesses.items()})
|
||||
# The live header and decoded JSON must compare identically (including
|
||||
# tuple-valued poses), without relying on incidental Python container types.
|
||||
return json.loads(json.dumps(value, allow_nan=False))
|
||||
|
||||
|
||||
def validate_preparation_contract(profile, records):
|
||||
"""Audit a declared graph against both its profile and accepted models.
|
||||
|
||||
Older captures remain evaluable under their original per-model policies.
|
||||
A new header must not advertise the new graph while importing old budgets.
|
||||
"""
|
||||
from ..core.geometry.tag_pose.production_image_motion import image_parameters_for_sampling
|
||||
declared = [row['preparation_contract'] for row in records
|
||||
if row.get('kind') == 'session_start' and 'preparation_contract' in row]
|
||||
if not declared:
|
||||
return
|
||||
if len(declared) != 1 or declared[0] != preparation_contract(profile):
|
||||
raise ValueError('preparation_contract_profile_changed')
|
||||
tasks = {task.key: set(task.joints) for task in profile.motion.tasks}
|
||||
for row in records:
|
||||
if row.get('kind') != 'motion_branch_initialization' or row.get('status') != 'resolved':
|
||||
continue
|
||||
joints = row.get('zero_joints', ())
|
||||
if not joints or not set(joints) <= tasks.get(row.get('task_name'), set()):
|
||||
raise ValueError('preparation_contract_model_dependency_changed')
|
||||
_, expected = preparation_image_budget(profile, joint_count=len(joints))
|
||||
if image_parameters_for_sampling(row.get('image_sampling_policy')) != expected:
|
||||
raise ValueError('preparation_contract_model_budget_changed')
|
||||
|
||||
|
||||
def configure_preparation(profile, policy):
|
||||
if policy != 'occlusion_aware_witness_v1':
|
||||
raise ValueError('unknown_preparation_policy')
|
||||
|
||||
@@ -182,6 +182,10 @@ class CalibrationCoordinator:
|
||||
"source": "sdk_target_position_register" if parameters.initial_command else "adapter_feedback"},
|
||||
**({"diagnostic_capture": self.diagnostic_capture.as_dict()} if self.diagnostic_capture else {}),
|
||||
}
|
||||
from ..profiles.preparation import preparation_contract
|
||||
preparation = preparation_contract(profile)
|
||||
if preparation is not None:
|
||||
self._session_header["preparation_contract"] = preparation
|
||||
if profile.acquisition.command_capture_mode == "separate" and self.diagnostic_capture is None:
|
||||
from ..core.fitting.command_sampling import SAMPLING_POLICY
|
||||
self._session_header["command_sampling_policy"] = SAMPLING_POLICY
|
||||
|
||||
@@ -299,7 +299,9 @@ class CalibrationEngine:
|
||||
)
|
||||
|
||||
def resume_compatible(self, session_start: Mapping[str, Any]) -> bool:
|
||||
from ..profiles.preparation import preparation_contract
|
||||
return (self.capture_compatible(session_start)
|
||||
and session_start.get("preparation_contract") == preparation_contract(self.profile)
|
||||
and session_start.get("capture_schedule_version") == capture_schedule_version(self.profile))
|
||||
|
||||
|
||||
|
||||
@@ -343,6 +343,8 @@ def validate_motion_provenance(profile, records, *, required=False, require_comp
|
||||
The explicit ``required`` flag is set by the production owner. Independent
|
||||
record markers preserve this requirement after a header is removed.
|
||||
"""
|
||||
from ..profiles.preparation import validate_preparation_contract
|
||||
validate_preparation_contract(profile, records)
|
||||
if not required and not uses_motion_evidence(records):
|
||||
return {"status": "legacy_image_confirmation", "motion_evidence_verified": False}
|
||||
from .visual_motion import validate_visual_motion_evidence
|
||||
|
||||
@@ -6,7 +6,7 @@ import numpy as np
|
||||
|
||||
from ..core.geometry.tag_pose.motion_evidence import MotionRelation
|
||||
from ..core.geometry.tag_pose.pose_bridge import SharedTagPoseBridge
|
||||
from ..core.geometry.tag_pose.observer_pose import fit_observer_pose
|
||||
from ..core.geometry.tag_pose.observer_pose import fit_observer_pose, require_observer_precision_budget
|
||||
from ..core.geometry.tag_pose.image_motion_projection import select_image_motion_frame
|
||||
from .branch_initialization import _pose
|
||||
from .shared_tag_reference import bridge_frames
|
||||
@@ -37,6 +37,9 @@ def build_observer_bridge(profile, source):
|
||||
rows, held = source.rows, source.held_channels
|
||||
if len(parent_model.geometry) != 1 or parent_model.geometry[0].relation.child_role != spec.parent_role:
|
||||
raise ValueError('observer_parent_model_invalid')
|
||||
uncertainty = {name: (angle, point) for name, angle, point in source.parent_report['geometry_uncertainty']}
|
||||
inherited = uncertainty[parent_model.geometry[0].relation.joint]
|
||||
require_observer_precision_budget(inherited)
|
||||
target = profile.motion.joint_zero_references[source.joint].command
|
||||
minimum = max(10, profile.acquisition.fixed_reference_minimum_frames)
|
||||
endpoints = source.endpoint_rows[-minimum:]
|
||||
@@ -77,8 +80,6 @@ def build_observer_bridge(profile, source):
|
||||
matrices.append(row['camera_matrix'])
|
||||
if len(sizes) != 1:
|
||||
raise ValueError('observer_tag_size_changed')
|
||||
uncertainty = {name: (angle, point) for name, angle, point in source.parent_report['geometry_uncertainty']}
|
||||
inherited = uncertainty[parent_model.geometry[0].relation.joint]
|
||||
pose = fit_observer_pose(parents, candidates, corners, matrices, sizes.pop(),
|
||||
tuple(r['image_stamp_ns'] for r in rows), inherited=inherited,
|
||||
parent_corners=[r['tags'][spec.parent_role]['corners_xy'] for r in rows],
|
||||
|
||||
@@ -45,6 +45,8 @@ def discover_resume_candidate(
|
||||
session_root: Path, *, profile_id: str, serial_number: str,
|
||||
protected_hashes: Mapping[str, str],
|
||||
training_policy: str = "fixed",
|
||||
preparation_contract: Mapping[str, Any] | None = None,
|
||||
rejected_candidates: list | None = None,
|
||||
) -> Path | None:
|
||||
"""Locate evidence, never authorize its reuse or publish from a checkpoint.
|
||||
|
||||
@@ -99,6 +101,8 @@ def discover_resume_candidate(
|
||||
raise ValueError("checkpoint training policy differs")
|
||||
if not _compatible_header(start, profile_id, serial_number, root.name, protected_hashes):
|
||||
raise ValueError("checkpoint header incompatible")
|
||||
if start.get("preparation_contract") != preparation_contract:
|
||||
raise ValueError("checkpoint preparation contract changed")
|
||||
if kind == "fixed_base_reference_locked":
|
||||
reference_found = True
|
||||
if kind == "scan_unit_complete" or kind.endswith("sweep_observation_quality"):
|
||||
@@ -116,7 +120,13 @@ def discover_resume_candidate(
|
||||
previous = completed.get(identity)
|
||||
if previous is None or attempt >= previous[0]:
|
||||
completed[identity] = (attempt, passed)
|
||||
except (OSError, ValueError):
|
||||
except (OSError, ValueError) as error:
|
||||
if rejected_candidates is not None:
|
||||
rejected_candidates.append(dict(session=candidate.name, reason=str(error)))
|
||||
# An explicit fresh run is a lineage boundary even if its header
|
||||
# cannot be reused. Do not resurrect evidence from before it.
|
||||
if start is not None and start.get("resume_checkpoint_requested") is False:
|
||||
break
|
||||
continue
|
||||
if start is None or not reference_found or not completed_found:
|
||||
continue
|
||||
|
||||
@@ -141,11 +141,21 @@ def run_online(
|
||||
atomic_write_json(session/"measurement_contract.json", contract_report)
|
||||
if profile.artifacts.output_schema_version >= 3 and commands_enabled and diagnostic is None:
|
||||
require_release_geometry(contract_report)
|
||||
from ..profiles.preparation import preparation_contract
|
||||
rejected_checkpoints = []
|
||||
preparation = preparation_contract(profile)
|
||||
resume = discover_resume_candidate(
|
||||
config.session_root, profile_id=profile.key.profile_id,
|
||||
serial_number=config.serial_number, protected_hashes=protected_inputs(config),
|
||||
training_policy=profile.quality.training_policy,
|
||||
preparation_contract=preparation, rejected_candidates=rejected_checkpoints,
|
||||
) if allow_resume and commands_enabled and diagnostic is None else None
|
||||
atomic_write_json(session/"resume_selection.json", dict(
|
||||
selected_session=resume.name if resume else None,
|
||||
preparation_contract=preparation, rejected_candidates=rejected_checkpoints))
|
||||
if any(row['reason'] == 'checkpoint preparation contract changed' for row in rejected_checkpoints):
|
||||
print("旧断点的准备策略或父子模型预算与当前版本不同,已排除复用;"
|
||||
+ ("将使用兼容断点。" if resume else "本轮重新采集整条准备链,历史记录保留。"), flush=True)
|
||||
if contract_report["cad_constraint_conflicts"]:
|
||||
print("原始 CAD 存在限位/mimic 冲突,未修改原文件;详见 measurement_contract.json。最终范围验证不通过时禁止发布。", flush=True)
|
||||
log_path = session / "calibration.log"
|
||||
|
||||
@@ -67,6 +67,85 @@ def test_uncertain_control_transaction_does_not_refresh_the_clock():
|
||||
assert timing.frame_clock.anchor == latch(600_000_000)
|
||||
|
||||
|
||||
def test_rejected_sync_preserves_failed_transaction_and_previous_anchor(tmp_path):
|
||||
path = tmp_path/'clock.jsonl'
|
||||
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000., journal_path=path)
|
||||
timing.frame_clock = initialized()
|
||||
anchor = timing.frame_clock.anchor
|
||||
jumped = latch(500_000_000, host_jump=10_000_000)
|
||||
with pytest.raises(ValueError, match='discontinuity'):
|
||||
timing._update([jumped])
|
||||
assert timing.frame_clock.anchor == anchor
|
||||
row = json.loads(path.read_text()) # Evidence is flushed before invalidation.
|
||||
assert row['kind'] == 'camera_clock_sync_rejected'
|
||||
assert ClockLatch(**row['previous_anchor']) == anchor
|
||||
assert [ClockLatch(**x) for x in row['latches']] == [jumped]
|
||||
replay = DeviceFrameClock(row['ticks_per_second'])
|
||||
replay.anchor = ClockLatch(**row['previous_anchor'])
|
||||
with pytest.raises(ValueError, match='discontinuity'):
|
||||
replay.update([ClockLatch(**x) for x in row['latches']])
|
||||
timing.close()
|
||||
|
||||
|
||||
def test_raw_clock_brackets_wall_clock_without_replacing_it(monkeypatch):
|
||||
wall = iter((T0, T0+100_000))
|
||||
raw = iter((20_000, 130_000))
|
||||
camera = SimpleNamespace(MV_CC_SetCommandValue=lambda name: 0)
|
||||
timing = MvsCameraTiming(camera, None, lambda: next(wall), exposure_us=5000.,
|
||||
raw_clock_ns=lambda: next(raw))
|
||||
monkeypatch.setattr(timing, '_integer', lambda name: 123456)
|
||||
measured = timing._latch()
|
||||
assert measured == ClockLatch(123456, T0, T0+100_000, 20_000, 130_000)
|
||||
assert measured.midpoint_ns == T0+50_000
|
||||
assert measured.half_width_ns == 50_000
|
||||
|
||||
|
||||
@pytest.mark.parametrize('slew', [-.005, .005])
|
||||
def test_measured_drift_refreshes_before_continuity_budget_is_spent(monkeypatch, slew):
|
||||
from dataclasses import replace
|
||||
def slewed(elapsed_ns):
|
||||
return latch(elapsed_ns, width=100_000, host_jump=round(elapsed_ns*slew))
|
||||
# At the former fixed interval smooth slew fails the unchanged gate.
|
||||
fixed = DeviceFrameClock(100_000_000)
|
||||
fixed.update([slewed(0), slewed(100_000_000)])
|
||||
with pytest.raises(ValueError, match='discontinuity'):
|
||||
fixed.update([slewed(600_000_000)])
|
||||
now = [.1]
|
||||
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.monotonic', lambda: now[0])
|
||||
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
|
||||
timing.frame_clock = DeviceFrameClock(100_000_000)
|
||||
timing._update([slewed(0), slewed(100_000_000)])
|
||||
monkeypatch.setattr(timing, '_latch', lambda: slewed(round(now[0]*1e9)))
|
||||
maximum_error = 0
|
||||
for index in range(1, 1801): # 60 seconds at the real camera's 30 Hz.
|
||||
now[0] = .1+index/30
|
||||
timing.refresh_if_due()
|
||||
elapsed = round((now[0]-.016)*1e9)
|
||||
stamp = timing.frame_clock.timestamp(latch(elapsed).device_tick,
|
||||
received_ns=slewed(round(now[0]*1e9)).midpoint_ns, exposure_us=5000.)
|
||||
expected = T0+round((elapsed+2_500_000)*(1+slew))
|
||||
maximum_error = max(maximum_error, abs(stamp-expected))
|
||||
assert maximum_error < 1_000_000
|
||||
assert .02 <= timing.frame_clock.refresh_interval_seconds < .1
|
||||
# A discontinuity is still rejected, including after successful adaptation.
|
||||
anchor = timing.frame_clock.anchor
|
||||
bad = slewed(round((now[0]+.04)*1e9))
|
||||
with pytest.raises(ValueError, match='discontinuity'):
|
||||
timing._update([replace(bad, before_ns=bad.before_ns+10_000_000, after_ns=bad.after_ns+10_000_000)])
|
||||
assert timing.frame_clock.anchor == anchor
|
||||
|
||||
|
||||
def test_startup_anchor_age_is_charged_to_refresh_budget(monkeypatch):
|
||||
monkeypatch.setattr('linkerhand_calibration.camera_timing.time.monotonic', lambda: 10.)
|
||||
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
|
||||
timing.frame_clock = DeviceFrameClock(100_000_000)
|
||||
initial = [latch(i*20_000_000, width=100_000 if i == 0 else 200_000,
|
||||
host_jump=i*100_000) for i in range(8)]
|
||||
timing._update(initial)
|
||||
assert timing.frame_clock.anchor == initial[0]
|
||||
assert timing.next_refresh == 10. # Renew before acquiring the next frame.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('host_jump', [0, 10_000_000, -1_000_000_000])
|
||||
def test_recovery_revalidates_clock_and_preserves_frame_time_order(monkeypatch, host_jump):
|
||||
timing = MvsCameraTiming(None, None, lambda: 0, exposure_us=5000.)
|
||||
|
||||
@@ -41,6 +41,14 @@ def test_all_legal_candidates_use_fixed_split_and_propagate_parent_uncertainty(o
|
||||
assert result.training_stamps == tuple(range(1, 49, 2))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('inherited', [(math.radians(2.274), .0005), (0., .001)])
|
||||
def test_exhausted_parent_budget_is_rejected_before_fitting(observer_images, monkeypatch, inherited):
|
||||
monkeypatch.setattr('linkerhand_calibration.core.geometry.tag_pose.observer_pose.least_squares',
|
||||
lambda *a, **kw: pytest.fail('fitting cannot recover spent parent precision'))
|
||||
with pytest.raises(ValueError, match='parent_precision_budget_exhausted'):
|
||||
fit_observer_pose(*observer_images, inherited=inherited)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault', ['missing', 'moving', 'installation', 'unobservable', 'uncertain_parent'])
|
||||
def test_invalid_observer_evidence_cannot_authorize_a_pose(observer_images, fault):
|
||||
parents, candidates, corners, matrices, size, stamps = observer_images
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Preparation upgrades cannot silently reuse frozen upstream geometry."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from linkerhand_calibration.core.domain.capture_plan import STAGED, configure_training
|
||||
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
|
||||
from linkerhand_calibration.profiles import load_bundled_hand_profile
|
||||
from linkerhand_calibration.profiles.preparation import configure_preparation, preparation_contract
|
||||
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION, CalibrationEngine
|
||||
from linkerhand_calibration.runtime.joint_resume import PreparedResume
|
||||
from linkerhand_calibration.runtime.resume import discover_resume_candidate
|
||||
|
||||
|
||||
def dense_profile():
|
||||
return configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'), STAGED),
|
||||
'occlusion_aware_witness_v1')
|
||||
|
||||
|
||||
def header(profile):
|
||||
return dict(kind='session_start', serial_number='O30_TEST', profile_id=profile.key.profile_id,
|
||||
acquisition_policy_version=ACQUISITION_POLICY_VERSION,
|
||||
pose_tracking_policy_version=POSE_TRACKING_POLICY_VERSION,
|
||||
capture_schedule_version=CalibrationEngine(profile).capture_schedule_version,
|
||||
capture_plan={'policy': STAGED}, protected_hashes={'source': 'a'*64},
|
||||
preparation_contract=preparation_contract(profile), resume_checkpoint_requested=True)
|
||||
|
||||
|
||||
def checkpoint(root, name, start, *, bad_tail=False):
|
||||
path = root/name
|
||||
path.mkdir()
|
||||
rows = [start, dict(kind='fixed_base_reference_locked'),
|
||||
dict(kind='scan_unit_complete', task_name='thumb_cmc_roll_front',
|
||||
cycle=0, direction='increasing', passed=True)]
|
||||
(path/'raw_samples.jsonl').write_text('\n'.join(json.dumps(row) for row in rows)
|
||||
+ ('\nnot-json' if bad_tail else '\n'))
|
||||
return path
|
||||
|
||||
|
||||
def discover(root, profile, rejected):
|
||||
return discover_resume_candidate(root, profile_id=profile.key.profile_id, serial_number='O30_TEST',
|
||||
protected_hashes={'source': 'a'*64}, training_policy=STAGED,
|
||||
preparation_contract=preparation_contract(profile), rejected_candidates=rejected)
|
||||
|
||||
|
||||
def test_old_budget_rejected_at_header_before_loading_large_history(tmp_path):
|
||||
profile = dense_profile()
|
||||
old = header(profile)
|
||||
old.pop('preparation_contract')
|
||||
checkpoint(tmp_path, '001', old, bad_tail=True)
|
||||
rejected = []
|
||||
assert discover(tmp_path, profile, rejected) is None
|
||||
assert rejected == [dict(session='001', reason='checkpoint preparation contract changed')]
|
||||
assert not CalibrationEngine(profile).resume_compatible(old)
|
||||
|
||||
|
||||
def test_matching_contract_resumes_and_json_roundtrip_is_identical(tmp_path):
|
||||
profile = dense_profile()
|
||||
start = json.loads(json.dumps(header(profile)))
|
||||
source = checkpoint(tmp_path, '001', start)
|
||||
assert CalibrationEngine(profile).resume_compatible(start)
|
||||
assert discover(tmp_path, profile, []) == source
|
||||
# Capture validity is separate: readable historical artifacts are not
|
||||
# relabelled corrupt simply because a new live plan uses a different budget.
|
||||
old = {key: value for key, value in start.items() if key != 'preparation_contract'}
|
||||
assert CalibrationEngine(profile).capture_compatible(old)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field', ['budget', 'witness', 'parent', 'version'])
|
||||
def test_changed_dependency_graph_invalidates_whole_preparation_before_motion(tmp_path, field):
|
||||
profile = dense_profile()
|
||||
changed = header(profile)
|
||||
contract = changed['preparation_contract']
|
||||
if field == 'budget':
|
||||
contract['tasks']['pinky_pip_side']['command_intervals'] = 128
|
||||
elif field == 'witness':
|
||||
contract['witnesses'].pop('pinky_mcp_pitch')
|
||||
elif field == 'parent':
|
||||
contract['tasks']['pinky_dip_side']['parent_reference']['geometry_joint'] = 'ring_pip'
|
||||
else:
|
||||
contract['version'] = 'future_strategy'
|
||||
checkpoint(tmp_path, '001', changed)
|
||||
assert discover(tmp_path, profile, []) is None
|
||||
assert not CalibrationEngine(profile).resume_compatible(changed)
|
||||
with pytest.raises(ValueError, match='identity/policy changed'):
|
||||
PreparedResume.load(profile, CalibrationEngine(profile), tmp_path/'001/raw_samples.jsonl', 'O30_TEST')
|
||||
|
||||
|
||||
def test_incompatible_fresh_run_cannot_resurrect_older_lineage(tmp_path):
|
||||
profile = dense_profile()
|
||||
checkpoint(tmp_path, '001', header(profile))
|
||||
fresh = header(profile)
|
||||
fresh.pop('preparation_contract')
|
||||
fresh['resume_checkpoint_requested'] = False
|
||||
checkpoint(tmp_path, '002', fresh)
|
||||
assert discover(tmp_path, profile, []) is None
|
||||
|
||||
|
||||
def test_resume_upgrade_does_not_rewrite_historical_capture(tmp_path):
|
||||
profile = dense_profile()
|
||||
old = header(profile)
|
||||
old.pop('preparation_contract')
|
||||
path = checkpoint(tmp_path, '001', old)/'raw_samples.jsonl'
|
||||
before = path.read_bytes()
|
||||
assert discover(tmp_path, profile, []) is None
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_capture_cycle_supplement_keeps_preparation_but_changed_pose_does_not():
|
||||
from linkerhand_calibration.core.domain.capture_plan import select_task_training
|
||||
profile = dense_profile()
|
||||
supplemented = select_task_training(profile, profile.motion.tasks[0].key, (0, 1, 2))
|
||||
assert preparation_contract(supplemented) == preparation_contract(profile)
|
||||
zeros = dict(profile.motion.joint_zero_references)
|
||||
spec = zeros['pinky_pip']
|
||||
command = list(spec.command)
|
||||
command[0] -= 1
|
||||
zeros['pinky_pip'] = replace(spec, command=tuple(command))
|
||||
changed = replace(profile, motion=replace(profile.motion, joint_zero_references=zeros))
|
||||
assert preparation_contract(changed) != preparation_contract(profile)
|
||||
|
||||
|
||||
def test_live_coordinator_writes_effective_preparation_graph(tmp_path):
|
||||
from runtime_host_fixture import coordinator_fixture
|
||||
host, _ = coordinator_fixture(tmp_path, model='o30', profile_transform=lambda _: dense_profile())
|
||||
try:
|
||||
row = json.loads(host.raw_path.read_text().splitlines()[0])
|
||||
assert row['preparation_contract'] == preparation_contract(host.profile)
|
||||
assert CalibrationEngine(host.profile).resume_compatible(row)
|
||||
finally:
|
||||
host.close()
|
||||
|
||||
|
||||
def test_legacy_profiles_keep_existing_resume_contract():
|
||||
profile = load_bundled_hand_profile('o30_right_18')
|
||||
assert preparation_contract(profile) is None
|
||||
|
||||
|
||||
def test_new_header_cannot_relabel_old_parent_models():
|
||||
from linkerhand_calibration.profiles.preparation import validate_preparation_contract
|
||||
from linkerhand_calibration.runtime.motion_provenance import validate_motion_provenance
|
||||
from linkerhand_calibration.core.geometry.tag_pose.production_image_motion import DENSE_PREPARATION_SAMPLING
|
||||
profile = dense_profile()
|
||||
old = dict(kind='motion_branch_initialization', task_name='pinky_pip_side',
|
||||
zero_joints=['pinky_pip'], status='resolved')
|
||||
with pytest.raises(ValueError, match='model_budget_changed'):
|
||||
validate_motion_provenance(profile, [header(profile), old])
|
||||
dense = {**old, 'image_sampling_policy': DENSE_PREPARATION_SAMPLING}
|
||||
validate_preparation_contract(profile, [header(profile), dense])
|
||||
changed = deepcopy(header(profile))
|
||||
changed['preparation_contract']['version'] = 'another_graph'
|
||||
with pytest.raises(ValueError, match='profile_changed'):
|
||||
validate_preparation_contract(profile, [changed, dense])
|
||||
Reference in New Issue
Block a user