diff --git a/src/g20_thumb_apriltag_calibration/README.md b/src/g20_thumb_apriltag_calibration/README.md
index 3794598..55aade3 100644
--- a/src/g20_thumb_apriltag_calibration/README.md
+++ b/src/g20_thumb_apriltag_calibration/README.md
@@ -15,18 +15,34 @@ ros2 run g20_thumb_apriltag_calibration calibrate_g20_right
当场剔除并从其在扫掠顺序中的原始位置重采,避免全部任务采完后才在最终验收
失败、把会话拉回靠前的关节。运行中的多视角任务按正面主测量和侧面校验测量
独立保留;单轮转轴异常且其余三轮形成一致簇时只补扫异常轮的两个方向。侧面
-校验视角的任务级有效率只记录为诊断,完整端点、分箱覆盖和模型质量门限仍保持
-不变。需要强制从第一个关节
+轴线位置若也能明确定位为单轮异常,同样只补扫该轮;补扫会保留任务预检和前次
+采集确定的PnP分支参考,不会因重新初始化切换到另一组平面Tag镜像解。侧面
+校验视角的任务级有效率只记录为诊断;G20右手预检若逐帧识别率低于标称值,
+但同步有效位姿已经完整覆盖端点、中点、最小分箱数和最大分箱空洞,也按完整
+轨迹通过。正式扫描仍逐方向执行相同的硬分箱覆盖检查,轴线、曲线和模型质量
+门限保持不变。每个任务的低速往返预检、首轮交接和四轮双向正式扫描属于同一
+采集事务:相邻方向共享已验证端点和任务级PnP参考。G20右手正式扫描固定使用
+产品审定速度,不再根据单次识别密度自动提速,确保不同会话测量的是同一动态
+过程。需要强制从第一个关节
重新采集时使用 `--no-resume`。升级前已经分别完成的正面/侧面roll也会合并为
一个完整同步任务断点;只有两边数据都完整时才复用。
命令自动完成产品哈希预检、运动、当前任务补扫、前三轮训练、第四轮隔离留出、
-15个会话数据求解主动关节URDF零位修正(`thumb_mcp`固定CAD参考)、
+16个会话数据求解主动关节URDF零位修正、
21条视觉实测命令曲线发布;四指PIP/DIP的动态曲线均实测,四指DIP静态零位保留CAD。
终端只显示中文进度和问题;失败时复制“请复制以下内容给开发者”块即可。
四指末端的16 mm Tag允许使用刚性延长杆避挡;软件不假设末端Tag平面与中节Tag
平面平行。延长杆和Tag在一次标定期间必须完全刚性,不能晃动、扭转或重新调整。
+侧面掌部基准Tag(ID 4)与各活动指节Tag也不要求安装面平行:首次联合PnP使用
+静态多帧刚性、重投影误差和跨轮任务参考选择分支,不再用固定15°安装角门限阻断扫描。
+单Tag独立位姿仍保留75°倾角保护;对包含锁定掌部基准和完整父子链的任务,倾角保护只
+限制独立选择,不会在联合选择前删除正深度、低重投影的IPPE候选。联合跟踪继续用相邻帧
+绝对/相对位姿连续性约束这些斜视候选,最终轴线残差、四轮重复性和隔离留出门限不放宽。
+终端中的Tag计数表示“可见”;等待扫描起点时会另列PnP初始化进度和累计拒绝原因。
+若联合候选仍然失败,`raw_samples.jsonl` 会按8个反馈计数的区间保存
+`group_pnp_candidate_event`,其中包含缺失角色、逐Tag候选数/倾角/重投影、角点和相机内参
+哈希,可直接定位运动到哪个机械位置后开始失效,而不需要再次盲扫整条流程。
正式结果位于 `calibration_output/G20_RIGHT_001/latest_passed`。该指针只在
JSON、URDF数值等价、mesh完整性、21条曲线CAD限位、被动关节保护和隔离留出验证
@@ -64,19 +80,22 @@ ros2 launch g20_thumb_apriltag_calibration three_camera_calibration.launch.py \
固定掌部位姿。小指和无名指弯曲避让会遮住正面ID 0,因此四指正面+侧面同步roll中
允许ID 0暂时不可见,并使用本会话基准锁定值;运动连杆Tag仍必须实时可见,门限不
放宽。终端用`锁`表示该固定参考有效,例如`正面[0锁,12✓]`,`✗`才表示需要处理的
-实时Tag。锁定后不得移动相机、手掌底座或整只手,否则缓存参考失效,必须重新启动
-标定。任务级Tag有效率门限按"当前任务所需角色生效期间的采集帧"统计;
+实时Tag。锁定后本次标定运行中不得再移动相机、手掌底座或整只手,否则缓存参考
+失效,必须重新启动标定;两次独立会话之间轻微调整整只手的位置不会改变机械端点
+零位基准。任务级Tag有效率门限按"当前任务所需角色生效期间的采集帧"统计;
任务结束后角色要求会切回预检全套标签,静止期帧不参与该门限,避免把
采集质量良好的任务误判为可见性失败。
恢复末端Tag后,程序能够独立实测四指PIP和DIP的转轴及动态命令曲线。但同一次
相机外参和同一套Tag安装下的重复扫描无法排除固定安装相位偏差;实体手在反馈0端
-能够触掌是独立的机械端点约束。因此四指MCP pitch、PIP的URDF静态零位由本次
-实测全屈曲行程与CAD触掌角之差求出,不再把同相机的平行轴PnP相位直接当作绝对
-零位,也不写死为0。生成URDF时同步修正这些关节的坐标上限和DIP mimic坐标偏置,
+能够触掌是独立的机械端点约束。三个thumb CMC主动轴、`thumb_mcp`、四指MCP pitch
+和PIP的URDF静态零位均由本次实测全行程与CAD机械端点之差求出,不再把跨相机的
+平面PnP绝对相位直接当作编码器零位,也不写死为0。生成URDF时同步修正这些关节的
+坐标上限以及相关被动关节的mimic坐标偏置,
保证非零零偏不会缩短最大闭合量。末节Tag继续用于DIP动态曲线、轴线质量、遮挡和
-第四轮留出检查。正式数据求解静态修正范围为拇指CMC三个主动关节、四指MCP roll、
-MCP pitch及PIP,共15个;只有`thumb_mcp`固定为既有CAD参考。
+第四轮留出检查。正式数据求解静态修正范围为拇指CMC三个主动关节、`thumb_mcp`、
+四指MCP roll、MCP pitch及PIP,共16个。`thumb_mcp`与四指屈伸关节一样使用
+实测全屈曲行程和CAD机械端点联合求解,不直接采用MCP/IP耦合运动的单目PnP相位。
侧面累计避障按“PIP→MCP pitch→roll”的安全顺序分阶段进入,并按逆序分阶段退出;
同类辅助电机(全部邻指滚转、全部PIP、全部MCP pitch)合并为同一个并行航点同时
运动,被测通道最后单独进入。“滚转全部回中前不展开弯曲手指”“每指pitch先于PIP”
@@ -87,7 +106,8 @@ MCP pitch及PIP,共15个;只有`thumb_mcp`固定为既有CAD参考。
当前已经在位(含反馈容差)的辅助电机保持原位,只有下一组不再使用的避障电机
退回基准,避免"先展开回基准、马上又折回"的多余动作;已评审的
"滚转先回中再展开""先滚开再弯曲"顺序保持不变。预检正反方向若都保留至少64个电机分箱且最大空缺
-不超过8,会把非roll任务四轮正式速度最多提高到预检速度的1.5倍;否则保持原保守速度。
+不超过8,只作为采集能力诊断。G20右手四轮正式速度始终使用产品配置的固定值,
+不会因本次预检帧率或识别密度而改变;旧11-Tag布局仍保留自适应速度兼容逻辑。
四指roll不再把同一反馈127误当成方向无关的唯一机械姿态:以`255→127`为标准物理
零位,反向到达127的实测偏差保留在`increasing_rad`中。方向分支间隙上限1.5°、
四轮间隙极差上限0.3°;其他关节仍使用严格的0.5°baseline回差门限。
@@ -96,7 +116,8 @@ MCP pitch及PIP,共15个;只有`thumb_mcp`固定为既有CAD参考。
方向分支检查和动态曲线的127相位都使用这两组双向静止数据,运动中经过127的帧不再
替代静态保持姿态。
前三轮只用于训练,第四轮完全留出;留出轮不参与显著性、Student-t置信区间或最终重拟合。
-每轮PnP都清空帧间跟踪状态并重新执行8帧静态初始化,但同一任务第1轮
+每个任务只在低速递减预检起点执行一次8帧PnP静态初始化;预检往返和四轮正式
+扫描连续复用同一帧间分支与任务参考,不再让每一轮独立选择平面Tag解。同一任务第1轮
已确立的端点相对姿态作为后3轮的分支锚点,防止独立初始化选到相反的
IPPE镜像解。baseline标准接近和全部质量门限保持不变。
电机15任务会利用源URDF中已确认的`thumb_ip mimic=1.03`,只在逐帧IPPE双解中
@@ -105,26 +126,31 @@ IPPE镜像解。baseline标准接近和全部质量门限保持不变。
当前19-Tag产品流程发布精简schema v4:21条运行时曲线全部来自当前会话的视觉实测。
URDF零位字段覆盖拇指4个主动关节和四指各自的`mcp_roll/mcp_pitch/pip`,共16个,
-其中四指`mcp_pitch/pip`共8个字段由视觉行程+机械端点联合求解,只有`thumb_mcp`
-字段固定为0;
+其中三个thumb CMC轴、`thumb_mcp`及四指`mcp_pitch/pip`共12个字段由实测旋转行程
+与机械端点联合求解;
+写出非零`thumb_mcp`零偏时同步平移其关节坐标上限,并更新被动`thumb_ip`的
+`mimic offset`,因此不会改变CAD定义的最大屈曲实体姿态;
`thumb_ip`及四指DIP静态零位保留源CAD。旧schema v5文件仅作历史回放兼容,
当前一键流程不再生成它。正面/侧面roll在同一次运动中独立拟合;方向、
轴线和动态曲线均通过时做不确定度加权轴融合。侧面PIP连杆标签在滚转扫掠中
相对侧相机视线倾斜约13°~20°,平面标签的单目IPPE姿态二义性会给侧视姿态引入
数度的系统性"绕视线"偏差(亚像素重投影无法发现,会话20260820_105535实测
-前后轴向稳定相差11.4°),因此跨视角方向差超过0.75°融合门限时不再判定任务
-失败:程序记录`cross_view_roll_axis_diagnostic`诊断、跳过融合并采用可信的
-前视轴向;仅当差值超过粗错误兜底上限(`cross_view_roll_maximum_axis_difference_deg`
-默认15°或线距超过`cross_view_roll_maximum_axis_line_difference_mm`默认30mm,
-对应标签贴错连杆或标签松动)时才失败。前视侧摆连杆与侧视PIP连杆的轴线
-本身存在约21mm的系统性位置差(丝杠平移连杆),属预期现象。侧视校验通道
+前后轴向稳定相差11.4°),因此侧视PIP连杆姿态不再参与MCP轴向融合或角曲线验收,
+正侧姿态差只写入`cross_view_roll_axis_diagnostic`。四根MCP侧摆轴在产品URDF中
+严格平行:小指作为先采集的参考轴,其余三指复用该公共方向并各自独立拟合轴线位置,
+避免平面PnP分支在不同会话中改变轴向。前视侧摆连杆受丝杆平移影响,其纯旋转拟合得到的是
+随手指结构变化的伪轴线,不能与侧视PIP连杆的物理轴线使用统一距离门限;
+两者线距仅记录在诊断中。侧视校验通道
(`*_mcp_roll_side`)的
-分支间隙跨轮极差上限放宽为
-`cross_view_roll_alias_maximum_branch_gap_range_deg`默认0.5°(绝对间隙1.5°上限
-不变),其`axis_pose_line_rms`降为诊断,不再作为准入门限;径向、平面、
-圆一致性等其余数据质量门限全部保留。侧面端视roll的圆轨迹方向已经受
+姿态分支间隙跨轮极差和独立姿态轴方向极差只作诊断,不触发重复采集;这两个量来自
+近掠射平面Tag的非发布姿态分量。绝对分支间隙1.5°上限保持不变,真正发布的正面主轴
+仍使用原跨轮严格门限。侧视逐帧`axis_pose_line_rms`同样只作诊断,组合轴线改用四轮
+位置RMS验收;径向、平面、圆一致性及可见性门限全部保留。
+侧面端视roll的圆轨迹方向已经受
姿态轴约束,因此自由三维圆平面与姿态轴的夹角只保留诊断,不再被重复作为硬门限;
-径向残差、SE(3)轴线残差和正侧面轴/曲线一致性仍是硬门限。任一静态目标、第四轮留出、
+径向残差和四轮轴线位置RMS仍是硬门限。正式MCP动态曲线统一使用正面Tag中心的
+二维投影圆角度,侧面姿态曲线仅保留为诊断;任一正式视角自身四轮不重复或第四轮
+留出失败仍会拒绝发布。任一静态目标、第四轮留出、
遮挡、PnP或跨机位检查失败时,只保留原始轨迹和`passed:false`诊断,不发布正式URDF。
8个组合姿态仅保留为开发诊断,正式产品默认不执行。轴线零位求解不提供适合绝对笛卡尔
位置验收的手基座变换,因此不能用该诊断推翻已经通过的单关节隔离留出结果。三个CMC轴恢复使用
@@ -335,13 +361,14 @@ Tag位置,接近轴向观察时改用相机图像平面相位并丢弃单目Pn
约4°的整指倾斜;重复扫描与同源留出不能排除这种系统偏差,因此不得写入URDF。
四指MCP屈伸和PIP采用同一静态策略:参考指轨迹仍参与动态曲线、轴质量和机构诊断,
-但拟合出的绝对相位不写入任何一根四指的 `origin.rpy`。拇指CMC roll/yaw/pitch的非零
-修正只能来自当前会话的三轮轨迹求解并通过第三轮留出验证;代码和配置中不保存任何
-按左右手或序列号写死的拇指零位角。电机5的256点动态曲线也使用本机三轮实测结果。
+但拟合出的共同掌坐标相位不写入四指 `origin.rpy`;只发布各指相对四指中值的实测
+装配偏差。拇指CMC roll/yaw/pitch的非零修正来自当前会话的完整相对旋转行程与机械
+端点,视觉轴链继续用于轴线、PnP和留出诊断;代码和配置中不保存任何按左右手或
+序列号写死的拇指零位角。电机5的256点动态曲线同样使用本机四轮实测结果。
-7个直接零位依赖链为:yaw轴约束拇指roll、pitch轴约束拇指yaw、MCP轴线相位约束
-拇指pitch;IP轴线相位仅作诊断,不能覆盖拇指MCP的原始CAD零位。参考指MCP pitch轴
-约束roll,PIP/DIP轴线相位只用于参考指机构诊断,不再覆盖四指CAD静态零位。
+视觉依赖链仍为:yaw轴检查拇指roll、pitch轴检查拇指yaw、MCP轴线相位检查拇指
+pitch、IP轴线相位检查拇指MCP。该链用于几何和PnP诊断,不再决定四个具有机械端点
+的拇指主动关节绝对零位;四指PIP/DIP轴线相位也继续用于机构诊断。
原始URDF的 `origin.xyz`、`axis.xyz`、连杆长度、mesh和被动结构固定。yaw扫描时电机5
保持145,求解器使用实测 `angle_rad[145]` 还原该条件,不会把145误当成baseline。
偏移超过各关节专用上限时整次失败。数值求解会在更宽的诊断范围内继续估计,因此状态和原始JSONL
@@ -431,9 +458,9 @@ calibration_output/G20_RIGHT_001/<时间戳>/
`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从
指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或
覆盖原文件;只有通过独立求解验证或明确机械装配基准授权的主动关节 `origin.rpy` 可能
-改变;当前19-Tag右手保留16个主动静态零位字段,其中15个由本会话数据求解,只有
-`thumb_mcp`固定为原始CAD零位0;四指DIP为被动关节,发布实测动态曲线并保留CAD
-静态零位,其mimic坐标偏置只随上游PIP坐标系变换作等价调整。
+改变;当前19-Tag右手的16个主动静态零位字段全部由本会话数据求解;四指DIP和
+`thumb_ip`为被动关节,发布实测动态曲线并保留CAD静态零位,其mimic坐标偏置只随
+上游主动关节坐标系变换作等价调整。
未观测关节和其他URDF文本保持不变。源URDF中的相对mesh资源会按原相对路径复制到
同一会话,保证会话内URDF可独立加载,并在正式发布时逐文件记录SHA256。每帧Tag SE(3)、
图像时间戳、20通道状态、同步误差和PnP误差只进入 `raw_samples.jsonl`。
diff --git a/src/g20_thumb_apriltag_calibration/config/g20_right_product.yaml b/src/g20_thumb_apriltag_calibration/config/g20_right_product.yaml
index c8753fe..07f3f5a 100644
--- a/src/g20_thumb_apriltag_calibration/config/g20_right_product.yaml
+++ b/src/g20_thumb_apriltag_calibration/config/g20_right_product.yaml
@@ -1,6 +1,7 @@
schema_version: 1
model: G20
side: right
+tag_layout: g20_right_19
serial_number: G20_RIGHT_001
can_interface: can0
output_root: calibration_output
@@ -23,9 +24,9 @@ artifacts:
source_urdf: src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right.urdf
source_urdf_sha256: eeb6ffb0e95d2a6acd4c26331ae68062e0d74160de4b552b4f6d395cce5ca4e8
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
- camera_extrinsics_sha256: aa0a1498a210ef36f20d59bd4fdc612a01fd09eb5a2bc1c0b8d84c05a44d5c5a
+ camera_extrinsics_sha256: 59ab7510ad0a2912ca636876039427c4c25906cbbfa84e96300d46b86e929334
calibration_config: src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
- calibration_config_sha256: d1a8be37c9ae21cb4f975ddd66c3cf93fed25dae98ddcf971259b395fa69b521
+ calibration_config_sha256: 8fa16814d7a3462a00aae4eadc273e66bc167bedd5908b0fed40ce6d505b2aeb
tag_config: src/g20_thumb_apriltag_calibration/config/three_camera_tags_g20_right_19.yaml
tag_config_sha256: b1ab45e97ae42d57b0a3a63c725107b5aa3828c06b2ced16f8222d6e9ebadc41
diff --git a/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml b/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
index 961a216..0aa2473 100644
--- a/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
+++ b/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
@@ -84,10 +84,12 @@ g20_calibration:
zero_maximum_axis_cone_mismatch_deg: 5.0
zero_maximum_observability_condition_number: 10000000000.0
zero_maximum_offset_deg: 20.0
- # 四指MCP roll保留严格的装配保护范围。MCP pitch/PIP静态零位由
- # 实测全屈曲行程与反馈0触掌的CAD机械端点联合求解,不允许写死为0。
+ # 四指MCP roll保留严格的装配保护范围。thumb CMC三轴由多轴视觉几何
+ # 求解且不假定电气端点等于CAD上限;thumb_mcp及四指MCP pitch/PIP
+ # 静态零位由实测全行程与CAD机械端点联合求解,不写死为0。
zero_finger_maximum_offset_deg: 3.0
- flexion_endpoint_maximum_offset_deg: 5.0
+ # 只对实物已确认等同CAD端点的关节使用该限制;CMC电气端点不作此假设。
+ mechanical_endpoint_maximum_offset_deg: 5.0
endpoint_tolerance_u8: 2.0
# 请求命令与固件反馈是两个标定域。稳态检查点允许小幅死区,但反馈
@@ -155,6 +157,11 @@ g20_calibration:
directional_zero_maximum_branch_gap_deg: 2.0
directional_zero_maximum_branch_gap_range_deg: 0.3
cross_view_roll_maximum_branch_gap_difference_deg: 0.3
+ # 正面roll是Tag中心的二维投影角,侧面roll是三维姿态角。允许一个有界的
+ # 固定比例吸收Tag安装倾角/偏置带来的投影缩放,再严格比较两条曲线形状;
+ # 比例过大、方向相反、形状RMS及两视角各自的四轮重复性仍会失败。
+ cross_view_roll_maximum_shape_rms_deg: 1.25
+ cross_view_roll_maximum_projection_scale_ratio: 1.5
passive_maximum_monotonic_correction_deg: 3.0
passive_maximum_hysteresis_deg: 2.0
# 九点姿态先按实测反馈重新对齐,再用此门限检查固有机械回差;请求命令域
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py
index 1524dfd..952da99 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py
@@ -17,6 +17,7 @@ import xml.etree.ElementTree as ET
import numpy as np
+from .core import COMMAND_NAMES
from .trajectory import (
_angle_for_circle,
_fit_circle_with_axis,
@@ -72,6 +73,24 @@ class SweepSpec:
return f"{self.view}:motor{self.motor_index}:{','.join(self.joints)}"
+@dataclass(frozen=True)
+class PalmAxisObserver:
+ """Non-blocking direction observation attached to an existing task.
+
+ This is deliberately not a ``JointSpec``: it has no curve, endpoint or
+ retry semantics. The camera callback records it only while both Tags are
+ visible during the named sweep.
+ """
+
+ source_name: str
+ task_name: str
+ view: str
+ parent_role: str
+ child_role: str
+ model_joint: str
+ motor_index: int
+
+
@dataclass(frozen=True)
class JointCurveFit:
angle_rad: tuple[float, ...]
@@ -98,6 +117,23 @@ class HandCalibrationProfile:
layout_id: str = "legacy_11"
measurement_specs: Mapping[str, JointSpec] | None = None
axis_validation_sources: Mapping[str, str] | None = None
+ palm_axis_observers: tuple[PalmAxisObserver, ...] = ()
+ minimum_palm_orientation_sources: int = 0
+ # Hardware and algorithm differences belong to the product profile, not
+ # to the acquisition state machine. Keeping these fields here lets a new
+ # model (or the mirrored hand) reuse the same recorder/fitter by supplying
+ # a different declarative profile.
+ model: str = "G20"
+ command_names: tuple[str, ...] = COMMAND_NAMES
+ baseline_command: tuple[int, ...] = THREE_CAMERA_BASELINE_COMMAND
+ capabilities: frozenset[str] = frozenset()
+
+ @property
+ def command_count(self) -> int:
+ return len(self.command_names)
+
+ def supports(self, capability: str) -> bool:
+ return str(capability) in self.capabilities
@property
def measured_joints(self) -> tuple[str, ...]:
@@ -115,6 +151,20 @@ class HandCalibrationProfile:
name for name, spec in self.record_specs.items() if spec.measured
)
+ @property
+ def palm_orientation_sources(self) -> Mapping[str, str]:
+ return {
+ observer.source_name: observer.model_joint
+ for observer in self.palm_axis_observers
+ }
+
+ @property
+ def palm_axis_motor_by_source(self) -> Mapping[str, int]:
+ return {
+ observer.source_name: int(observer.motor_index)
+ for observer in self.palm_axis_observers
+ }
+
@property
def active_joints(self) -> tuple[str, ...]:
return tuple(
@@ -612,6 +662,20 @@ def _build_right_19_profile() -> HandCalibrationProfile:
layout_id=G20_RIGHT_19_LAYOUT,
measurement_specs=measurement_specs,
axis_validation_sources=axis_validation_sources,
+ palm_axis_observers=(),
+ minimum_palm_orientation_sources=0,
+ capabilities=frozenset(
+ {
+ "precheck_sweeps",
+ "steady_command_checkpoints",
+ "directional_zero",
+ "isolated_holdout",
+ "cross_view_roll_curve",
+ "stable_cross_view_cone_bias",
+ "measured_passive_dips",
+ "urdf_zero_publication",
+ }
+ ),
)
@@ -1435,18 +1499,86 @@ def compare_cross_view_roll_curves(
*,
maximum_rms_difference_rad: float = math.radians(1.0),
maximum_branch_gap_difference_rad: float = math.radians(0.3),
+ allow_projection_scale: bool = False,
+ maximum_projection_scale_ratio: float = 1.5,
) -> dict[str, float]:
- """Validate two independently fitted roll curves before axis fusion."""
+ """Validate two independently fitted roll curves before axis fusion.
+
+ The G20 right roll primary is an image-plane centre trajectory while its
+ side validation is a 3-D rotation trajectory. A fixed Tag mounting tilt
+ changes the image-plane angular scale without changing the mechanism. In
+ projection-scale mode, remove that one constant scale before comparing
+ curve shape. Direction, scale bounds, direction-dependent branch gap and
+ every single-view quality/holdout gate remain independent hard checks.
+ """
if maximum_rms_difference_rad <= 0.0:
raise ValueError("cross-view RMS limit must be positive")
if maximum_branch_gap_difference_rad <= 0.0:
raise ValueError("cross-view branch-gap limit must be positive")
- metrics: dict[str, float] = {}
+ if maximum_projection_scale_ratio <= 1.0:
+ raise ValueError("cross-view projection-scale ratio must exceed one")
+
+ fields: dict[str, tuple[np.ndarray, np.ndarray]] = {}
for field_name in ("angle_rad", "decreasing_rad", "increasing_rad"):
left = np.asarray(getattr(primary, field_name), dtype=float)
right = np.asarray(getattr(secondary, field_name), dtype=float)
if left.shape != (256,) or right.shape != (256,):
raise ValueError("cross-view curves must contain 256 values")
+ if not np.all(np.isfinite(left)) or not np.all(np.isfinite(right)):
+ raise ValueError("cross-view curves must be finite")
+ fields[field_name] = (left, right)
+
+ primary_travel = float(
+ fields["angle_rad"][0][0] - fields["angle_rad"][0][255]
+ )
+ secondary_travel = float(
+ fields["angle_rad"][1][0] - fields["angle_rad"][1][255]
+ )
+ if primary_travel * secondary_travel <= 0.0:
+ raise ValueError("cross_view_roll_curve_direction_disagrees")
+
+ primary_zero = int(primary.circle.get("zero_command_u8", 127))
+ secondary_zero = int(secondary.circle.get("zero_command_u8", 127))
+ if not 0 <= primary_zero <= 255 or not 0 <= secondary_zero <= 255:
+ raise ValueError("cross-view zero command must be in [0, 255]")
+ primary_anchor = float(primary.decreasing_rad[primary_zero])
+ secondary_anchor = float(secondary.decreasing_rad[secondary_zero])
+ projection_scale = 1.0
+ if allow_projection_scale:
+ primary_values = np.concatenate(
+ [left - primary_anchor for left, _ in fields.values()]
+ )
+ secondary_values = np.concatenate(
+ [right - secondary_anchor for _, right in fields.values()]
+ )
+ denominator = float(secondary_values @ secondary_values)
+ if denominator <= np.finfo(float).eps:
+ raise ValueError("cross_view_roll_projection_scale_unobservable")
+ projection_scale = float(
+ (primary_values @ secondary_values) / denominator
+ )
+ if projection_scale <= 0.0 or not math.isfinite(projection_scale):
+ raise ValueError("cross_view_roll_curve_direction_disagrees")
+ scale_ratio = max(projection_scale, 1.0 / projection_scale)
+ if scale_ratio > float(maximum_projection_scale_ratio):
+ raise ValueError(
+ "cross_view_roll_projection_scale_ratio_too_large:"
+ f"{scale_ratio:.6f}"
+ )
+
+ metrics: dict[str, float] = {
+ "projection_scale": projection_scale,
+ "projection_scale_ratio": max(
+ projection_scale, 1.0 / projection_scale
+ ),
+ }
+ for field_name, (left, right) in fields.items():
+ metrics[f"raw_{field_name}_rms_difference_rad"] = float(
+ np.sqrt(np.mean(np.square(left - right)))
+ )
+ if allow_projection_scale:
+ left = left - primary_anchor
+ right = projection_scale * (right - secondary_anchor)
rms = float(np.sqrt(np.mean(np.square(left - right))))
metrics[f"{field_name}_rms_difference_rad"] = rms
if rms > maximum_rms_difference_rad:
@@ -1454,13 +1586,7 @@ def compare_cross_view_roll_curves(
"cross_view_roll_curve_difference_too_large:"
f"{field_name}:{math.degrees(rms):.6f}deg"
)
- primary_travel = float(primary.angle_rad[0] - primary.angle_rad[255])
- secondary_travel = float(secondary.angle_rad[0] - secondary.angle_rad[255])
- if primary_travel * secondary_travel <= 0.0:
- raise ValueError("cross_view_roll_curve_direction_disagrees")
metrics["travel_difference_rad"] = abs(primary_travel - secondary_travel)
- primary_zero = int(primary.circle.get("zero_command_u8", 127))
- secondary_zero = int(secondary.circle.get("zero_command_u8", 127))
primary_gap = float(
primary.increasing_rad[primary_zero]
- primary.decreasing_rad[primary_zero]
@@ -1469,9 +1595,27 @@ def compare_cross_view_roll_curves(
secondary.increasing_rad[secondary_zero]
- secondary.decreasing_rad[secondary_zero]
)
- branch_gap_difference = abs(primary_gap - secondary_gap)
+ metrics["raw_baseline_branch_gap_difference_rad"] = abs(
+ primary_gap - secondary_gap
+ )
+ branch_gap_difference = abs(
+ primary_gap - projection_scale * secondary_gap
+ )
metrics["baseline_branch_gap_difference_rad"] = branch_gap_difference
- if branch_gap_difference > maximum_branch_gap_difference_rad:
+ # In projection-scale mode the primary is an image-circle phase while the
+ # secondary is a 3-D rotation angle. A fixed Tag tilt/offset changes the
+ # image projection of the two motion branches independently, so their
+ # absolute gap is not a cross-view invariant. Both views retain their
+ # own hard absolute-gap and four-cycle gap-range checks in the acquisition
+ # quality gate. Keep this value as a diagnostic, but apply the cross-view
+ # absolute-gap gate only when both curves live in comparable coordinates.
+ metrics["baseline_branch_gap_gate_applied"] = float(
+ not allow_projection_scale
+ )
+ if (
+ not allow_projection_scale
+ and branch_gap_difference > maximum_branch_gap_difference_rad
+ ):
raise ValueError(
"cross_view_roll_branch_gap_difference_too_large:"
f"{math.degrees(branch_gap_difference):.6f}deg"
@@ -1479,6 +1623,35 @@ def compare_cross_view_roll_curves(
return metrics
+def cross_view_roll_diagnostic_metrics(
+ primary: JointCurveFit,
+ secondary: JointCurveFit,
+) -> dict[str, float]:
+ """Compare a validation-only roll view without creating a retry gate.
+
+ G20-right publishes the front image-plane roll curve. The side PIP-link
+ observation supplies an independently quality-gated physical axis-line
+ position, but its angle includes projection and linkage effects and is
+ therefore diagnostic rather than a second measurement of the published
+ curve. Live fitting and offline replay must use the same non-blocking
+ comparison; otherwise a stable view bias can recall an already complete
+ task even though reacquiring the same motion cannot change the result.
+ """
+ try:
+ return compare_cross_view_roll_curves(
+ primary,
+ secondary,
+ maximum_rms_difference_rad=float("inf"),
+ maximum_branch_gap_difference_rad=float("inf"),
+ allow_projection_scale=True,
+ maximum_projection_scale_ratio=float("inf"),
+ )
+ except ValueError as error:
+ if str(error) == "cross_view_roll_curve_direction_disagrees":
+ return {"direction_disagrees": 1.0}
+ raise
+
+
def fit_projected_zero(
records: Sequence[Mapping[str, Any]],
*,
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/offline_replay.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/offline_replay.py
index 7e01f7d..9b8eca2 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/offline_replay.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/offline_replay.py
@@ -30,25 +30,34 @@ from .full_hand import (
build_compact_payload,
canonical_zero_direction,
clamp_runtime_fits_to_urdf_limits,
- compare_cross_view_roll_curves,
+ cross_view_roll_diagnostic_metrics,
derive_mimic_passive_fits,
fit_joint_image_curve,
get_hand_calibration_profile,
validate_compact_payload,
)
+from .product import get_product_calibration_contract
from .storage import atomic_write_json
from .urdf_zero import (
JointAxisMeasurement,
UrdfKinematicModel,
_angles_from_state,
+ anchor_right_19_mechanical_endpoint_curves,
+ axis_line_uses_depth_free_interpretation_plane,
+ axis_line_cycle_rms_m,
baseline_hysteresis_by_cycle_rad,
circle_direction_is_constrained,
+ cross_view_side_line_source,
fit_joint_axis_measurement,
+ fit_partial_palm_orientation_measurements,
fit_rotation_joint_curve,
get_zero_calibration_profile,
- derive_right_19_flexion_endpoint_offsets,
+ derive_right_19_mechanical_endpoint_offsets,
joint_curve_holdout_errors,
+ refit_axis_line_group_with_shared_radius,
+ select_cross_view_roll_direction_source,
solve_urdf_zero_offsets,
+ with_depth_free_axis_projection,
write_zero_corrected_urdf,
)
@@ -139,6 +148,35 @@ def _latest_attempt_records(
return dict(records)
+def _latest_palm_axis_records(
+ rows: Sequence[Mapping[str, Any]],
+) -> dict[str, list[dict[str, Any]]]:
+ """Select the latest accepted append-only palm side-channel attempts."""
+ samples = [
+ dict(row) for row in rows if row.get("kind") == "palm_axis_sample"
+ ]
+ latest_attempt: dict[tuple[str, int, str], int] = {}
+ for row in samples:
+ key = (
+ str(row["source_joint"]),
+ int(row["cycle"]),
+ str(row["direction"]),
+ )
+ latest_attempt[key] = max(
+ latest_attempt.get(key, 0), int(row.get("attempt", 1))
+ )
+ records: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in samples:
+ key = (
+ str(row["source_joint"]),
+ int(row["cycle"]),
+ str(row["direction"]),
+ )
+ if int(row.get("attempt", 1)) == latest_attempt[key]:
+ records[key[0]].append(row)
+ return dict(records)
+
+
def _load_raw_session(
session_dir: Path,
) -> tuple[
@@ -146,6 +184,7 @@ def _load_raw_session(
dict[str, list[dict[str, Any]]],
dict[str, list[dict[str, Any]]],
dict[str, list[dict[str, Any]]],
+ dict[str, list[dict[str, Any]]],
Path,
]:
raw_path = session_dir / "raw_samples.jsonl"
@@ -163,7 +202,9 @@ def _load_raw_session(
for row in rows:
if "command_u8" in row:
continue
- if row.get("kind") in {"sample", "baseline_hold_sample"}:
+ if row.get("kind") in {
+ "sample", "baseline_hold_sample", "palm_axis_sample"
+ }:
row["command_u8"] = int(round(float(row["feedback_u8"])))
elif row.get("kind") == "steady_command_sample":
row["command_u8"] = int(row["requested_command_u8"])
@@ -175,6 +216,7 @@ def _load_raw_session(
_latest_attempt_records(rows),
_latest_attempt_records(rows, kind="baseline_hold_sample"),
_latest_attempt_records(rows, kind="steady_command_sample"),
+ _latest_palm_axis_records(rows),
raw_path,
)
@@ -189,7 +231,13 @@ def _fit_curve(
motor = profile.record_specs[name].motor_index
if (
profile.layout_id == G20_RIGHT_19_LAYOUT
- and name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
+ and (
+ name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
+ or (
+ name.endswith("_mcp_roll")
+ and not name.startswith("thumb_")
+ )
+ )
):
return fit_joint_image_curve(records)
return fit_rotation_joint_curve(
@@ -234,6 +282,7 @@ def _fit_axes(
)
extrinsics = load_three_camera_extrinsics(extrinsics_file)
cache: dict[tuple[str, int], JointAxisMeasurement] = {}
+ cross_view_direction_source_cache: dict[str, str] = {}
upstream_by_joint = {
"thumb_mcp": "thumb_cmc_pitch",
"thumb_ip": "thumb_mcp",
@@ -246,6 +295,15 @@ def _fit_axes(
for finger in ("index", "middle", "ring", "pinky")
},
}
+ if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ reference_roll = f"{profile.reference_finger}_mcp_roll"
+ upstream_by_joint.update(
+ {
+ f"{finger}_mcp_roll": reference_roll
+ for finger in ("index", "middle", "ring", "pinky")
+ if f"{finger}_mcp_roll" != reference_roll
+ }
+ )
def fit_raw(name: str, cycle: int) -> JointAxisMeasurement:
key = (name, cycle)
@@ -288,6 +346,9 @@ def _fit_axes(
),
view_normal_common_xyz=tuple(float(value) for value in view_normal),
)
+ result = with_depth_free_axis_projection(
+ result, extrinsics.transform(spec.view)[:3, 3]
+ )
cache[key] = result
return result
@@ -311,11 +372,52 @@ def _fit_axes(
np.cross(secondary_point - primary_point, primary_axis)
)
)
+ if (
+ profile.record_specs[validation_name].zero_kind
+ == "axis_cross_view_validation"
+ ):
+ point_view = profile.record_specs[name].view
+ if point_view is None:
+ raise ValueError(
+ f"{name}: primary point source has no view"
+ )
+ point_camera_center = extrinsics.transform(point_view)[:3, 3]
+ point_ray = primary_point - point_camera_center
+ interpretation_plane_normal = np.cross(
+ point_ray, primary_axis
+ )
+ interpretation_plane_normal /= np.linalg.norm(
+ interpretation_plane_normal
+ )
+ return replace(
+ primary,
+ point_common_xyz_m=tuple(
+ float(value)
+ for value in primary.point_common_xyz_m
+ ),
+ pose_axis_line_rms_m=primary.pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(name,),
+ axis_point_source=(
+ "front_interpretation_plane_cross_view_validated"
+ ),
+ axis_point_camera_center_common_xyz_m=tuple(
+ float(value)
+ for value in point_camera_center
+ ),
+ axis_point_interpretation_plane_normal_common_xyz=tuple(
+ float(value)
+ for value in interpretation_plane_normal
+ ),
+ )
# Mirror the live node's tiered policy: the side-view planar-tag
# IPPE bias makes sub-degree cross-view agreement unreachable, so
# disagreement above the fusion gate keeps the front-only axis while
- # only the gross bound (wrong link / loose tag) still fails.
- if difference > math.radians(15.0) or line_distance > 0.030:
+ # only a gross direction error (wrong link / loose tag) still fails.
+ # The front screw-driven roll trajectory yields a displaced
+ # pseudo-line, so its distance to the side physical line is retained
+ # only as a diagnostic. The live node likewise keeps only the gross
+ # direction gate here and validates side-line quality independently.
+ if difference > math.radians(15.0):
raise ValueError(f"{name}: cross-view axis gross disagreement")
if difference > math.radians(0.75) or line_distance > 0.001:
selected_direction = primary_axis
@@ -329,7 +431,6 @@ def _fit_axes(
None,
)
if observer_name is not None:
- observer = fit_raw(observer_name, cycle)
model = UrdfKinematicModel(source_urdf)
parent_axis, _ = model.axis_line(
name, zero_offsets={}, joint_angles={}
@@ -340,11 +441,9 @@ def _fit_axes(
expected_cone = math.acos(
abs(float(np.clip(parent_axis @ observer_axis, -1.0, 1.0)))
)
- measured_observer = np.asarray(
- observer.axis_common_xyz, dtype=float
- )
-
- def cone_residual(candidate: np.ndarray) -> float:
+ def cone_residual(
+ candidate: np.ndarray, measured_observer: np.ndarray
+ ) -> float:
measured_cone = math.acos(
abs(
float(
@@ -356,10 +455,46 @@ def _fit_axes(
)
return abs(measured_cone - expected_cone)
- if (
- cone_residual(primary_axis) > math.radians(5.0)
- and cone_residual(secondary_axis) <= math.radians(5.0)
- ):
+ if name not in cross_view_direction_source_cache:
+ primary_residuals: list[float] = []
+ secondary_residuals: list[float] = []
+ for group_cycle in range(repetitions):
+ group_observer = np.asarray(
+ fit_raw(
+ observer_name, group_cycle
+ ).axis_common_xyz,
+ dtype=float,
+ )
+ primary_residuals.append(
+ cone_residual(
+ np.asarray(
+ fit_raw(
+ name, group_cycle
+ ).axis_common_xyz,
+ dtype=float,
+ ),
+ group_observer,
+ )
+ )
+ secondary_residuals.append(
+ cone_residual(
+ np.asarray(
+ fit_raw(
+ validation_name, group_cycle
+ ).axis_common_xyz,
+ dtype=float,
+ ),
+ group_observer,
+ )
+ )
+ cross_view_direction_source_cache[name] = (
+ select_cross_view_roll_direction_source(
+ primary_residuals,
+ secondary_residuals,
+ math.radians(5.0),
+ )
+ )
+ if cross_view_direction_source_cache[name] == "secondary":
selected_direction = secondary_axis
selected_source = "cross_view_cone_selected_secondary"
# Mirror the live node: use the view whose direction satisfies
@@ -375,6 +510,7 @@ def _fit_axes(
for value in secondary.point_common_xyz_m
),
pose_axis_line_rms_m=secondary.pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(validation_name,),
axis_direction_source=selected_source,
axis_point_source="side_circle_cross_view",
)
@@ -406,14 +542,45 @@ def _fit_axes(
secondary.pose_axis_line_rms_m,
line_distance,
),
+ pose_axis_line_source_joints=(name, validation_name),
axis_direction_source="cross_view_weighted_fusion",
)
- return [
- fit_one(name, cycle)
- for name in zero_profile.axis_joints
- for cycle in range(repetitions)
- ]
+ result: list[JointAxisMeasurement] = []
+ for name in zero_profile.axis_joints:
+ group = [fit_one(name, cycle) for cycle in range(repetitions)]
+ side_sources = {
+ source
+ for measurement in group
+ if (source := cross_view_side_line_source(measurement)) is not None
+ }
+ if (
+ len(side_sources) == 1
+ and all(
+ cross_view_side_line_source(measurement) is not None
+ for measurement in group
+ )
+ and not any(
+ axis_line_uses_depth_free_interpretation_plane(measurement)
+ for measurement in group
+ )
+ ):
+ source = next(iter(side_sources))
+ source_spec = profile.record_specs[source]
+ group = list(
+ refit_axis_line_group_with_shared_radius(
+ group,
+ records_by_joint[source],
+ zero_command_u8=int(
+ baseline[source_spec.motor_index]
+ ),
+ canonical_zero_direction=canonical_zero_direction(
+ profile, source
+ ),
+ )
+ )
+ result.extend(group)
+ return result
def _maximum_undirected_axis_difference(axes: Sequence[Sequence[float]]) -> float:
@@ -601,6 +768,7 @@ def _quality_failures(
if (
max(baseline_hysteresis) - min(baseline_hysteresis)
> gap_range_limit
+ and spec.zero_kind != "axis_cross_view_validation"
):
failures.append(
f"{name}: baseline directional gap repeatability"
@@ -613,6 +781,7 @@ def _quality_failures(
cycle_travels: list[float] = []
cycle_axes: list[Sequence[float]] = []
cycle_axis_sources: list[str] = []
+ cycle_axis_measurements: list[JointAxisMeasurement] = []
for cycle in range(repetitions):
cycle_fit = _fit_curve(
name,
@@ -626,11 +795,13 @@ def _quality_failures(
axis = axis_by_key[(name, cycle)]
cycle_axes.append(axis.axis_common_xyz)
cycle_axis_sources.append(axis.axis_direction_source)
+ cycle_axis_measurements.append(axis)
if axis.radial_rms_m > float(parameters["axis_maximum_radial_rms_m"]):
failures.append(f"{name} cycle {cycle + 1}: radial RMS")
if (
profile.record_specs[name].zero_kind
!= "axis_cross_view_validation"
+ and cross_view_side_line_source(axis) is None
and axis.pose_axis_line_rms_m
> float(parameters["axis_maximum_pose_line_rms_m"])
):
@@ -655,6 +826,29 @@ def _quality_failures(
)
):
failures.append(f"{name} cycle {cycle + 1}: axis disagreement")
+ side_line_sources = {
+ source
+ for measurement in cycle_axis_measurements
+ if (
+ source := cross_view_side_line_source(measurement)
+ ) is not None
+ }
+ if (
+ len(cycle_axis_measurements) == repetitions
+ and len(side_line_sources) == 1
+ and all(
+ cross_view_side_line_source(measurement) is not None
+ for measurement in cycle_axis_measurements
+ )
+ and not any(
+ axis_line_uses_depth_free_interpretation_plane(measurement)
+ for measurement in cycle_axis_measurements
+ )
+ and axis_line_cycle_rms_m(cycle_axis_measurements)
+ > float(parameters["axis_maximum_pose_line_rms_m"])
+ ):
+ source = next(iter(side_line_sources))
+ failures.append(f"{source}: axis-line cycle RMS")
travel_limit = math.radians(
float(
parameters[
@@ -671,6 +865,7 @@ def _quality_failures(
source == "upstream_constraint"
for source in cycle_axis_sources
)
+ and spec.zero_kind != "axis_cross_view_validation"
and _maximum_undirected_axis_difference(cycle_axes) > math.radians(
float(parameters["zero_maximum_axis_cycle_difference_deg"])
)
@@ -832,21 +1027,28 @@ def replay_session(
records_by_joint,
baseline_records_by_joint,
command_records_by_joint,
+ palm_axis_records_by_source,
raw_path,
) = _load_raw_session(session)
+ model = str(start.get("model", "G20")).strip().upper()
side = str(start["hand_type"]).lower()
requested_layout_id = str(
start.get("tag_layout", "legacy_11")
).lower()
- profile = get_hand_calibration_profile(side, requested_layout_id)
+ contract = get_product_calibration_contract(
+ model, side, requested_layout_id
+ )
+ profile = contract.profile
# Normalise the former g20_right_15 compatibility alias immediately so
# every downstream product guard uses the actual physical 19-Tag profile
# instead of silently falling through legacy-11 logic.
layout_id = profile.layout_id
- zero_profile = get_zero_calibration_profile(side, layout_id)
+ zero_profile = contract.zero_profile
baseline = tuple(int(value) for value in start["baseline_command_u8"])
- if len(baseline) != 20:
- raise ValueError("session baseline must contain exactly 20 commands")
+ if len(baseline) != profile.command_count:
+ raise ValueError(
+ "session baseline length differs from the registered product schema"
+ )
if (
layout_id == G20_RIGHT_19_LAYOUT
and write_outputs
@@ -878,8 +1080,14 @@ def replay_session(
for row in combinations
):
raise ValueError("combination pose validation exceeds product limits")
- if set(records_by_joint) != set(profile.record_joints):
- raise ValueError("raw session does not contain exactly the task record set")
+ required_record_joints = set(profile.record_joints)
+ if (
+ not required_record_joints.issubset(records_by_joint)
+ or not set(records_by_joint).issubset(set(profile.record_joints))
+ ):
+ raise ValueError(
+ "raw session does not contain the required task record set"
+ )
def hysteresis_records(
name: str,
@@ -904,7 +1112,9 @@ def replay_session(
name: str, *, require_full_pose: bool = False
) -> list[dict[str, Any]]:
"""Merge settled baseline holds into directional model records."""
- records = [dict(record) for record in records_by_joint[name]]
+ records = [
+ dict(record) for record in records_by_joint.get(name, ())
+ ]
if canonical_zero_direction(profile, name) is None:
return records
required = {
@@ -971,7 +1181,7 @@ def replay_session(
}
command_fits = dict(training_fits)
if layout_id == G20_RIGHT_19_LAYOUT:
- if set(command_records_by_joint) != set(profile.record_joints):
+ if set(command_records_by_joint) != required_record_joints:
raise ValueError(
"g20_right_19 session is incomplete: missing steady command checkpoints"
)
@@ -1035,6 +1245,11 @@ def replay_session(
command_fits,
profile=profile,
)
+ command_fits = anchor_right_19_mechanical_endpoint_curves(
+ command_fits,
+ command_records_by_joint,
+ maximum_direction_difference_rad=command_gap_limit,
+ )
cross_view_roll_metrics: dict[str, dict[str, float]] = {}
for name, validation_name in (
profile.axis_validation_sources or {}
@@ -1055,35 +1270,11 @@ def replay_session(
profile=profile,
baseline=baseline,
)
- training_metrics = compare_cross_view_roll_curves(
- training_fits[name],
- validation_training,
- maximum_rms_difference_rad=math.radians(
- float(parameters["maximum_validation_mae_deg"])
- ),
- maximum_branch_gap_difference_rad=math.radians(
- float(
- parameters.get(
- "cross_view_roll_maximum_branch_gap_difference_deg",
- 0.3,
- )
- )
- ),
+ training_metrics = cross_view_roll_diagnostic_metrics(
+ training_fits[name], validation_training
)
- final_metrics = compare_cross_view_roll_curves(
- measured_fits[name],
- validation_all,
- maximum_rms_difference_rad=math.radians(
- float(parameters["maximum_validation_mae_deg"])
- ),
- maximum_branch_gap_difference_rad=math.radians(
- float(
- parameters.get(
- "cross_view_roll_maximum_branch_gap_difference_deg",
- 0.3,
- )
- )
- ),
+ final_metrics = cross_view_roll_diagnostic_metrics(
+ measured_fits[name], validation_all
)
cross_view_roll_metrics[name] = {
**{
@@ -1103,6 +1294,28 @@ def replay_session(
source_urdf=source_urdf,
repetitions=repetitions,
)
+ palm_orientation_measurements, palm_orientation_rejections = (
+ fit_partial_palm_orientation_measurements(
+ sources=profile.palm_orientation_sources,
+ records_by_joint=palm_axis_records_by_source,
+ motor_by_source=profile.palm_axis_motor_by_source,
+ baseline_command_u8=baseline,
+ cycles=range(repetitions),
+ minimum_sources=int(
+ profile.minimum_palm_orientation_sources
+ ),
+ minimum_arc_rad=math.radians(
+ float(parameters["trajectory_minimum_arc_deg"])
+ ),
+ maximum_rotation_orthogonal_rms_rad=math.radians(
+ float(
+ parameters[
+ "active_maximum_rotation_orthogonal_rms_deg"
+ ]
+ )
+ ),
+ )
+ )
failures = _quality_failures(
records_by_joint=records_by_joint,
baseline_records_by_joint=(
@@ -1223,11 +1436,15 @@ def replay_session(
name: int(spec.motor_index) for name, spec in profile.joint_specs.items()
}
endpoint_zero_offsets = (
- derive_right_19_flexion_endpoint_offsets(
+ derive_right_19_mechanical_endpoint_offsets(
source_urdf,
command_fits,
maximum_offset_rad=math.radians(
- float(parameters.get("flexion_endpoint_maximum_offset_deg", 5.0))
+ float(
+ parameters.get(
+ "mechanical_endpoint_maximum_offset_deg", 5.0
+ )
+ )
),
)
if layout_id == G20_RIGHT_19_LAYOUT
@@ -1242,6 +1459,7 @@ def replay_session(
solve_arguments = {
"source_urdf": source_urdf,
"measurements": axes,
+ "palm_orientation_measurements": palm_orientation_measurements,
"motor_by_joint": motor_by_joint,
"maximum_offset_rad": math.radians(float(parameters["zero_maximum_offset_deg"])),
"finger_maximum_offset_rad": math.radians(
@@ -1254,6 +1472,13 @@ def replay_session(
"maximum_axis_cone_mismatch_rad": math.radians(
float(parameters["zero_maximum_axis_cone_mismatch_deg"])
),
+ "maximum_systematic_axis_cone_bias_rad": math.radians(
+ float(
+ parameters.get(
+ "cross_view_roll_maximum_axis_difference_deg", 15.0
+ )
+ )
+ ),
"maximum_observability_condition_number": float(
parameters.get(
"zero_maximum_observability_condition_number", 1.0e10
@@ -1469,6 +1694,21 @@ def replay_session(
"offset_covariance_rad2": (
final_zero.offset_covariance_rad2
),
+ "palm_orientation_sources": [
+ {
+ "source_joint": item.source_joint,
+ "model_joint": item.model_joint,
+ "cycle": item.cycle,
+ "observed_arc_rad": item.observed_arc_rad,
+ "rotation_orthogonal_rms_rad": (
+ item.rotation_orthogonal_rms_rad
+ ),
+ }
+ for item in palm_orientation_measurements
+ ],
+ "palm_orientation_rejections": dict(
+ palm_orientation_rejections
+ ),
}
if layout_id == G20_RIGHT_19_LAYOUT
else None
@@ -1508,7 +1748,7 @@ def replay_session(
"thumb_default": float(parameters["zero_maximum_offset_deg"]),
},
"static_zero_policy": (
- "visual_axes_plus_measured_contact_endpoint"
+ "visual_axes_plus_measured_mechanical_endpoint"
if layout_id == G20_RIGHT_19_LAYOUT
else "direct_measurements_only"
),
@@ -1538,6 +1778,15 @@ def replay_session(
name: [math.degrees(value) for value in values]
for name, values in holdout_zero.cycle_offsets_rad.items()
},
+ "axis_cone_mismatch_by_joint_deg": {
+ name: math.degrees(value)
+ for name, value in (
+ holdout_zero.axis_cone_mismatch_by_joint_rad.items()
+ )
+ },
+ "axis_cone_bias_classification_by_joint": dict(
+ holdout_zero.axis_cone_bias_classification_by_joint
+ ),
"offset_uncertainty_deg": {
name: math.degrees(value)
for name, value in holdout_zero.offset_uncertainty_rad.items()
@@ -1577,7 +1826,7 @@ def replay_session(
def main() -> None:
parser = argparse.ArgumentParser(
- description="Replay and independently validate a complete G20 calibration session."
+ description="Replay and validate a registered hand-calibration session."
)
parser.add_argument("session_dir")
parser.add_argument("--serial-number", default=None)
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/one_command.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/one_command.py
index 2925ffb..a9fcc4d 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/one_command.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/one_command.py
@@ -1,4 +1,4 @@
-"""One-command product runner for G20_RIGHT_001 calibration."""
+"""One-command runner for a registered hand-calibration product."""
from __future__ import annotations
@@ -155,8 +155,9 @@ def _launch_command(
resume_from: Path | None = None,
) -> list[str]:
values = {
- "hand_type": "right",
- "tag_layout": "g20_right_19",
+ "model": config.model,
+ "hand_type": config.side,
+ "tag_layout": config.tag_layout,
"serial_number": config.serial_number,
"can_interface": config.can_interface,
"session_dir": str(session),
@@ -462,8 +463,8 @@ def _automatic_resume_candidate(config: ProductConfig) -> Path | None:
continue
if (
start is None
- or start.get("hand_type") != "right"
- or start.get("tag_layout") != "g20_right_19"
+ or start.get("hand_type") != config.side
+ or start.get("tag_layout") != config.tag_layout
or start.get("source_urdf_sha256")
!= config.source_urdf_sha256
):
@@ -550,9 +551,9 @@ def run(
print(
"\n".join(
[
- "PASS:G20右手标定、URDF修正和独立复验全部通过。",
+ f"PASS:{config.model} {config.side} 标定、URDF修正和复验全部通过。",
f"正式结果:{config.session_root / 'latest_passed'}",
- f"JSON:{session / f'g20_right_{config.serial_number}_calibration.json'}",
+ f"JSON:{session / summary['artifacts']['json']}",
f"URDF:{summary['artifacts']['urdf']}",
]
),
@@ -564,7 +565,7 @@ def run(
def main(args: list[str] | None = None) -> None:
- parser = argparse.ArgumentParser(description="G20右手一键精密标定")
+ parser = argparse.ArgumentParser(description="配置驱动的机械手精密标定")
parser.add_argument("--config", default=str(_default_product_config()))
parser.add_argument("--workspace", default=None)
parser.add_argument("--preflight-only", action="store_true")
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/operator_report.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/operator_report.py
index 2f7960b..df512ba 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/operator_report.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/operator_report.py
@@ -55,6 +55,43 @@ def _task_tag_id_status(views: Mapping[str, Any]) -> str:
)
+def _pnp_wait_status(views: Mapping[str, Any]) -> str:
+ """Explain why visible Tags have not produced a usable 3-D pose."""
+ labels = {"front": "正面", "side": "侧面", "top": "顶部"}
+ details: list[str] = []
+ for name in ("front", "side", "top"):
+ item = views.get(name, {})
+ if not isinstance(item, Mapping) or item.get("pnp_pose_valid"):
+ continue
+ parts: list[str] = []
+ progress = item.get("pnp_initialization_progress")
+ if isinstance(progress, Mapping):
+ parts.append(
+ "初始化"
+ f"{int(progress.get('accepted', 0))}/"
+ f"{int(progress.get('required', 0))}"
+ )
+ counts: dict[str, int] = {}
+ for field in ("pnp_rejection_counts", "group_pnp_rejection_counts"):
+ values = item.get(field, {})
+ if not isinstance(values, Mapping):
+ continue
+ for reason, count in values.items():
+ counts[str(reason)] = counts.get(str(reason), 0) + int(count)
+ if counts:
+ common = sorted(
+ counts.items(), key=lambda pair: (-pair[1], pair[0])
+ )[:2]
+ parts.append(
+ "拒绝=" + ",".join(f"{reason}×{count}" for reason, count in common)
+ )
+ if parts:
+ details.append(f"{labels[name]} " + ";".join(parts))
+ if not details:
+ return ""
+ return "PnP:" + " | ".join(details) + "(Tag可见不等于三维位姿有效)"
+
+
@dataclass
class ProgressEstimator:
started_at: float
@@ -202,7 +239,7 @@ def render_progress_zh(
)
else:
tag_status = (
- f"{visible_tags}/{required_tags} 有效"
+ f"{visible_tags}/{required_tags} 可见"
+ (f"(含锁定 {locked_tag_count})" if locked_tag_count else "")
)
task_tag_ids = (
@@ -233,10 +270,23 @@ def render_progress_zh(
f"Tag:{tag_status}{task_tag_ids} 相机:{ready_cameras}/3 正常 反馈:{feedback_hz:.1f} Hz",
f"质量:有效帧 {active.get('valid_frames', 0)} 已自动重扫 {retry} 次",
]
- if fit_attempt_limit > 1:
+ if str(status.get("reason", "")) == "waiting_for_task_tags_at_sweep_start":
+ pnp_wait = _pnp_wait_status(views)
+ if pnp_wait:
+ lines.append(pnp_wait)
+ fit_retry_cycles = [
+ int(cycle) for cycle in active.get("fit_retry_cycles", [])
+ ]
+ if fit_attempt > 1 and fit_retry_cycles:
+ cycle_text = "/".join(str(cycle) for cycle in fit_retry_cycles)
lines.append(
- f"拟合:整关节第 {fit_attempt}/{fit_attempt_limit} 次尝试;"
- "每次包含完整四轮双向扫描"
+ f"拟合:首次拟合定位第 {cycle_text} 轮异常,"
+ "仅补采该轮双向;其余轮次数据保留"
+ )
+ elif fit_attempt_limit > 1:
+ lines.append(
+ f"拟合:任务级硬门限检查(最多允许 "
+ f"{fit_attempt_limit - 1} 次异常轮补采)"
)
resume = status.get("resume", {})
if isinstance(resume, Mapping) and resume.get("used"):
@@ -266,6 +316,25 @@ def classify_error(reason: str, status: Mapping[str, Any]) -> tuple[str, str, st
for item in views.values()
if isinstance(item, Mapping) and item.get("group_pnp_reason")
] if isinstance(views, Mapping) else []
+ pnp_diagnostics_present = bool(
+ group_pnp_reasons
+ or (
+ isinstance(views, Mapping)
+ and any(
+ isinstance(item, Mapping)
+ and any(
+ item.get(field)
+ for field in (
+ "pnp_rejection_counts",
+ "group_pnp_rejection_counts",
+ "pnp_initialization_progress",
+ "group_missing_candidate_roles",
+ )
+ )
+ for item in views.values()
+ )
+ )
+ )
if value.startswith("CFG-"):
return value, "产品配置或文件预检失败", "不要移动相机;复制本诊断块给开发者。"
if value.startswith("CAM-STATUS-202"):
@@ -280,6 +349,13 @@ def classify_error(reason: str, status: Mapping[str, Any]) -> tuple[str, str, st
"标定节点状态心跳停止更新",
"已采集的完整任务会保留;检查calibration.log后从断点继续,不要重新采集。",
)
+ if "palm_orientation_quality_failed" in value:
+ return (
+ "VAL-QUALITY-501",
+ "掌部方向校正的可观测轨迹不足",
+ "保持Tag安装不变;调整标定前手位,使正面Tag 10–13至少三枚"
+ "在对应运动起始段可见。",
+ )
if "motor_state_stalled" in value:
return "MOTION-STALL-301", "电机反馈停止向目标推进,程序已保持当前位置", "先检查机械卡阻,未排除前不要重复强推。"
if any(
@@ -295,9 +371,19 @@ def classify_error(reason: str, status: Mapping[str, Any]) -> tuple[str, str, st
if (
("synchronised" in value or "sweep_start_tag_timeout" in value)
and not missing
- and group_pnp_reasons
+ and pnp_diagnostics_present
):
- return "CAM-GEOMETRY-201", "Tag可见,但整组PnP候选持续被几何检查拒绝", "不要调整Tag;复制本诊断块给开发者检查PnP候选选择。"
+ problem = (
+ "Tag可见,但三维PnP轨迹在运动中失效"
+ if value.startswith("synchronised")
+ and int(active.get("valid_frames", 0) or 0) > 0
+ else "Tag可见,但三维PnP位姿初始化未完成"
+ )
+ return (
+ "CAM-GEOMETRY-201",
+ problem,
+ "不要根据二维码可见性调整Tag;复制累计PnP拒绝原因给开发者。",
+ )
if missing or "tag" in value or "detection" in value or "synchronised" in value:
return "OBS-TAG-103", f"所需Tag不可用或持续丢失:{missing}", "检查Tag是否脱落、翘起、反光或被遮挡后重新运行。"
if "state" in value and ("timeout" in value or "lost" in value):
@@ -308,6 +394,15 @@ def classify_error(reason: str, status: Mapping[str, Any]) -> tuple[str, str, st
return "FIT-CHECKPOINT-402", "稳态检查点采集流程未完整结束", "完整样本已保留;复制本诊断块给开发者检查采集状态机。"
if "fit" in value or "trajectory" in value or "axis" in value:
return "FIT-MODEL-401", "关节轴或动态曲线拟合未达到精度门限", "不要放宽门限;复制本诊断块给开发者分析原始样本。"
+ if (
+ "endpoint_zero_offsets' is not defined" in value
+ or "validated_endpoint_zero_state" in value
+ ):
+ return (
+ "PUB-ARTIFACT-601",
+ "已验证零位状态未能完整传递到URDF发布阶段",
+ "采集与验证数据仍可保留;不要移动相机或Tag,复制诊断块给开发者。",
+ )
if "validation" in value or "combination" in value or "quality" in value or "zero" in value:
return "VAL-QUALITY-501", "留出验证或URDF零位验证未通过", "结果不会发布;复制本诊断块给开发者。"
if "publish" in value or "artifact" in value or "URDF" in value:
@@ -338,6 +433,20 @@ def build_failure_report(
}
if group_pnp_reasons:
active["group_pnp_reasons"] = group_pnp_reasons
+ for field in (
+ "pnp_rejection_counts",
+ "group_pnp_rejection_counts",
+ "pnp_initialization_progress",
+ "group_missing_candidate_roles",
+ "pnp_candidate_diagnostics",
+ ):
+ values = {
+ str(view): item.get(field)
+ for view, item in views.items()
+ if isinstance(item, Mapping) and item.get(field)
+ }
+ if values:
+ active[field] = values
try:
explanation, automatic_action = three_camera_reason_zh(
str(status.get("state", "")), reason, active
@@ -359,8 +468,43 @@ def build_failure_report(
metrics = {
"valid_frames": active.get("valid_frames"),
"sample": active.get("sample", {}),
+ "detection_frames": active.get("detection_frames"),
+ "detection_valid_frames": active.get("detection_valid_frames"),
+ "detection_rate": active.get("detection_rate"),
+ "detection_rate_by_view": active.get("detection_rate_by_view", {}),
"automatic_retry_count": active.get("automatic_retry_count", 0),
}
+ pnp_diagnostics = {
+ str(view): {
+ field: item.get(field)
+ for field in (
+ "group_pnp_reason",
+ "group_missing_candidate_roles",
+ "pnp_rejections",
+ "pnp_rejection_counts",
+ "group_pnp_rejection_counts",
+ "pnp_initialization_progress",
+ "pnp_candidate_diagnostics",
+ )
+ if item.get(field)
+ }
+ for view, item in (
+ views.items() if isinstance(views, Mapping) else []
+ )
+ if isinstance(item, Mapping)
+ and any(
+ item.get(field)
+ for field in (
+ "group_pnp_reason",
+ "group_missing_candidate_roles",
+ "pnp_rejections",
+ "pnp_rejection_counts",
+ "group_pnp_rejection_counts",
+ "pnp_initialization_progress",
+ "pnp_candidate_diagnostics",
+ )
+ )
+ }
payload: dict[str, Any] = {
"schema_version": 1,
"serial_number": config.serial_number,
@@ -374,6 +518,7 @@ def build_failure_report(
"automatic_action_zh": automatic_action,
"suggestion_zh": suggestion,
"metrics": metrics,
+ "pnp_diagnostics": pnp_diagnostics,
"camera_state": camera_state,
"feedback_hz": status.get("feedback_hz", 0.0),
"hashes": {
@@ -397,6 +542,12 @@ def build_failure_report(
f"详细说明:{explanation}",
f"自动处理:{automatic_action}",
f"关键指标:{json.dumps(metrics, ensure_ascii=False, separators=(',', ':'))}",
+ "PnP诊断:"
+ + json.dumps(
+ pnp_diagnostics,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ ),
f"相机状态:{json.dumps(camera_state, ensure_ascii=False, separators=(',', ':'))}",
f"反馈状态:{float(payload['feedback_hz'] or 0.0):.1f} Hz",
f"配置哈希:{payload['hashes']['product_config_sha256']}",
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/pnp.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/pnp.py
index b1b42e0..9ee5c91 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/pnp.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/pnp.py
@@ -913,11 +913,15 @@ class SquareTagPoseTracker:
self.last_candidates_by_role: dict[
str, tuple[SquareTagPose, ...]
] = {}
+ self.last_candidate_diagnostics_by_role: dict[
+ str, dict[str, float | int]
+ ] = {}
self.branch_correction_counts: dict[str, int] = {}
def reset(self) -> None:
self._previous.clear()
self.last_candidates_by_role.clear()
+ self.last_candidate_diagnostics_by_role.clear()
self.branch_correction_counts.clear()
def estimate(
@@ -938,28 +942,91 @@ class SquareTagPoseTracker:
)
except (ValueError, cv2.error):
self.last_candidates_by_role[str(role)] = ()
+ self.last_candidate_diagnostics_by_role[str(role)] = {
+ "solved_candidate_count": 0,
+ "reprojection_candidate_count": 0,
+ "independent_tilt_candidate_count": 0,
+ "maximum_reprojection_error_px": float(
+ self.maximum_reprojection_error_px
+ ),
+ "maximum_independent_tilt_deg": math.degrees(
+ self.maximum_tag_tilt_rad
+ ),
+ }
return None, "pnp_solve_failed"
if not candidates:
self.last_candidates_by_role[str(role)] = ()
+ self.last_candidate_diagnostics_by_role[str(role)] = {
+ "solved_candidate_count": 0,
+ "reprojection_candidate_count": 0,
+ "independent_tilt_candidate_count": 0,
+ "maximum_reprojection_error_px": float(
+ self.maximum_reprojection_error_px
+ ),
+ "maximum_independent_tilt_deg": math.degrees(
+ self.maximum_tag_tilt_rad
+ ),
+ }
return None, "pnp_solve_failed"
- usable_candidates: list[SquareTagPose] = []
- for candidate in candidates:
+ reprojection_candidates = [
+ candidate
+ for candidate in candidates
+ if candidate.reprojection_error_px
+ <= self.maximum_reprojection_error_px
+ ]
+ candidate_tilts_rad: list[float] = []
+ independent_candidates: list[SquareTagPose] = []
+ for candidate in reprojection_candidates:
normal = Rotation.from_quat(
candidate.quaternion_xyzw
).as_matrix()[:, 2]
tilt = math.acos(
float(np.clip(abs(normal[2]), 0.0, 1.0))
)
- if (
- candidate.reprojection_error_px
- <= self.maximum_reprojection_error_px
- and tilt <= self.maximum_tag_tilt_rad
- ):
- usable_candidates.append(candidate)
+ candidate_tilts_rad.append(float(tilt))
+ if tilt <= self.maximum_tag_tilt_rad:
+ independent_candidates.append(candidate)
+
+ # Candidate generation and candidate selection have different
+ # contracts. The per-Tag tilt limit protects a pose used without any
+ # other geometry, but it must not erase a finite, low-reprojection
+ # IPPE solution before SquareTagGroupPoseTracker can evaluate it
+ # against the fixed palm reference, the articulated chain and the
+ # preceding group pose. At a strongly oblique view the planar
+ # ambiguity is usually smaller, and rejecting both branches at a
+ # fixed angle caused deterministic mid-sweep holes despite continuous
+ # image detections. Group tracking therefore receives every
+ # reprojection-valid candidate; independent tracking below retains the
+ # original tilt safety gate.
self.last_candidates_by_role[str(role)] = tuple(
- usable_candidates
+ reprojection_candidates
)
- if not usable_candidates:
+ diagnostics: dict[str, float | int] = {
+ "solved_candidate_count": len(candidates),
+ "reprojection_candidate_count": len(reprojection_candidates),
+ "independent_tilt_candidate_count": len(independent_candidates),
+ "minimum_reprojection_error_px": float(
+ min(
+ candidate.reprojection_error_px
+ for candidate in candidates
+ )
+ ),
+ "maximum_reprojection_error_px": float(
+ self.maximum_reprojection_error_px
+ ),
+ "maximum_independent_tilt_deg": math.degrees(
+ self.maximum_tag_tilt_rad
+ ),
+ }
+ if candidate_tilts_rad:
+ diagnostics["minimum_candidate_tilt_deg"] = math.degrees(
+ min(candidate_tilts_rad)
+ )
+ diagnostics["maximum_candidate_tilt_deg"] = math.degrees(
+ max(candidate_tilts_rad)
+ )
+ self.last_candidate_diagnostics_by_role[str(role)] = diagnostics
+ if not independent_candidates:
return None, "no_pose_within_reprojection_or_tilt_limit"
previous_record = self._previous.get(str(role))
@@ -971,7 +1038,7 @@ class SquareTagPoseTracker:
previous = previous_pose
selected, reason = select_continuous_pose(
- usable_candidates,
+ independent_candidates,
previous=previous,
maximum_reprojection_error_px=(
self.maximum_reprojection_error_px
@@ -1180,6 +1247,7 @@ class SquareTagGroupPoseTracker:
self._task_reference_relative_poses: dict[
tuple[str, str], tuple[Rotation, np.ndarray]
] = {}
+ self.last_missing_roles: tuple[str, ...] = ()
def reset(self, *, preserve_task_reference: bool = False) -> None:
self._previous.clear()
@@ -1190,6 +1258,7 @@ class SquareTagGroupPoseTracker:
self.branch_correction_counts.clear()
self._decreasing_relative_rotations.clear()
self._coupled_reference_rotations.clear()
+ self.last_missing_roles = ()
if not preserve_task_reference:
self._task_reference_relative_poses.clear()
@@ -1369,8 +1438,14 @@ class SquareTagGroupPoseTracker:
tuple(candidates_by_role.get(role, ()))
for role in self.roles
]
- if any(not candidates for candidates in candidate_lists):
+ self.last_missing_roles = tuple(
+ role
+ for role, candidates in zip(self.roles, candidate_lists)
+ if not candidates
+ )
+ if self.last_missing_roles:
return None, "group_missing_pose_candidates"
+ self.last_missing_roles = ()
combinations = [
dict(zip(self.roles, combination))
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/product.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/product.py
index 46febbe..cf5d591 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/product.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/product.py
@@ -1,4 +1,10 @@
-"""Immutable G20 right-hand product identity and one-command preflight."""
+"""Immutable product identity and shared calibration preflight.
+
+Model-specific kinematics live in registered calibration contracts. The
+loader itself only verifies that the selected contract, Tag configuration,
+cameras and immutable artifacts agree, so adding another hand does not
+require another copy of the one-command runner.
+"""
from __future__ import annotations
@@ -11,11 +17,113 @@ from typing import Any, Mapping
import yaml
from .extrinsics import camera_info_fingerprint, load_three_camera_extrinsics
+from .full_hand import (
+ G20_RIGHT_19_LAYOUT,
+ HandCalibrationProfile,
+ get_hand_calibration_profile,
+)
+from .urdf_zero import ZeroCalibrationProfile, get_zero_calibration_profile
VIEWS = ("front", "side", "top")
+@dataclass(frozen=True)
+class ProductCalibrationContract:
+ """Declarative boundary between one hand model and the shared engine."""
+
+ model: str
+ side: str
+ layout_id: str
+ profile: HandCalibrationProfile
+ zero_profile: ZeroCalibrationProfile
+
+ @property
+ def required_tag_ids(self) -> frozenset[int]:
+ return frozenset(
+ int(tag_id)
+ for tags in self.profile.view_tags.values()
+ for tag_id in tags.values()
+ )
+
+ @property
+ def views(self) -> tuple[str, ...]:
+ return tuple(self.profile.view_tags)
+
+
+_PRODUCT_CONTRACTS: dict[
+ tuple[str, str, str], ProductCalibrationContract
+] = {}
+
+
+def register_product_calibration_contract(
+ contract: ProductCalibrationContract,
+) -> None:
+ """Register one model/side/layout without modifying shared workflow code."""
+ model = str(contract.model).strip().upper()
+ side = str(contract.side).strip().lower()
+ layout = str(contract.layout_id).strip().lower()
+ if not model or side not in {"left", "right"} or not layout:
+ raise ValueError("product calibration contract identity is invalid")
+ if contract.profile.side != side:
+ raise ValueError("product contract side differs from hand profile")
+ if contract.profile.layout_id.lower() != layout:
+ raise ValueError("product contract layout differs from hand profile")
+ if contract.zero_profile.hand != contract.profile:
+ raise ValueError("zero-calibration profile differs from hand profile")
+ if len(contract.profile.baseline_command) != contract.profile.command_count:
+ raise ValueError("profile baseline and command names differ in length")
+ if not contract.required_tag_ids:
+ raise ValueError("product contract must declare at least one Tag")
+ key = (model, side, layout)
+ existing = _PRODUCT_CONTRACTS.get(key)
+ if existing is not None and existing != contract:
+ raise ValueError(f"product calibration contract already registered: {key}")
+ _PRODUCT_CONTRACTS[key] = contract
+
+
+def get_product_calibration_contract(
+ model: str, side: str, layout_id: str
+) -> ProductCalibrationContract:
+ key = (
+ str(model).strip().upper(),
+ str(side).strip().lower(),
+ str(layout_id).strip().lower(),
+ )
+ try:
+ return _PRODUCT_CONTRACTS[key]
+ except KeyError as error:
+ supported = ", ".join("/".join(item) for item in sorted(_PRODUCT_CONTRACTS))
+ raise ValueError(
+ f"unsupported calibration product {key}; registered={supported}"
+ ) from error
+
+
+register_product_calibration_contract(
+ ProductCalibrationContract(
+ model="G20",
+ side="right",
+ layout_id=G20_RIGHT_19_LAYOUT,
+ profile=get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT),
+ zero_profile=get_zero_calibration_profile(
+ "right", G20_RIGHT_19_LAYOUT
+ ),
+ )
+)
+for _legacy_side in ("left", "right"):
+ register_product_calibration_contract(
+ ProductCalibrationContract(
+ model="G20",
+ side=_legacy_side,
+ layout_id="legacy_11",
+ profile=get_hand_calibration_profile(_legacy_side, "legacy_11"),
+ zero_profile=get_zero_calibration_profile(
+ _legacy_side, "legacy_11"
+ ),
+ )
+ )
+
+
def sha256_file(path: str | Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
@@ -104,6 +212,10 @@ def _custom_pnp_tag_sizes_m_by_id(
class ProductConfig:
path: Path
workspace: Path
+ model: str
+ side: str
+ tag_layout: str
+ calibration_contract: ProductCalibrationContract
serial_number: str
can_interface: str
source_urdf: Path
@@ -130,7 +242,7 @@ def load_product_config(
workspace: str | Path | None = None,
check_can: bool = True,
) -> ProductConfig:
- """Load and fully verify the fixed G20_RIGHT_001 product configuration."""
+ """Load and verify a product against its registered calibration contract."""
source = Path(path).expanduser().resolve()
if not source.is_file():
raise ValueError(f"product config does not exist: {source}")
@@ -142,8 +254,17 @@ def load_product_config(
serial = str(raw.get("serial_number", ""))
if re.fullmatch(r"[A-Za-z0-9_.-]+", serial) is None:
raise ValueError("serial_number is invalid")
- if raw.get("model") != "G20" or raw.get("side") != "right":
- raise ValueError("product config must describe the G20 right hand")
+ model = str(raw.get("model", "")).strip().upper()
+ side = str(raw.get("side", "")).strip().lower()
+ # Keep the deployed G20 schema compatible while making the layout an
+ # explicit product choice for all new configurations.
+ layout = str(
+ raw.get(
+ "tag_layout",
+ G20_RIGHT_19_LAYOUT if (model, side) == ("G20", "right") else "",
+ )
+ ).strip().lower()
+ contract = get_product_calibration_contract(model, side, layout)
can_interface = str(raw.get("can_interface", "")).strip()
if not can_interface:
raise ValueError("can_interface is required")
@@ -197,16 +318,13 @@ def load_product_config(
if actual != expected:
raise ValueError(f"{name} SHA-256 mismatch: expected={expected} actual={actual}")
- expected_tag_ids = set(range(19))
+ expected_tag_ids = set(contract.required_tag_ids)
tag_sizes = _tag_sizes_m_by_id(tag_config)
if set(tag_sizes) != expected_tag_ids:
raise ValueError(
- "g20_right_19 Tag config must contain exactly IDs 0 through 18"
- )
- expected_tag_sizes = {tag_id: 0.016 for tag_id in expected_tag_ids}
- if tag_sizes != expected_tag_sizes:
- raise ValueError(
- "g20_right_19 black-code Tag sizes must be 16 mm for every ID"
+ f"{model}/{side}/{layout} Tag IDs differ from the registered "
+ f"profile: expected={sorted(expected_tag_ids)} "
+ f"actual={sorted(tag_sizes)}"
)
if _custom_pnp_tag_sizes_m_by_id(
calibration_config, expected_tag_ids
@@ -216,12 +334,17 @@ def load_product_config(
)
camera_raw = _mapping(raw.get("cameras"), "cameras")
- if set(camera_raw) != set(VIEWS):
- raise ValueError("cameras must contain front/side/top")
+ required_views = tuple(contract.views)
+ if set(required_views) != set(VIEWS):
+ raise ValueError(
+ "the current shared engine requires front/side/top views"
+ )
+ if set(camera_raw) != set(required_views):
+ raise ValueError(f"cameras must contain {'/'.join(required_views)}")
loaded_extrinsics = load_three_camera_extrinsics(extrinsics)
cameras: dict[str, dict[str, str]] = {}
serials: set[str] = set()
- for view in VIEWS:
+ for view in required_views:
item = _mapping(camera_raw[view], f"cameras.{view}")
camera_serial = str(item.get("serial_number", ""))
info_path = _resolve_path(
@@ -259,6 +382,10 @@ def load_product_config(
return ProductConfig(
path=source,
workspace=root,
+ model=model,
+ side=side,
+ tag_layout=layout,
+ calibration_contract=contract,
serial_number=serial,
can_interface=can_interface,
source_urdf=source_urdf,
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/publication.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/publication.py
index 597d5eb..0f9b8df 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/publication.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/publication.py
@@ -24,7 +24,7 @@ from .full_hand import (
from .product import ProductConfig, sha256_file
from .storage import atomic_write_json
from .urdf_zero import (
- RIGHT_19_FLEXION_ENDPOINT_JOINTS,
+ RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
get_zero_calibration_profile,
)
@@ -41,6 +41,7 @@ ACTIVE_ZERO_JOINTS = frozenset(
RETAINED_ACTIVE_ZERO_JOINTS = frozenset(
get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT).active_joints
) - ACTIVE_ZERO_JOINTS
+SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD = 5.1e-9
def _load_json(path: Path) -> dict[str, Any]:
@@ -89,7 +90,7 @@ def _mask_active_origin_rpy_fields(text: str) -> str:
def _mask_endpoint_coordinate_fields(text: str) -> str:
- """Mask only limit/mimic fields induced by flexion zero coordinates."""
+ """Mask only limit/mimic fields induced by endpoint zero coordinates."""
pattern = re.compile(
r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
re.DOTALL,
@@ -98,7 +99,7 @@ def _mask_endpoint_coordinate_fields(text: str) -> str:
def replace(match: re.Match[str]) -> str:
block = match.group(0)
name = match.group("name")
- if name in RIGHT_19_FLEXION_ENDPOINT_JOINTS:
+ if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS:
return re.sub(
r"(]*\bupper\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_UPPER__\2",
@@ -110,7 +111,10 @@ def _mask_endpoint_coordinate_fields(text: str) -> str:
block,
re.DOTALL,
)
- if mimic is not None and mimic.group("source") in RIGHT_19_FLEXION_ENDPOINT_JOINTS:
+ if (
+ mimic is not None
+ and mimic.group("source") in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
+ ):
return re.sub(
r"(]*\boffset\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_MIMIC_OFFSET__\2",
@@ -145,7 +149,7 @@ def _verify_expected_origin_offsets(
if set(offsets) != ACTIVE_ZERO_JOINTS or any(
not math.isfinite(value) for value in offsets.values()
):
- raise ValueError("expected offsets must contain 12 finite static-zero values")
+ raise ValueError("expected offsets must contain all finite active static-zero values")
before = _joint_elements(source)
after = _joint_elements(corrected)
maximum_rotation_error = 0.0
@@ -186,7 +190,7 @@ def _verify_expected_origin_offsets(
# so comparing that file with the published JSON necessarily permits half
# of one last-place unit. This is about 2.9e-7 degrees and is far below
# any calibration or URDF numerical significance.
- if maximum_rotation_error > 5.1e-9:
+ if maximum_rotation_error > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD:
raise ValueError(
"corrected URDF origin.rpy does not match the published zero offsets"
)
@@ -245,7 +249,13 @@ def verify_corrected_urdf(
original_limit = source_joints[name].find("limit")
corrected_limit = corrected_joints[name].find("limit")
expected_upper = float(original_limit.get("upper")) - offset
- if abs(float(corrected_limit.get("upper")) - expected_upper) > 1.0e-10:
+ # Schema v4 stores the corresponding zero at eight decimal
+ # places, while the URDF is written from the full-precision solve.
+ # Match the half-last-place tolerance used for origin rotations.
+ if (
+ abs(float(corrected_limit.get("upper")) - expected_upper)
+ > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ ):
raise ValueError(f"corrected URDF has invalid {name} upper limit")
for name in PASSIVE_JOINTS:
original_mimic = source_joints[name].find("mimic")
@@ -253,9 +263,20 @@ def verify_corrected_urdf(
if original_mimic is None or corrected_mimic is None:
continue
source_name = str(original_mimic.get("joint"))
+ multiplier = float(original_mimic.get("multiplier", "1"))
expected = float(original_mimic.get("offset", "0"))
- expected += float(original_mimic.get("multiplier", "1")) * endpoint_offsets.get(source_name, 0.0)
- if abs(float(corrected_mimic.get("offset", "0")) - expected) > 1.0e-10:
+ expected += multiplier * endpoint_offsets.get(source_name, 0.0)
+ # The active endpoint offset comes from schema-v4 JSON rounded to
+ # eight decimal places, while the URDF mimic was written from the
+ # full-precision solve. Propagate exactly the same accepted
+ # quantization through the mimic multiplier; retain a much
+ # smaller allowance for XML decimal formatting itself.
+ tolerance = (
+ abs(multiplier)
+ * SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ + 1.0e-12
+ )
+ if abs(float(corrected_mimic.get("offset", "0")) - expected) > tolerance:
raise ValueError(f"corrected URDF has invalid {name} mimic offset")
return tuple(sorted(changed))
@@ -284,55 +305,62 @@ def verify_urdf_mesh_resources(urdf: str | Path) -> dict[str, Path]:
def validate_runtime_curves_against_urdf_limits(
- payload: Mapping[str, Any], source_urdf: str | Path
+ payload: Mapping[str, Any], runtime_urdf: str | Path
) -> None:
- """Reject a runtime curve whose commanded q leaves a CAD safety limit."""
+ """Reject a curve whose q leaves its runtime URDF coordinate limits.
+
+ Curves in schema v4 are expressed in the corrected URDF joint coordinate,
+ not in the source-CAD coordinate. Endpoint zero calibration can therefore
+ move a corrected coordinate limit while preserving the same physical CAD
+ endpoint; callers publishing a calibrated pair must pass that corrected
+ URDF here.
+ """
validate_compact_payload(payload)
- source_joints = _joint_elements(source_urdf)
+ runtime_joints = _joint_elements(runtime_urdf)
for name, calibration in payload["joints"].items():
- joint = source_joints.get(str(name))
+ joint = runtime_joints.get(str(name))
if joint is None:
- raise ValueError(f"source URDF is missing runtime joint {name}")
+ raise ValueError(f"runtime URDF is missing joint {name}")
limit = joint.find("limit")
if limit is None or limit.get("lower") is None or limit.get("upper") is None:
- raise ValueError(f"source URDF joint {name} has no finite position limit")
+ raise ValueError(f"runtime URDF joint {name} has no finite position limit")
lower = float(limit.get("lower"))
upper = float(limit.get("upper"))
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
- raise ValueError(f"source URDF joint {name} has invalid position limits")
+ raise ValueError(f"runtime URDF joint {name} has invalid position limits")
curve = np.asarray(calibration["angle_rad"], dtype=float)
minimum = float(np.min(curve))
maximum = float(np.max(curve))
tolerance = 1.0e-7
if minimum < lower - tolerance or maximum > upper + tolerance:
raise ValueError(
- f"runtime curve exceeds source URDF limit for {name}: "
+ f"runtime curve exceeds runtime URDF limit for {name}: "
f"[{minimum:.9g}, {maximum:.9g}] not within "
f"[{lower:.9g}, {upper:.9g}]"
)
def clamp_compact_payload_to_urdf_limits(
- payload: Mapping[str, Any], source_urdf: str | Path
+ payload: Mapping[str, Any], runtime_urdf: str | Path
) -> tuple[dict[str, Any], dict[str, int]]:
- """Return a schema-preserving runtime payload bounded by CAD limits."""
+ """Return a schema-preserving payload bounded in its runtime coordinates."""
result = copy.deepcopy(dict(payload))
validate_compact_payload(result)
- source_joints = _joint_elements(source_urdf)
+ runtime_joints = _joint_elements(runtime_urdf)
clipped_by_joint: dict[str, int] = {}
for name, calibration in result["joints"].items():
- joint = source_joints.get(str(name))
+ joint = runtime_joints.get(str(name))
limit = None if joint is None else joint.find("limit")
if (
limit is None
or limit.get("lower") is None
or limit.get("upper") is None
):
- raise ValueError(f"source URDF joint {name} has no finite position limit")
+ raise ValueError(f"runtime URDF joint {name} has no finite position limit")
lower = float(limit.get("lower"))
upper = float(limit.get("upper"))
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
- raise ValueError(f"source URDF joint {name} has invalid position limits")
+ raise ValueError(f"runtime URDF joint {name} has invalid position limits")
source = np.asarray(calibration["angle_rad"], dtype=float)
bounded = np.clip(source, lower, upper)
count = int(np.count_nonzero(bounded != source))
@@ -429,7 +457,10 @@ def compare_session_offsets(
) -> dict[str, float]:
left = active_offsets(first)
right = active_offsets(second)
- differences = {name: abs(left[name] - right[name]) for name in sorted(left)}
+ differences = {
+ name: abs(left[name] - right[name])
+ for name in sorted(left)
+ }
failed = {name: value for name, value in differences.items() if value > maximum_difference_rad}
if failed:
details = ", ".join(
@@ -549,14 +580,15 @@ def finalize_session_artifacts(
raise ValueError("node status is missing combination validation")
_verify_combination_validation(combination)
offsets = active_offsets(payload)
+ endpoint_offsets = {
+ name: offsets[name]
+ for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
+ }
changed_joints = verify_corrected_urdf(
config.source_urdf,
paths["urdf"],
expected_offsets_rad=offsets,
- endpoint_anchored_offsets_rad={
- name: offsets[name]
- for name in RIGHT_19_FLEXION_ENDPOINT_JOINTS
- },
+ endpoint_anchored_offsets_rad=endpoint_offsets,
)
payload, clipped_runtime_joints = clamp_compact_payload_to_urdf_limits(
payload, paths["urdf"]
@@ -628,8 +660,13 @@ def finalize_session_artifacts(
config.source_urdf,
paths["urdf"],
expected_offsets_rad=offsets,
+ endpoint_anchored_offsets_rad=endpoint_offsets,
)
- validate_runtime_curves_against_urdf_limits(payload, config.source_urdf)
+ # Revalidate the same coordinate contract immediately before publication:
+ # verify_corrected_urdf proves that corrected endpoint limits map back to
+ # the original physical CAD endpoints, while the runtime curves must stay
+ # inside those corrected-coordinate limits.
+ validate_runtime_curves_against_urdf_limits(payload, paths["urdf"])
final_mesh_hashes = {
name: sha256_file(path)
for name, path in verify_urdf_mesh_resources(paths["urdf"]).items()
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py
index 0ad597f..9761db9 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import re
-from typing import Any, Mapping
+from typing import Any, Mapping, Sequence
STATE_NAMES_ZH = {
@@ -115,10 +115,13 @@ def _task_text(active: Mapping[str, Any]) -> str:
)
fit_attempt = int(active.get("fit_attempt", 1))
if fit_attempt > 1:
- task += (
- f"(整关节自动重采第{fit_attempt}/"
- f"{active.get('fit_attempt_limit', '?')}次)"
- )
+ retry_cycles = active.get("fit_retry_cycles", [])
+ if retry_cycles:
+ task += "(补采异常轮" + "/".join(
+ str(cycle) for cycle in retry_cycles
+ ) + ")"
+ else:
+ task += f"(拟合补采第{fit_attempt}次)"
return task
@@ -240,14 +243,87 @@ def three_camera_reason_zh(
if reason == "synchronised_tag_state_timeout":
group_reasons = active.get("group_pnp_reasons", {})
if isinstance(group_reasons, Mapping) and group_reasons:
- reason_text = "、".join(
- f"{VIEW_NAMES_ZH.get(str(view), str(view))}={value}"
- for view, value in group_reasons.items()
+ tag_rejections = active.get("pnp_rejection_counts", {})
+ group_rejections = active.get(
+ "group_pnp_rejection_counts", {}
)
+ missing_roles = active.get(
+ "group_missing_candidate_roles", {}
+ )
+ candidate_diagnostics = active.get(
+ "pnp_candidate_diagnostics", {}
+ )
+ view_details: list[str] = []
+ for view, value in group_reasons.items():
+ view_name = str(view)
+ parts = [str(value)]
+ missing = (
+ missing_roles.get(view_name, ())
+ if isinstance(missing_roles, Mapping)
+ else ()
+ )
+ if isinstance(missing, Sequence) and not isinstance(
+ missing, (str, bytes)
+ ) and missing:
+ parts.append(
+ "缺候选=" + ",".join(str(role) for role in missing)
+ )
+ counts: dict[str, int] = {}
+ for source in (tag_rejections, group_rejections):
+ values = (
+ source.get(view_name)
+ if isinstance(source, Mapping)
+ else None
+ )
+ if isinstance(values, Mapping):
+ for name, count in values.items():
+ counts[str(name)] = counts.get(str(name), 0) + int(
+ count
+ )
+ if counts:
+ common = sorted(
+ counts.items(), key=lambda pair: (-pair[1], pair[0])
+ )[:3]
+ parts.append(
+ "累计拒绝="
+ + ",".join(
+ f"{name}×{count}" for name, count in common
+ )
+ )
+ view_candidates = (
+ candidate_diagnostics.get(view_name, {})
+ if isinstance(candidate_diagnostics, Mapping)
+ else {}
+ )
+ if isinstance(view_candidates, Mapping) and missing:
+ summaries: list[str] = []
+ for role in missing:
+ diagnostic = view_candidates.get(str(role), {})
+ if not isinstance(diagnostic, Mapping):
+ continue
+ summaries.append(
+ f"{role}(solve="
+ f"{int(diagnostic.get('solved_candidate_count', 0))},"
+ "reproj="
+ f"{int(diagnostic.get('reprojection_candidate_count', 0))},"
+ "tilt="
+ f"{int(diagnostic.get('independent_tilt_candidate_count', 0))})"
+ )
+ if summaries:
+ parts.append("候选统计=" + ",".join(summaries))
+ view_details.append(
+ f"{VIEW_NAMES_ZH.get(view_name, view_name)}="
+ + ";".join(parts)
+ )
+ reason_text = "、".join(view_details)
return (
- f"{detail_prefix}Tag仍可见且反馈正常,但连续图像帧被整组PnP几何检查拒绝"
+ f"{detail_prefix}已经取得部分有效轨迹,但Tag仍可见且反馈正常时,"
+ "后续连续图像帧"
+ "被整组PnP几何检查拒绝"
f"({reason_text}),因此无法与电机状态形成有效轨迹帧。",
- "不要调整或反复粘贴Tag;保留当前会话并复制诊断块给开发者检查PnP分支逻辑。",
+ "不要调整或反复粘贴Tag;保留当前会话中的"
+ "group_pnp_candidate_event,"
+ "按缺失角色的候选统计检查PnP分支逻辑。",
)
return (
f"{detail_prefix}运动过程中连续超过允许时间没有取得“所需Tag全部有效且能与电机状态"
@@ -273,15 +349,81 @@ def three_camera_reason_zh(
)
if reason == "sweep_start_tag_timeout":
group_reasons = active.get("group_pnp_reasons", {})
- if isinstance(group_reasons, Mapping) and group_reasons:
- reason_text = "、".join(
- f"{VIEW_NAMES_ZH.get(str(view), str(view))}={value}"
- for view, value in group_reasons.items()
+ progress_by_view = active.get("pnp_initialization_progress", {})
+ tag_rejections = active.get("pnp_rejection_counts", {})
+ group_rejections = active.get("group_pnp_rejection_counts", {})
+ if any(
+ isinstance(value, Mapping) and bool(value)
+ for value in (
+ group_reasons,
+ progress_by_view,
+ tag_rejections,
+ group_rejections,
)
+ ):
+ details: list[str] = []
+ views = set()
+ for value in (
+ group_reasons,
+ progress_by_view,
+ tag_rejections,
+ group_rejections,
+ ):
+ if isinstance(value, Mapping):
+ views.update(str(view) for view in value)
+ for view in sorted(views):
+ parts: list[str] = []
+ progress = (
+ progress_by_view.get(view)
+ if isinstance(progress_by_view, Mapping)
+ else None
+ )
+ if isinstance(progress, Mapping):
+ parts.append(
+ "初始化"
+ f"{int(progress.get('accepted', 0))}/"
+ f"{int(progress.get('required', 0))}"
+ )
+ counts: dict[str, int] = {}
+ for source in (tag_rejections, group_rejections):
+ values = (
+ source.get(view)
+ if isinstance(source, Mapping)
+ else None
+ )
+ if isinstance(values, Mapping):
+ for name, count in values.items():
+ counts[str(name)] = (
+ counts.get(str(name), 0) + int(count)
+ )
+ if counts:
+ common = sorted(
+ counts.items(), key=lambda pair: (-pair[1], pair[0])
+ )[:3]
+ parts.append(
+ "累计拒绝="
+ + ",".join(
+ f"{name}×{count}" for name, count in common
+ )
+ )
+ latest = (
+ group_reasons.get(view)
+ if isinstance(group_reasons, Mapping)
+ else None
+ )
+ if latest and not str(latest).startswith(
+ "group_initializing:"
+ ):
+ parts.append(f"最后状态={latest}")
+ if parts:
+ details.append(
+ f"{VIEW_NAMES_ZH.get(view, view)}=" + ";".join(parts)
+ )
+ reason_text = "、".join(details) or "未形成完整初始化窗口"
return (
- "被测电机已经到达扫描起点,所需Tag也可见,但整组PnP初始化持续"
- f"拒绝候选({reason_text}),因此没有生成同步端点帧。",
- "不要重新粘贴可见Tag;保留当前会话并复制诊断块给开发者检查PnP安装先验。",
+ "被测电机已经到达扫描起点,所需Tag也可见,但三维PnP位姿初始化"
+ f"没有完成({reason_text}),因此没有生成同步端点帧。",
+ "不要根据可见性重复粘贴Tag;保留累计拒绝原因并检查PnP候选选择。",
)
return (
"被测电机已经到达扫描起点,但当前任务所需的实时运动Tag没有形成足够的"
@@ -308,6 +450,19 @@ def three_camera_reason_zh(
"随机复测位置没有采集到足够的同步有效Tag帧。",
"检查当前机位Tag可见性后调用resume。",
)
+ if reason == "palm_orientation_quality_failed":
+ failures = active.get("failures", [])
+ detail = (
+ str(failures[0].get("reason", "方向观测不足"))
+ if failures
+ else "方向观测不足"
+ )
+ return (
+ "掌部公共方向无法由至少三根手指的短时正面轨迹稳定确定:"
+ + detail,
+ "保持Tag安装不变;让正面Tag 10–13在对应MCP-pitch起始段"
+ "至少可见15°行程后重新标定。",
+ )
if reason in {"joint_fit_check_failed", "joint_fit_systematic_failure"}:
metric_names = {
"plane_rms_mm": "平面拟合RMS",
@@ -327,8 +482,10 @@ def three_camera_reason_zh(
"axis_plane_rms_mm": "三维圆轴向RMS",
"axis_radial_rms_mm": "三维圆半径RMS",
"axis_pose_line_rms_mm": "姿态轨迹轴线RMS",
+ "axis_line_cycle_rms_mm": "四轮轴线位置RMS",
"rotation_circle_axis_difference_deg": "姿态轴与圆轨迹轴夹角",
"axis_cycle_difference_deg": "各轮转轴方向极差",
+ "cross_view_roll_curve": "正面/侧面关节角曲线差异RMS",
"third_cycle_axis_holdout_deg": "第三轮留出零位可观测轴向误差",
"third_cycle_axis_line_rms_mm": "第三轮留出轴线RMS",
"third_cycle_trajectory_p95_deg": "第三轮留出轨迹误差P95",
@@ -353,6 +510,7 @@ def three_camera_reason_zh(
"axis_plane_rms_mm": "mm",
"axis_radial_rms_mm": "mm",
"axis_pose_line_rms_mm": "mm",
+ "axis_line_cycle_rms_mm": "mm",
"rotation_circle_axis_difference_deg": "°",
"axis_cycle_difference_deg": "°",
"third_cycle_axis_holdout_deg": "°",
@@ -360,6 +518,7 @@ def three_camera_reason_zh(
"third_cycle_trajectory_p95_deg": "°",
"state_image_sync_p95_ms": "ms",
"tag_valid_rate_percent": "%",
+ "cross_view_roll_curve": "°",
}
details: list[str] = []
for failure in active.get("failures", []):
@@ -402,8 +561,19 @@ def three_camera_reason_zh(
for failure in active.get("failures", [])
)
if reason == "joint_fit_systematic_failure":
+ cross_view_systematic = any(
+ failure.get("classification")
+ in {
+ "stable_cross_view_installation_or_model_bias",
+ "stable_cross_view_direction_conflict",
+ }
+ for failure in active.get("failures", [])
+ )
suggestion = (
- "各轮重复出现同一模型冲突,继续运动不会改善;程序已禁止自动重扫。"
+ "四轮都出现稳定的正面/侧面差异,属于Tag安装外参或跨视角模型偏差,"
+ "继续重扫不会改善;检查Tag刚性安装与跨视角安装变换,不要放宽门限。"
+ if cross_view_systematic
+ else "各轮重复出现同一模型冲突,继续运动不会改善;程序已禁止自动重扫。"
"请直接复制诊断块给开发者,不要放宽门限。"
)
else:
@@ -543,6 +713,18 @@ def three_camera_reason_zh(
return "三维轨迹、关节轴零位和第三轮留出验证已经完成。", "检查JSON、修正URDF路径和quality.passed。"
if reason == "quality_failed":
return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。"
+ if reason.startswith("validated_endpoint_zero_state"):
+ return (
+ "轨迹和URDF零位验证已经通过,但发布前检测到端点零位状态缺失或与"
+ "已验证模型不一致;这是程序内部状态生命周期错误,结果未发布。",
+ "不要移动相机、Tag或机械手底座;保留当前会话并把原因码交给开发者。",
+ )
+ if reason.startswith("PUB-ARTIFACT-601:"):
+ return (
+ "标定节点已经生成通过质量门限的JSON和候选URDF,但一键程序在正式发布前"
+ "发现这对产物的坐标、限位、哈希或资源一致性检查失败;原始URDF未被覆盖。",
+ "不要重新标定相机或调整Tag;保留本会话产物和启动日志供开发者检查发布契约。",
+ )
if reason == "combination_pose_prediction_failed":
return (
"单关节、零位和URDF几何验证已通过,但当前多关节组合姿态的Tag实测位姿与模型预测超过门限。",
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py
index 89726ce..f0404e2 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py
@@ -57,6 +57,7 @@ from .full_hand import (
THREE_CAMERA_BASELINE_COMMAND,
HandCalibrationProfile,
JointCurveFit,
+ PalmAxisObserver,
SweepSpec,
build_calibration_motion_command,
build_calibration_preparation_waypoints,
@@ -67,6 +68,7 @@ from .full_hand import (
canonical_zero_direction,
clamp_runtime_fits_to_urdf_limits,
compare_cross_view_roll_curves,
+ cross_view_roll_diagnostic_metrics,
derive_mimic_passive_fits,
fit_joint_image_curve,
get_hand_calibration_profile,
@@ -77,24 +79,36 @@ from .pnp import (
SquareTagPose,
SquareTagPoseTracker,
)
+from .product import get_product_calibration_contract
from .storage import append_jsonl, append_jsonl_many, atomic_write_json
from .three_camera_diagnostics import render_three_camera_status_text_zh
from .urdf_zero import (
LEFT_ZERO_PROFILE,
+ RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
JointAxisMeasurement,
+ PalmOrientationMeasurement,
UrdfKinematicModel,
ZeroCalibrationProfile,
ZeroSolveResult,
+ anchor_right_19_mechanical_endpoint_curves,
+ axis_line_cycle_rms_m,
+ axis_line_uses_depth_free_interpretation_plane,
baseline_hysteresis_by_cycle_rad,
circle_direction_is_constrained,
+ cross_view_side_line_source,
fit_joint_axis_measurement,
+ fit_partial_palm_orientation_measurements,
fit_rotation_joint_curve,
- derive_right_19_flexion_endpoint_offsets,
+ derive_right_19_mechanical_endpoint_offsets,
joint_curve_holdout_errors,
+ maximum_axis_line_cycle_spread_m,
measure_joint_curve_observation,
+ refit_axis_line_group_with_shared_radius,
+ select_cross_view_roll_direction_source,
solve_urdf_zero_offsets,
get_zero_calibration_profile,
write_zero_corrected_urdf,
+ with_depth_free_axis_projection,
)
@@ -252,10 +266,80 @@ def _sweep_views(
return tuple(views or [spec.view])
+def _palm_axis_observer_for_sweep(
+ profile: HandCalibrationProfile,
+ spec: SweepSpec,
+ view: str,
+) -> PalmAxisObserver | None:
+ """Return the one non-blocking palm observer attached to this task/view."""
+ matches = tuple(
+ observer
+ for observer in profile.palm_axis_observers
+ if observer.task_name == spec.key and observer.view == str(view)
+ )
+ if len(matches) > 1:
+ raise ValueError(
+ f"multiple palm-axis observers configured for {spec.key}:{view}"
+ )
+ return matches[0] if matches else None
+
+
+def _palm_axis_observer_schema(
+ profile: HandCalibrationProfile,
+) -> list[dict[str, Any]]:
+ return [
+ {
+ "source_name": item.source_name,
+ "task_name": item.task_name,
+ "view": item.view,
+ "parent_role": item.parent_role,
+ "child_role": item.child_role,
+ "model_joint": item.model_joint,
+ "motor_index": int(item.motor_index),
+ }
+ for item in profile.palm_axis_observers
+ ]
+
+
+def _palm_axis_resume_policy(
+ profile: HandCalibrationProfile,
+ session_start: Mapping[str, Any],
+) -> tuple[bool, tuple[str, ...]]:
+ """Validate optional palm-axis checkpoint data when a model uses it."""
+ capability = "palm_axis_side_channel_v1"
+ previous = {str(value) for value in session_start.get("capabilities", [])}
+ if not profile.palm_axis_observers and capability not in profile.capabilities:
+ return True, ()
+ required_previous = set(profile.capabilities) - {capability}
+ if not required_previous.issubset(previous):
+ raise ValueError("resume checkpoint lacks required capabilities")
+ if capability in previous:
+ if session_start.get("palm_axis_observers") != (
+ _palm_axis_observer_schema(profile)
+ ):
+ raise ValueError("resume palm-axis observer schema differs")
+ return True, ()
+ return False, tuple(
+ observer.task_name for observer in profile.palm_axis_observers
+ )
+
+
def _node_profile(node: Any) -> HandCalibrationProfile:
return getattr(node, "profile", LEFT_HAND_PROFILE)
+def _command_names(node: Any) -> tuple[str, ...]:
+ profile = _node_profile(node)
+ names = tuple(getattr(profile, "command_names", ()) or COMMAND_NAMES)
+ if not names or len(set(names)) != len(names):
+ raise ValueError("product command names must be non-empty and unique")
+ return names
+
+
+def _command_count(node: Any) -> int:
+ return len(_command_names(node))
+
+
def _sweep_joints_for_view(
profile: HandCalibrationProfile,
spec: SweepSpec,
@@ -332,6 +416,19 @@ def _fit_retry_joint_names(
selected: set[str] = set()
validation_sources = profile.axis_validation_sources or {}
for failure in failures:
+ quality_sources = failure.get("quality_source_joints", ())
+ if (
+ isinstance(quality_sources, Sequence)
+ and not isinstance(quality_sources, (str, bytes))
+ ):
+ selected_sources = {
+ str(source)
+ for source in quality_sources
+ if str(source) in spec.joints
+ }
+ if selected_sources:
+ selected.update(selected_sources)
+ continue
name = str(failure.get("joint", ""))
if str(failure.get("metric", "")) == "cross_view_roll_curve":
name = str(validation_sources.get(name, name))
@@ -392,6 +489,89 @@ def _isolated_axis_cycle_outliers(
return {best_excluded}
+def _isolated_axis_line_cycle_outliers(
+ measurements: Sequence[JointAxisMeasurement], limit_m: float
+) -> set[int]:
+ """Return one cycle whose removal restores line-position repeatability.
+
+ Four independently fitted near-parallel lines can contain one bad PnP
+ branch or one mechanically unsettled pass. When three cycles form a
+ clearly tighter cluster below the unchanged formal RMS limit, reacquire
+ only the excluded cycle. Ambiguous gradual drift still returns no
+ outlier and therefore keeps the conservative full four-cycle retry.
+ """
+ if len(measurements) < 4 or float(limit_m) <= 0.0:
+ return set()
+ full_rms = axis_line_cycle_rms_m(measurements)
+ if full_rms <= float(limit_m):
+ return set()
+ candidates: list[tuple[float, int]] = []
+ for excluded in range(len(measurements)):
+ inliers = [
+ measurement
+ for index, measurement in enumerate(measurements)
+ if index != excluded
+ ]
+ inlier_rms = axis_line_cycle_rms_m(inliers)
+ if inlier_rms <= float(limit_m):
+ candidates.append((inlier_rms, excluded))
+ if not candidates:
+ return set()
+ candidates.sort()
+ best_rms, best_excluded = candidates[0]
+ # Require a strong three-cycle cluster and a unique choice. This rejects
+ # the common gradual-drift case where excluding either endpoint happens
+ # to move a marginal RMS just below the gate.
+ if best_rms > 0.75 * float(limit_m):
+ return set()
+ if (
+ len(candidates) > 1
+ and candidates[1][0] - best_rms <= 0.10 * float(limit_m)
+ ):
+ return set()
+ return {best_excluded}
+
+
+def _preserve_pnp_task_reference_for_sweep(
+ item: SweepItem, *, is_fit_retry: bool, has_precheck_anchor: bool
+) -> bool:
+ """Keep the accepted IPPE branch after precheck and across retries."""
+ return bool(
+ not item.precheck
+ and (
+ item.cycle > 0
+ or bool(is_fit_retry)
+ or bool(has_precheck_anchor)
+ )
+ )
+
+
+def _requires_pnp_tracker_reset_for_sweep(
+ profile: HandCalibrationProfile,
+ item: SweepItem,
+ *,
+ is_fit_retry: bool,
+) -> bool:
+ """Reset G20-right PnP once per task, not once per formal cycle.
+
+ The low-speed decreasing precheck establishes a branch with a static
+ endpoint window. Clearing frame-to-frame state at every later cycle made
+ four measurements of one rigid installation depend on four independent
+ planar-PnP initializations. Keep the tracker continuous for the whole
+ normal task transaction; a bounded retry remains a deliberately fresh
+ initialization while retaining the accepted task reference.
+ """
+ if item.direction != DIRECTION_DECREASING:
+ return False
+ if profile.layout_id != G20_RIGHT_19_LAYOUT:
+ return True
+ if is_fit_retry:
+ return True
+ if profile.supports("precheck_sweeps"):
+ return bool(item.precheck)
+ return not item.precheck and item.cycle == 0
+
+
def _fixed_base_role(view: str) -> str:
return {
"front": "front_base",
@@ -487,6 +667,63 @@ def _frames_cover_sweep_joints(
)
+def _records_have_formal_sweep_coverage(
+ records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
+ joint_names: Sequence[str],
+ *,
+ repetitions: int,
+ minimum_bins: int,
+ maximum_bin_gap: int,
+) -> bool:
+ """Return whether every persisted direction has formal bin coverage."""
+ expected_groups = {
+ (cycle, direction)
+ for cycle in range(int(repetitions))
+ for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING)
+ }
+ if not joint_names or not expected_groups:
+ return False
+ for joint_name in joint_names:
+ grouped: dict[tuple[int, str], set[int]] = {}
+ for record in records_by_joint.get(joint_name, ()):
+ direction = str(record.get("direction", ""))
+ if direction not in {
+ DIRECTION_DECREASING,
+ DIRECTION_INCREASING,
+ }:
+ continue
+ command = int(
+ np.clip(
+ np.rint(
+ float(
+ record.get(
+ "command_u8",
+ record.get("feedback_u8", -1),
+ )
+ )
+ ),
+ 0,
+ 255,
+ )
+ )
+ grouped.setdefault(
+ (int(record.get("cycle", -1)), direction), set()
+ ).add(command)
+ if not expected_groups.issubset(grouped):
+ return False
+ for key in expected_groups:
+ commands = sorted(grouped[key])
+ if (
+ not commands
+ or commands[0] != 0
+ or commands[-1] != 255
+ or len(commands) < int(minimum_bins)
+ or max(np.diff(commands), default=0) > int(maximum_bin_gap)
+ ):
+ return False
+ return True
+
+
def _frames_cover_sweep_motion(
frames: Sequence[Any],
spec: SweepSpec,
@@ -716,6 +953,17 @@ def _unresolved_fit_failure_tasks(
# cross-cycle and holdout gates, so the old rejection itself
# must not force another eight-direction scan.
return True
+ if (
+ profile.layout_id == G20_RIGHT_19_LAYOUT
+ and metric == "cross_view_roll_curve"
+ and joint_name in (profile.axis_validation_sources or {})
+ ):
+ # The side roll alias is a validation-only observation in the
+ # current product model. Its raw samples are still
+ # revalidated through side-line radial/cycle/holdout gates,
+ # but a historical front/side curve-shape warning must not
+ # make a complete task look unfinished on every restart.
+ return True
return metric in {
"rotation_circle_axis_difference_deg",
"axis_plane_rms_mm",
@@ -752,7 +1000,9 @@ def resumable_completed_task_prefix(
"""
rows = _normalise_legacy_split_roll_resume_rows(profile, rows)
samples = _latest_resume_rows(
- rows, kind="sample", minimum_sweep_bins=minimum_sweep_bins
+ rows,
+ kind="sample",
+ minimum_sweep_bins=minimum_sweep_bins,
)
baselines = _latest_resume_rows(rows, kind="baseline_hold_sample")
checkpoints = _latest_resume_rows(rows, kind="steady_command_sample")
@@ -852,6 +1102,21 @@ def resumable_completed_task_prefix(
-1,
)
)
+ reusable.extend(
+ dict(row)
+ for row in rows
+ if row.get("kind") == "palm_axis_sample"
+ and str(row.get("task_name", "")) in completed_set
+ and int(row.get("attempt", 1))
+ == latest_task_attempt.get(
+ (
+ str(row.get("task_name", "")),
+ int(row.get("cycle", -1)),
+ str(row.get("direction", "")),
+ ),
+ -1,
+ )
+ )
reusable.sort(
key=lambda row: (
profile.sweep_specs.index(
@@ -935,6 +1200,8 @@ def _fit_failure_is_systematic(
"""Identify a repeatable model conflict that more motion cannot repair."""
if not failures or int(repetitions) <= 1:
return False
+ if all(bool(item.get("systematic", False)) for item in failures):
+ return True
if any(
str(item.get("metric", ""))
!= "rotation_circle_axis_difference_deg"
@@ -961,6 +1228,170 @@ def _fit_failure_is_systematic(
)
+def _cross_view_curve_failure(
+ primary_name: str,
+ validation_name: str,
+ primary: JointCurveFit,
+ validation: JointCurveFit,
+ *,
+ scope: str,
+ maximum_rms_difference_rad: float,
+ maximum_branch_gap_difference_rad: float,
+ allow_projection_scale: bool = False,
+ maximum_projection_scale_ratio: float = 1.5,
+ cycle_fits: Sequence[tuple[JointCurveFit, JointCurveFit]] = (),
+) -> dict[str, Any] | None:
+ """Return a localized, retry-aware cross-view failure.
+
+ A stable offset repeated in every cycle is an installation/model conflict;
+ reacquiring the same four motions cannot change it. A single bad cycle is
+ instead localized so only that side-view cycle is reacquired.
+ """
+ try:
+ compare_cross_view_roll_curves(
+ primary,
+ validation,
+ maximum_rms_difference_rad=maximum_rms_difference_rad,
+ maximum_branch_gap_difference_rad=(
+ maximum_branch_gap_difference_rad
+ ),
+ allow_projection_scale=allow_projection_scale,
+ maximum_projection_scale_ratio=(
+ maximum_projection_scale_ratio
+ ),
+ )
+ return None
+ except ValueError as error:
+ reason = str(error)
+
+ metrics: dict[str, float] = {}
+ if reason != "cross_view_roll_curve_direction_disagrees":
+ metrics = compare_cross_view_roll_curves(
+ primary,
+ validation,
+ maximum_rms_difference_rad=float("inf"),
+ maximum_branch_gap_difference_rad=float("inf"),
+ allow_projection_scale=allow_projection_scale,
+ maximum_projection_scale_ratio=float("inf"),
+ )
+ failure: dict[str, Any] = {
+ "joint": primary_name,
+ "metric": "cross_view_roll_curve",
+ "reason": reason,
+ "scope": scope,
+ "quality_source_joints": [validation_name],
+ }
+ metric_key: str | None = None
+ metric_limit: float | None = None
+ metric_in_degrees = True
+ if reason.startswith("cross_view_roll_curve_difference_too_large:"):
+ field_name = reason.split(":", 2)[1]
+ metric_key = f"{field_name}_rms_difference_rad"
+ metric_limit = float(maximum_rms_difference_rad)
+ elif reason.startswith(
+ "cross_view_roll_branch_gap_difference_too_large:"
+ ):
+ metric_key = "baseline_branch_gap_difference_rad"
+ metric_limit = float(maximum_branch_gap_difference_rad)
+ elif reason.startswith(
+ "cross_view_roll_projection_scale_ratio_too_large:"
+ ):
+ metric_key = "projection_scale_ratio"
+ metric_limit = float(maximum_projection_scale_ratio)
+ metric_in_degrees = False
+
+ cycle_values_rad: list[float] = []
+ cycle_direction_disagrees: list[bool] = []
+ for primary_cycle, validation_cycle in cycle_fits:
+ try:
+ cycle_metrics = compare_cross_view_roll_curves(
+ primary_cycle,
+ validation_cycle,
+ maximum_rms_difference_rad=float("inf"),
+ maximum_branch_gap_difference_rad=float("inf"),
+ allow_projection_scale=allow_projection_scale,
+ maximum_projection_scale_ratio=float("inf"),
+ )
+ except ValueError as cycle_error:
+ if str(cycle_error) != "cross_view_roll_curve_direction_disagrees":
+ raise
+ cycle_metrics = {}
+ if metric_key is not None:
+ value = cycle_metrics.get(metric_key)
+ if value is not None:
+ cycle_values_rad.append(float(value))
+ primary_travel = float(
+ primary_cycle.angle_rad[0] - primary_cycle.angle_rad[255]
+ )
+ validation_travel = float(
+ validation_cycle.angle_rad[0]
+ - validation_cycle.angle_rad[255]
+ )
+ cycle_direction_disagrees.append(
+ primary_travel * validation_travel <= 0.0
+ )
+
+ if metric_key is not None and metric_limit is not None:
+ actual_value = float(metrics[metric_key])
+ rendered_actual = (
+ math.degrees(actual_value)
+ if metric_in_degrees
+ else actual_value
+ )
+ rendered_limit = (
+ math.degrees(metric_limit)
+ if metric_in_degrees
+ else metric_limit
+ )
+ failure.update(
+ {
+ "actual": round(rendered_actual, 6),
+ "limit": round(rendered_limit, 6),
+ "comparison": "maximum",
+ "unit": "deg" if metric_in_degrees else "ratio",
+ }
+ )
+ if cycle_values_rad:
+ values = np.asarray(cycle_values_rad, dtype=float)
+ cycle_key = (
+ "cycle_values_deg"
+ if metric_in_degrees
+ else "cycle_values_ratio"
+ )
+ failure[cycle_key] = [
+ round(
+ math.degrees(value) if metric_in_degrees else value,
+ 6,
+ )
+ for value in values
+ ]
+ over = np.flatnonzero(values > metric_limit)
+ if (
+ over.size == 1
+ and np.all(
+ np.delete(values, over[0]) <= 0.9 * metric_limit
+ )
+ ):
+ failure["cycle"] = int(over[0]) + 1
+ # All cycles tell the same near-threshold story: this is a fixed
+ # view/mount/model bias, not random data that another scan heals.
+ if (
+ np.all(values >= 0.85 * metric_limit)
+ and float(np.ptp(values)) <= 0.35 * metric_limit
+ ):
+ failure["systematic"] = True
+ failure["classification"] = (
+ "stable_cross_view_installation_or_model_bias"
+ )
+ elif reason == "cross_view_roll_curve_direction_disagrees" and (
+ cycle_direction_disagrees
+ and all(cycle_direction_disagrees)
+ ):
+ failure["systematic"] = True
+ failure["classification"] = "stable_cross_view_direction_conflict"
+ return failure
+
+
def _fit_failure_repeats_branch_clusters(
previous: Sequence[Mapping[str, Any]],
current: Sequence[Mapping[str, Any]],
@@ -1095,6 +1526,58 @@ class SweepItem:
return 0 if self.direction == DIRECTION_DECREASING else 255
+def _build_sweep_plan(
+ profile: HandCalibrationProfile, repetitions: int
+) -> tuple[SweepItem, ...]:
+ """Build one deterministic qualification-and-measurement transaction.
+
+ Each product task owns a low-speed outbound/return qualification followed
+ by fixed-order formal cycles. Keeping this construction in one place
+ prevents resume, progress and runtime transitions from silently inventing
+ different task lifecycles.
+ """
+ plan: list[SweepItem] = []
+ for spec in profile.sweep_specs:
+ if profile.supports("precheck_sweeps"):
+ plan.extend(
+ SweepItem(spec, -1, direction, precheck=True)
+ for direction in (
+ DIRECTION_DECREASING,
+ DIRECTION_INCREASING,
+ )
+ )
+ plan.extend(
+ SweepItem(spec, cycle, direction)
+ for cycle in range(int(repetitions))
+ for direction in (
+ DIRECTION_DECREASING,
+ DIRECTION_INCREASING,
+ )
+ )
+ return tuple(plan)
+
+
+def _sweep_plan_transition(
+ profile: HandCalibrationProfile,
+ completed: SweepItem,
+ following: SweepItem,
+) -> str:
+ """Return the only allowed boundary between two planned sweep items."""
+ if completed.spec != following.spec:
+ return "task_change"
+ # Both qualification directions and the qualification-to-formal handoff
+ # are one continuous transaction. The physical endpoint and task
+ # clearance pose are unchanged, so a baseline detour can only discard
+ # good observations and perturb the PnP branch.
+ if completed.precheck:
+ return "immediate_reverse"
+ if completed.direction == DIRECTION_INCREASING:
+ # Formal cycles deliberately re-establish the same mechanical approach
+ # between repetitions so hysteresis measurements remain comparable.
+ return "cycle_reset"
+ return "immediate_reverse"
+
+
@dataclass(frozen=True)
class ValidationItem:
spec: SweepSpec
@@ -1252,6 +1735,15 @@ class ViewRuntime:
latest_tag_quality: dict[str, TagQuality] = field(default_factory=dict)
latest_pnp_rejections: dict[str, str] = field(default_factory=dict)
latest_group_pnp_reason: str = ""
+ latest_group_missing_candidate_roles: tuple[str, ...] = ()
+ # Task-scoped counters preserve the reasons that occurred before the last
+ # camera frame. A final ``group_initializing:5/8`` frame must not hide a
+ # recurring per-Tag rejection or a completed-window geometry rejection.
+ pnp_rejection_counts: dict[str, int] = field(default_factory=dict)
+ group_pnp_rejection_counts: dict[str, int] = field(default_factory=dict)
+ pnp_initialization_progress: tuple[int, int] | None = None
+ latest_pnp_valid: bool = False
+ last_pnp_diagnostic_signature: tuple[Any, ...] | None = None
pnp_invalid_since: float | None = None
pnp_reset_count: int = 0
last_message_at: float = 0.0
@@ -1349,6 +1841,9 @@ class G20ThreeCameraCalibrationNode(Node):
self.sweep_items: list[SweepItem] = []
self.sweep_index = 0
self.active_sweep: SweepItem | None = None
+ # Owns required Tag roles and PnP continuity across the brief
+ # active_sweep=None interval used for same-task cycle resets.
+ self.pnp_task_spec: SweepSpec | None = None
self.sweep_frames: list[FrameObservation] = []
# Roll joints use command 127 as their zero. A frame acquired while
# merely passing 127 contains velocity/latency error and is not a
@@ -1368,6 +1863,11 @@ class G20ThreeCameraCalibrationNode(Node):
# target is commanded immediately, a fast motor can leave the endpoint
# before the next camera/state pair arrives.
self.sweep_start_frames: list[FrameObservation] = []
+ # A completed direction already supplies fresh, synchronised frames at
+ # the physical endpoint where the immediately reversed direction
+ # starts. Stage those frames across the state transition instead of
+ # discarding them and waiting again at the unchanged pose.
+ self.carried_sweep_start_frames: list[FrameObservation] = []
self.sweep_started_at = 0.0
self.sweep_last_valid_at = 0.0
self.sweep_last_valid_at_by_view: dict[str, float] = {}
@@ -1408,10 +1908,21 @@ class G20ThreeCameraCalibrationNode(Node):
self.command_records_by_joint: dict[str, list[dict[str, Any]]] = {
name: [] for name in self.profile.record_joints
}
+ self.palm_axis_records_by_source: dict[
+ str, list[dict[str, Any]]
+ ] = {
+ observer.source_name: []
+ for observer in self.profile.palm_axis_observers
+ }
self.measured_fits: dict[str, JointCurveFit] = {}
self.axis_measurements: list[JointAxisMeasurement] = []
+ self.palm_orientation_measurements: list[
+ PalmOrientationMeasurement
+ ] = []
+ self.palm_orientation_rejections: dict[str, str] = {}
self.zero_result: ZeroSolveResult | None = None
+ self.validated_endpoint_zero_offsets_rad: dict[str, float] = {}
self.corrected_urdf_path: Path | None = None
self.fit_quality_passed = False
@@ -1482,6 +1993,7 @@ class G20ThreeCameraCalibrationNode(Node):
)
def _declare_parameters(self) -> None:
+ self.declare_parameter("model", "G20")
self.declare_parameter("hand_type", "left")
self.declare_parameter("tag_layout", "legacy_11")
self.declare_parameter("serial_number", "UNSET")
@@ -1570,8 +2082,11 @@ class G20ThreeCameraCalibrationNode(Node):
)
self.declare_parameter("zero_maximum_offset_deg", 20.0)
self.declare_parameter("zero_finger_maximum_offset_deg", 3.0)
- self.declare_parameter("flexion_endpoint_maximum_offset_deg", 5.0)
+ self.declare_parameter("mechanical_endpoint_maximum_offset_deg", 5.0)
self.declare_parameter("endpoint_tolerance_u8", 2.0)
+ self.declare_parameter(
+ "synchronised_endpoint_tolerance_margin_u8", 1.0
+ )
self.declare_parameter(
"steady_checkpoint_command_feedback_tolerance_u8", 8.0
)
@@ -1632,20 +2147,15 @@ class G20ThreeCameraCalibrationNode(Node):
self.declare_parameter(
"cross_view_roll_maximum_branch_gap_difference_deg", 0.3
)
+ self.declare_parameter(
+ "cross_view_roll_maximum_shape_rms_deg", 1.25
+ )
+ self.declare_parameter(
+ "cross_view_roll_maximum_projection_scale_ratio", 1.5
+ )
self.declare_parameter(
"cross_view_roll_maximum_axis_difference_deg", 15.0
)
- self.declare_parameter(
- # The front roll-link axis line and the side PIP-link axis line
- # sit ~21 mm apart systematically (session 20260820_132727:
- # 20.75-21.13 mm over eight attempts) because the splay carriage
- # carries a screw translation; keep the gross bound above that
- # systematic while still failing on gross misalignment.
- "cross_view_roll_maximum_axis_line_difference_mm", 30.0
- )
- self.declare_parameter(
- "cross_view_roll_alias_maximum_branch_gap_range_deg", 0.5
- )
self.declare_parameter("passive_maximum_monotonic_correction_deg", 3.0)
self.declare_parameter("passive_maximum_hysteresis_deg", 7.5)
self.declare_parameter("command_maximum_direction_gap_deg", 2.0)
@@ -1678,14 +2188,14 @@ class G20ThreeCameraCalibrationNode(Node):
return ()
return () if result is None else result
+ self.model = str(value("model")).strip().upper()
self.hand_type = str(value("hand_type")).lower()
self.tag_layout = str(value("tag_layout")).lower()
- self.profile: HandCalibrationProfile = get_hand_calibration_profile(
- self.hand_type, self.tag_layout
- )
- self.zero_profile: ZeroCalibrationProfile = (
- get_zero_calibration_profile(self.hand_type, self.tag_layout)
+ product_contract = get_product_calibration_contract(
+ self.model, self.hand_type, self.tag_layout
)
+ self.profile = product_contract.profile
+ self.zero_profile = product_contract.zero_profile
self.serial_number = str(value("serial_number"))
if self.serial_number == "UNSET":
raise ValueError("serial_number is required")
@@ -1896,11 +2406,14 @@ class G20ThreeCameraCalibrationNode(Node):
self.zero_finger_maximum_offset_rad = math.radians(
float(value("zero_finger_maximum_offset_deg"))
)
- self.flexion_endpoint_maximum_offset_rad = math.radians(
- float(value("flexion_endpoint_maximum_offset_deg"))
+ self.mechanical_endpoint_maximum_offset_rad = math.radians(
+ float(value("mechanical_endpoint_maximum_offset_deg"))
)
self.zero_joint_maximum_offsets_rad = {}
self.endpoint_tolerance_u8 = float(value("endpoint_tolerance_u8"))
+ self.synchronised_endpoint_tolerance_margin_u8 = float(
+ value("synchronised_endpoint_tolerance_margin_u8")
+ )
self.steady_checkpoint_command_feedback_tolerance_u8 = float(
value("steady_checkpoint_command_feedback_tolerance_u8")
)
@@ -2010,15 +2523,15 @@ class G20ThreeCameraCalibrationNode(Node):
self.cross_view_roll_maximum_branch_gap_difference_rad = math.radians(
float(value("cross_view_roll_maximum_branch_gap_difference_deg"))
)
+ self.cross_view_roll_maximum_shape_rms_rad = math.radians(
+ float(value("cross_view_roll_maximum_shape_rms_deg"))
+ )
+ self.cross_view_roll_maximum_projection_scale_ratio = float(
+ value("cross_view_roll_maximum_projection_scale_ratio")
+ )
self.cross_view_roll_maximum_axis_difference_rad = math.radians(
float(value("cross_view_roll_maximum_axis_difference_deg"))
)
- self.cross_view_roll_maximum_axis_line_difference_m = 0.001 * float(
- value("cross_view_roll_maximum_axis_line_difference_mm")
- )
- self.cross_view_roll_alias_maximum_branch_gap_range_rad = math.radians(
- float(value("cross_view_roll_alias_maximum_branch_gap_range_deg"))
- )
self.passive_maximum_monotonic_correction_rad = math.radians(
float(value("passive_maximum_monotonic_correction_deg"))
)
@@ -2059,13 +2572,17 @@ class G20ThreeCameraCalibrationNode(Node):
self.zero_maximum_confidence_half_width_rad = math.radians(
float(value("zero_maximum_confidence_half_width_deg"))
)
- if len(self.baseline_command) != 20:
- raise ValueError("baseline_command_u8 must contain exactly 20 values")
+ if len(self.baseline_command) != self.profile.command_count:
+ raise ValueError(
+ "baseline_command_u8 length does not match the registered "
+ f"product command schema ({self.profile.command_count})"
+ )
if any(value < 0 or value > 255 for value in self.baseline_command):
raise ValueError("baseline_command_u8 values must be in [0, 255]")
- if self.baseline_command != THREE_CAMERA_BASELINE_COMMAND:
+ if self.baseline_command != tuple(self.profile.baseline_command):
raise ValueError(
- "schema v4 requires the fixed G20 three-camera baseline"
+ "baseline_command_u8 differs from the registered product "
+ "calibration baseline"
)
if any(not serial for serial in self.camera_serials.values()):
raise ValueError("front/side/top camera serial parameters are required")
@@ -2145,6 +2662,10 @@ class G20ThreeCameraCalibrationNode(Node):
raise ValueError("speed_setting_settle_seconds must be non-negative")
if not 0.0 <= self.endpoint_tolerance_u8 <= 10.0:
raise ValueError("endpoint_tolerance_u8 must be in [0, 10]")
+ if not 0.0 <= self.synchronised_endpoint_tolerance_margin_u8 <= 4.0:
+ raise ValueError(
+ "synchronised_endpoint_tolerance_margin_u8 must be in [0, 4]"
+ )
if not (
self.endpoint_tolerance_u8
<= self.steady_checkpoint_command_feedback_tolerance_u8
@@ -2217,7 +2738,7 @@ class G20ThreeCameraCalibrationNode(Node):
self.zero_maximum_axis_cone_mismatch_rad,
self.zero_maximum_offset_rad,
self.zero_finger_maximum_offset_rad,
- self.flexion_endpoint_maximum_offset_rad,
+ self.mechanical_endpoint_maximum_offset_rad,
self.maximum_validation_error_rad,
self.zero_maximum_confidence_half_width_rad,
self.task_precheck_hold_seconds,
@@ -2229,12 +2750,17 @@ class G20ThreeCameraCalibrationNode(Node):
self.directional_zero_maximum_branch_gap_rad,
self.directional_zero_maximum_branch_gap_range_rad,
self.cross_view_roll_maximum_branch_gap_difference_rad,
+ self.cross_view_roll_maximum_shape_rms_rad,
self.passive_maximum_monotonic_correction_rad,
self.passive_maximum_hysteresis_rad,
self.command_maximum_direction_gap_rad,
)
):
raise ValueError("trajectory quality thresholds must be positive")
+ if not 1.0 < self.cross_view_roll_maximum_projection_scale_ratio <= 2.0:
+ raise ValueError(
+ "cross-view roll projection-scale ratio must be in (1, 2]"
+ )
if self.zero_finger_maximum_offset_rad > self.zero_maximum_offset_rad:
raise ValueError(
"zero_finger_maximum_offset_deg cannot exceed "
@@ -2327,26 +2853,15 @@ class G20ThreeCameraCalibrationNode(Node):
)
if pair[0] in role_set and pair[1] in role_set
)
- # The side-view palm/reference-finger Tags are mounted on parallel
- # outward-facing surfaces at the endpoint where a task tracker is
- # initialized. Temporal rigidity alone cannot distinguish two fully
- # static IPPE mirror branches; their face-normal consistency can. Do
- # not apply this prior to the articulated front thumb chain.
- normal_alignment_pairs = (
- tuple(
- pair
- for pair in adjacent_pairs
- # Distal 16 mm Tags may be carried by a rigid extension rod
- # to remain visible through PIP flexion. Its mounting plane
- # is arbitrary and must not be required to be parallel to the
- # middle-phalanx Tag. Static rigidity and relative-pose
- # continuity still disambiguate that PIP->DIP pair; retain
- # the palm/base->middle-Tag normal prior unchanged.
- if not pair[1].endswith("_dip")
- )
- if name == "side" and len(role_tuple) <= 4
- else ()
- )
+ # Do not impose an absolute face-normal relationship between the
+ # fixed palm Tag and an articulated finger Tag. That angle depends on
+ # the physical Tag bracket and the endpoint joint pose; it is not a
+ # product invariant. The old "nearly parallel" prior repeatedly
+ # cleared otherwise stable 8-frame initialisation windows for ID4/ID5
+ # on ring MCP flexion. Multi-frame relative rigidity, reprojection
+ # error and the task reference retained across cycles provide the
+ # valid branch evidence without assuming a particular mounting plane.
+ normal_alignment_pairs: tuple[tuple[str, str], ...] = ()
thumb_mcp_ip_roles = {"thumb_cmc", "thumb_mcp", "thumb_ip"}
thumb_mcp_ip_coupling = ()
if name == "front" and thumb_mcp_ip_roles.issubset(role_set):
@@ -2456,6 +2971,14 @@ class G20ThreeCameraCalibrationNode(Node):
and view in _sweep_views(_node_profile(self), self.retry_sweep_spec)
):
active_spec = self.retry_sweep_spec
+ elif (
+ getattr(self, "pnp_task_spec", None) is not None
+ and view
+ in _sweep_views(
+ _node_profile(self), self.pnp_task_spec
+ )
+ ):
+ active_spec = self.pnp_task_spec
if active_spec is None:
return tuple(runtime.preflight_roles)
required: set[str] = set()
@@ -2495,6 +3018,8 @@ class G20ThreeCameraCalibrationNode(Node):
spec = self.active_validation.spec
elif self.retry_sweep_spec is not None:
spec = self.retry_sweep_spec
+ elif getattr(self, "pnp_task_spec", None) is not None:
+ spec = self.pnp_task_spec
if spec is None or not _sweep_uses_locked_base_reference(
_node_profile(self), spec, view
):
@@ -2618,6 +3143,162 @@ class G20ThreeCameraCalibrationNode(Node):
preserve_task_reference=preserve_task_reference
)
+ @staticmethod
+ def _reset_view_pnp_diagnostics(runtime: ViewRuntime) -> None:
+ """Clear PnP evidence only when a new capture scope begins."""
+ runtime.latest_pnp_rejections.clear()
+ runtime.latest_group_pnp_reason = ""
+ runtime.latest_group_missing_candidate_roles = ()
+ runtime.pnp_rejection_counts.clear()
+ runtime.group_pnp_rejection_counts.clear()
+ runtime.pnp_initialization_progress = None
+ runtime.latest_pnp_valid = False
+ runtime.last_pnp_diagnostic_signature = None
+
+ def _record_group_pnp_candidate_event(
+ self,
+ *,
+ runtime: ViewRuntime,
+ stamp_ns: int,
+ required_roles: Sequence[str],
+ pose_roles: Sequence[str],
+ corners_by_role: Mapping[str, np.ndarray],
+ qualities: Mapping[str, TagQuality],
+ matched_tracking: tuple[tuple[float, ...], int] | None,
+ selected: Mapping[str, SquareTagPose] | None,
+ pnp_rejections: Mapping[str, str],
+ group_pnp_reason: str,
+ ) -> None:
+ """Persist bounded, replayable evidence for group candidate decisions.
+
+ Valid FrameObservation records intentionally contain only accepted
+ poses. Without a separate event, a deterministic PnP dropout leaves
+ no corners, per-candidate tilt, reprojection error or missing role in
+ raw_samples.jsonl, making the next run guess at the cause. Record one
+ event per eight-count motor bucket and decision signature. This is
+ dense enough to locate a repeatable geometry boundary without writing
+ every 30 Hz rejected camera message.
+ """
+ item = self.active_sweep
+ if (
+ item is None
+ or self.state not in {STATE_PREPARE_SWEEP, STATE_SWEEP}
+ or runtime.name not in _sweep_views(_node_profile(self), item.spec)
+ ):
+ return
+ candidate_diagnostics = {
+ role: dict(
+ runtime.tracker.last_candidate_diagnostics_by_role.get(
+ role, {}
+ )
+ )
+ for role in pose_roles
+ if runtime.tracker.last_candidate_diagnostics_by_role.get(role)
+ }
+ oblique_group_roles = tuple(
+ role
+ for role, diagnostics in candidate_diagnostics.items()
+ if int(diagnostics.get("reprojection_candidate_count", 0)) > 0
+ and int(
+ diagnostics.get("independent_tilt_candidate_count", 0)
+ )
+ == 0
+ )
+ if (
+ not group_pnp_reason
+ and not pnp_rejections
+ and not oblique_group_roles
+ ):
+ return
+ if selected is not None and not oblique_group_roles:
+ return
+ state_u8 = (
+ matched_tracking[0]
+ if matched_tracking is not None
+ else self.latest_state_u8
+ )
+ motor_value = (
+ None
+ if len(state_u8) != 20
+ else float(state_u8[item.spec.motor_index])
+ )
+ motor_bucket = (
+ -1
+ if motor_value is None or not math.isfinite(motor_value)
+ else int(np.clip(math.floor(motor_value / 8.0), 0, 31))
+ )
+ missing_roles = tuple(runtime.latest_group_missing_candidate_roles)
+ signature = (
+ item.spec.key,
+ item.cycle,
+ item.direction,
+ motor_bucket,
+ selected is not None,
+ str(group_pnp_reason),
+ tuple(
+ sorted(
+ (str(key), str(value))
+ for key, value in pnp_rejections.items()
+ )
+ ),
+ missing_roles,
+ oblique_group_roles,
+ )
+ if runtime.last_pnp_diagnostic_signature == signature:
+ return
+ runtime.last_pnp_diagnostic_signature = signature
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "group_pnp_candidate_event",
+ "outcome": (
+ "selected_with_oblique_group_constraint"
+ if selected is not None
+ else "group_pose_rejected"
+ ),
+ "view": runtime.name,
+ "task_name": item.spec.key,
+ "motor_index": item.spec.motor_index,
+ "cycle": item.cycle,
+ "direction": item.direction,
+ "feedback_u8": motor_value,
+ "feedback_bucket_u8": (
+ None if motor_bucket < 0 else motor_bucket * 8
+ ),
+ "image_stamp_ns": int(stamp_ns),
+ "required_roles": list(required_roles),
+ "required_tag_ids": {
+ role: runtime.view_tags[role]
+ for role in required_roles
+ },
+ "group_pnp_reason": str(group_pnp_reason),
+ "group_missing_candidate_roles": list(missing_roles),
+ "pnp_rejections": dict(pnp_rejections),
+ "oblique_group_roles": list(oblique_group_roles),
+ "candidate_diagnostics": candidate_diagnostics,
+ "tag_quality": {
+ role: {
+ "hamming": int(quality.hamming),
+ "decision_margin": float(quality.decision_margin),
+ "mean_edge_pixels": float(quality.edge_pixels),
+ }
+ for role, quality in qualities.items()
+ if role in required_roles
+ },
+ "corners_xy": {
+ role: np.asarray(corners, dtype=float).tolist()
+ for role, corners in corners_by_role.items()
+ if role in required_roles
+ },
+ "camera_intrinsics_sha256": runtime.intrinsics_sha256,
+ "camera_matrix": (
+ None
+ if runtime.camera_matrix is None
+ else runtime.camera_matrix.tolist()
+ ),
+ },
+ )
+
def _camera_info_callback(self, view: str, message: CameraInfo) -> None:
runtime = self.views[view]
valid = (
@@ -2669,11 +3350,15 @@ class G20ThreeCameraCalibrationNode(Node):
)
def _state_callback(self, message: JointState) -> None:
- if len(message.position) != 20:
+ command_names = _command_names(self)
+ if len(message.position) != len(command_names):
return
- if len(message.name) == 20 and set(message.name) == set(COMMAND_NAMES):
+ if (
+ len(message.name) == len(command_names)
+ and set(message.name) == set(command_names)
+ ):
lookup = dict(zip(message.name, message.position))
- state = tuple(float(lookup[name]) for name in COMMAND_NAMES)
+ state = tuple(float(lookup[name]) for name in command_names)
else:
state = tuple(float(value) for value in message.position)
stamp = _stamp_ns(message.header.stamp)
@@ -2715,6 +3400,19 @@ class G20ThreeCameraCalibrationNode(Node):
live_required_roles = self._live_required_roles_for_view(
view, required_roles
)
+ palm_observer: PalmAxisObserver | None = None
+ if (
+ self.active_sweep is not None
+ and self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP}
+ ):
+ palm_observer = _palm_axis_observer_for_sweep(
+ _node_profile(self), self.active_sweep.spec, view
+ )
+ palm_observer_roles = (
+ ()
+ if palm_observer is None
+ else (palm_observer.parent_role, palm_observer.child_role)
+ )
combination_capture = bool(
self.active_combination_validation is not None
and self.state in {STATE_VALIDATION_MOVE, STATE_VALIDATION_CAPTURE}
@@ -2723,8 +3421,7 @@ class G20ThreeCameraCalibrationNode(Node):
runtime.current_required_roles = required_roles
runtime.valid_flags.clear()
runtime.detection_times.clear()
- runtime.latest_pnp_rejections.clear()
- runtime.latest_group_pnp_reason = ""
+ self._reset_view_pnp_diagnostics(runtime)
runtime.task_valid_frames = 0
runtime.task_total_frames = 0
self._reset_view_trackers(runtime)
@@ -2759,7 +3456,22 @@ class G20ThreeCameraCalibrationNode(Node):
and self._quality_valid(qualities[role], include_pnp=False)
)
if combination_capture
- else live_required_roles
+ else tuple(
+ dict.fromkeys(
+ (
+ *live_required_roles,
+ *(
+ role
+ for role in palm_observer_roles
+ if role in qualities
+ and role != locked_base_role
+ and self._quality_valid(
+ qualities[role], include_pnp=False
+ )
+ ),
+ )
+ )
+ )
)
observable_combination_joints = (
_combination_observable_joints(
@@ -2789,7 +3501,12 @@ class G20ThreeCameraCalibrationNode(Node):
matched_tracking: tuple[tuple[float, ...], int] | None = None
if (
self.active_sweep is not None
- and view in _sweep_views(_node_profile(self), self.active_sweep.spec)
+ and (
+ view in _sweep_views(
+ _node_profile(self), self.active_sweep.spec
+ )
+ or palm_observer is not None
+ )
and self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP}
):
matched_tracking = interpolate_state_u8(
@@ -2842,13 +3559,19 @@ class G20ThreeCameraCalibrationNode(Node):
camera_matrix=runtime.camera_matrix,
stamp_ns=stamp,
)
- if rejection:
- pnp_rejections[role] = rejection
if pose is not None:
individual_selected[role] = pose
candidates[role] = runtime.tracker.last_candidates_by_role.get(
role, ()
)
+ # A group-constrained task deliberately retains an oblique
+ # candidate that the independent per-Tag selector would not
+ # use. Report a rejection only if candidate generation itself
+ # failed; otherwise the group tracker owns the decision.
+ if rejection and (
+ combination_capture or not candidates[role]
+ ):
+ pnp_rejections[role] = rejection
if combination_capture:
# Each visible target is tracked independently. This avoids
# an exponential all-Tag IPPE search and, more importantly,
@@ -2869,6 +3592,26 @@ class G20ThreeCameraCalibrationNode(Node):
trajectory_command_u8=tracking_command_u8,
trajectory_direction=tracking_direction,
)
+ runtime.latest_group_missing_candidate_roles = (
+ group_tracker.last_missing_roles
+ )
+ if selected is not None and palm_observer_roles:
+ selected = dict(selected)
+ for role in palm_observer_roles:
+ pose = individual_selected.get(role)
+ quality = qualities.get(role)
+ if pose is None or quality is None:
+ continue
+ pose_quality = TagQuality(
+ hamming=quality.hamming,
+ decision_margin=quality.decision_margin,
+ edge_pixels=quality.edge_pixels,
+ reprojection_error_px=pose.reprojection_error_px,
+ )
+ if self._quality_valid(
+ pose_quality, include_pnp=True
+ ):
+ selected[role] = pose
if selected is not None:
qualities = _selected_pose_qualities(
selected,
@@ -2899,7 +3642,11 @@ class G20ThreeCameraCalibrationNode(Node):
and all(role in selected for role in required_roles)
and all(
self._quality_valid(qualities[role], include_pnp=True)
- for role in selected
+ for role in (
+ selected
+ if combination_capture
+ else required_roles
+ )
)
and (
not combination_capture
@@ -2934,6 +3681,54 @@ class G20ThreeCameraCalibrationNode(Node):
valid_by_view[view] = valid_by_view.get(view, 0) + 1
runtime.latest_pnp_rejections = pnp_rejections
runtime.latest_group_pnp_reason = group_pnp_reason
+ if not group_pnp_reason:
+ runtime.latest_group_missing_candidate_roles = ()
+ runtime.latest_pnp_valid = valid
+ task_pnp_capture = bool(
+ self.active_sweep is not None
+ and self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP}
+ and view in _sweep_views(
+ _node_profile(self), self.active_sweep.spec
+ )
+ )
+ if task_pnp_capture:
+ for role, rejection in pnp_rejections.items():
+ key = f"{role}:{rejection}"
+ runtime.pnp_rejection_counts[key] = (
+ runtime.pnp_rejection_counts.get(key, 0) + 1
+ )
+ if group_pnp_reason.startswith("group_initializing:"):
+ try:
+ progress = group_pnp_reason.split(":", 1)[1]
+ accepted, required = progress.split("/", 1)
+ runtime.pnp_initialization_progress = (
+ int(accepted),
+ int(required),
+ )
+ except (IndexError, TypeError, ValueError):
+ runtime.pnp_initialization_progress = None
+ elif group_pnp_reason:
+ runtime.group_pnp_rejection_counts[group_pnp_reason] = (
+ runtime.group_pnp_rejection_counts.get(
+ group_pnp_reason, 0
+ )
+ + 1
+ )
+ elif selected is not None and runtime.pnp_initialization_progress:
+ required = runtime.pnp_initialization_progress[1]
+ runtime.pnp_initialization_progress = (required, required)
+ self._record_group_pnp_candidate_event(
+ runtime=runtime,
+ stamp_ns=stamp,
+ required_roles=required_roles,
+ pose_roles=pose_roles,
+ corners_by_role=corners_by_role,
+ qualities=qualities,
+ matched_tracking=matched_tracking,
+ selected=selected,
+ pnp_rejections=pnp_rejections,
+ group_pnp_reason=group_pnp_reason,
+ )
if view == "top":
runtime.pnp_invalid_since, reset_due = update_pnp_reset_watchdog(
detection_good=bool(
@@ -3013,7 +3808,16 @@ class G20ThreeCameraCalibrationNode(Node):
sweep_capture = bool(
self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP}
and self.active_sweep is not None
- and view in _sweep_views(_node_profile(self), self.active_sweep.spec)
+ and view in _sweep_views(
+ _node_profile(self), self.active_sweep.spec
+ )
+ )
+ palm_axis_capture = bool(
+ self.state == STATE_SWEEP
+ and self.active_sweep is not None
+ and not self.active_sweep.precheck
+ and self.active_sweep.cycle >= 0
+ and palm_observer is not None
)
validation_capture = bool(
self.state == STATE_VALIDATION_CAPTURE
@@ -3027,7 +3831,7 @@ class G20ThreeCameraCalibrationNode(Node):
or self.active_combination_validation is not None
)
)
- if not sweep_capture and not validation_capture:
+ if not sweep_capture and not validation_capture and not palm_axis_capture:
return
matched = matched_tracking
@@ -3049,6 +3853,17 @@ class G20ThreeCameraCalibrationNode(Node):
if self.extrinsics is None or not runtime.extrinsics_valid:
return
front_from_view = self.extrinsics.transform(view)
+ if palm_axis_capture and palm_observer is not None:
+ self._record_palm_axis_sample(
+ palm_observer,
+ selected,
+ front_from_view,
+ state_u8,
+ sync_error_ns=int(sync_error_ns),
+ stamp_ns=stamp,
+ )
+ if not sweep_capture and not validation_capture:
+ return
for name, spec in self.profile.record_specs.items():
if not spec.measured or spec.view != view:
continue
@@ -3104,6 +3919,113 @@ class G20ThreeCameraCalibrationNode(Node):
)
self._accept_frame(observation)
+ def _record_palm_axis_sample(
+ self,
+ observer: PalmAxisObserver,
+ selected: Mapping[str, SquareTagPose],
+ common_from_view: np.ndarray,
+ state_u8: Sequence[float],
+ *,
+ sync_error_ns: int,
+ stamp_ns: int,
+ ) -> None:
+ """Buffer one direction-only sample without touching sweep validity."""
+ item = self.active_sweep
+ if (
+ item is None
+ or item.spec.key != observer.task_name
+ or observer.parent_role not in selected
+ or observer.child_role not in selected
+ ):
+ return
+ parent = selected[observer.parent_role]
+ child = selected[observer.child_role]
+ parent_matrix = common_from_view @ transform_matrix(
+ parent.translation_xyz_m, parent.quaternion_xyzw
+ )
+ child_matrix = common_from_view @ transform_matrix(
+ child.translation_xyz_m, child.quaternion_xyzw
+ )
+ relative_rotation = (
+ Rotation.from_matrix(parent_matrix[:3, :3]).inv()
+ * Rotation.from_matrix(child_matrix[:3, :3])
+ )
+ feedback = float(state_u8[observer.motor_index])
+ record = {
+ "kind": "palm_axis_sample",
+ "attempt": self.sweep_attempts.get(
+ _sweep_storage_key(item.spec), 1
+ ),
+ "task_name": item.spec.key,
+ "view": observer.view,
+ "source_joint": observer.source_name,
+ "model_joint": observer.model_joint,
+ "motor_index": int(observer.motor_index),
+ "cycle": int(item.cycle),
+ "direction": item.direction,
+ "command_u8": int(np.clip(np.rint(feedback), 0, 255)),
+ "relative_quaternion_xyzw": [
+ float(value) for value in relative_rotation.as_quat()
+ ],
+ "parent_pose_common": matrix_payload(parent_matrix),
+ "child_pose_common": matrix_payload(child_matrix),
+ "state_u8": [float(value) for value in state_u8],
+ "state_image_sync_error_ms": round(
+ abs(int(sync_error_ns)) / 1_000_000.0, 6
+ ),
+ "pnp_reprojection_error_px": round(
+ max(
+ float(parent.reprojection_error_px),
+ float(child.reprojection_error_px),
+ ),
+ 6,
+ ),
+ "image_stamp_ns": int(stamp_ns),
+ }
+ self.palm_axis_records_by_source.setdefault(
+ observer.source_name, []
+ ).append(record)
+
+ def _persist_palm_axis_samples(self, item: SweepItem) -> None:
+ """Commit the side channel only after its authoritative sweep passes."""
+ observer = next(
+ (
+ candidate
+ for candidate in self.profile.palm_axis_observers
+ if candidate.task_name == item.spec.key
+ ),
+ None,
+ )
+ if observer is None:
+ return
+ attempt = int(
+ self.sweep_attempts.get(_sweep_storage_key(item.spec), 1)
+ )
+ rows = [
+ record
+ for record in self.palm_axis_records_by_source.get(
+ observer.source_name, ()
+ )
+ if int(record.get("attempt", 1)) == attempt
+ and int(record.get("cycle", -1)) == int(item.cycle)
+ and str(record.get("direction", "")) == item.direction
+ ]
+ append_jsonl_many(
+ self.raw_path,
+ (
+ {
+ **{
+ key: value
+ for key, value in record.items()
+ if key != "command_u8"
+ },
+ "requested_command_u8": int(item.target_u8),
+ "feedback_u8": int(record["command_u8"]),
+ }
+ for record in rows
+ ),
+ )
+
def _accept_frame(self, observation: FrameObservation) -> None:
if self.state == STATE_PREPARE_SWEEP and self.active_sweep is not None:
if observation.view not in _sweep_views(
@@ -3119,7 +4041,11 @@ class G20ThreeCameraCalibrationNode(Node):
self.sweep_start_frames.append(observation)
maximum_start_frames = max(
1, int(getattr(self, "preflight_frames", 30))
- ) * len(_sweep_views(_node_profile(self), self.active_sweep.spec))
+ ) * len(
+ _sweep_views(
+ _node_profile(self), self.active_sweep.spec
+ )
+ )
if len(self.sweep_start_frames) > maximum_start_frames:
del self.sweep_start_frames[:-maximum_start_frames]
return
@@ -3296,7 +4222,7 @@ class G20ThreeCameraCalibrationNode(Node):
def _resume_preflight_ready(self, now: float) -> bool:
if (
- len(self.latest_state_u8) != 20
+ len(self.latest_state_u8) != _command_count(self)
or now - self.last_state_at > 1.0
):
return False
@@ -3341,6 +4267,23 @@ class G20ThreeCameraCalibrationNode(Node):
for record in command_records[joint_name]
if int(record.get("cycle", -1)) not in cycles
]
+ observer = next(
+ (
+ item
+ for item in _node_profile(self).palm_axis_observers
+ if item.task_name == spec.key
+ ),
+ None,
+ )
+ if observer is not None:
+ records = self.palm_axis_records_by_source.get(
+ observer.source_name, []
+ )
+ records[:] = [
+ record
+ for record in records
+ if int(record.get("cycle", -1)) not in cycles
+ ]
all_items = getattr(self, "sweep_items", [])
self.retry_sweep_items = [
item
@@ -3367,13 +4310,18 @@ class G20ThreeCameraCalibrationNode(Node):
_node_profile(self).record_specs[name].view
for name in retry_joint_names
}
+ if observer is not None:
+ retry_views.add(observer.view)
for view in retry_views:
if view is None:
continue
runtime = getattr(self, "views", {}).get(view)
if runtime is None or reset_trackers is None:
continue
- reset_trackers(runtime)
+ if _node_profile(self).layout_id == G20_RIGHT_19_LAYOUT:
+ reset_trackers(runtime, preserve_task_reference=True)
+ else:
+ reset_trackers(runtime)
runtime.pnp_invalid_since = None
runtime.pnp_reset_count += 1
# Detection quality is attempt-local. Carrying the previous
@@ -3403,9 +4351,77 @@ class G20ThreeCameraCalibrationNode(Node):
},
)
self.fit_failure = {}
+ G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(self)
getattr(self, "motion_stall_details", {}).clear()
return spec
+ def _invalidate_fitted_calibration_state(self) -> None:
+ """Invalidate every artifact derived from the current motion records.
+
+ A task rescan changes the observation set, so curves, zero offsets and
+ validation diagnostics must be treated as one generation. Keeping
+ this transition in one place prevents final publication from mixing a
+ newly collected task with state left by an earlier fit attempt.
+ """
+ self.measured_fits = {}
+ self.axis_measurements = []
+ self.palm_orientation_measurements = []
+ self.palm_orientation_rejections = {}
+ self.zero_result = None
+ self.validated_endpoint_zero_offsets_rad = {}
+ self.corrected_urdf_path = None
+ self.fit_quality_passed = False
+ self.validation_errors_rad = []
+ self.cross_view_roll_metrics = {}
+ self.validation_only_fits = {}
+ self.joint_dynamic_diagnostics = {}
+
+ def _endpoint_zero_offsets_for_publication(self) -> dict[str, float]:
+ """Return the endpoint anchors belonging to the validated zero fit."""
+ stored = {
+ str(name): float(value)
+ for name, value in getattr(
+ self, "validated_endpoint_zero_offsets_rad", {}
+ ).items()
+ }
+ if self.profile.layout_id != G20_RIGHT_19_LAYOUT:
+ if stored:
+ raise RuntimeError(
+ "validated_endpoint_zero_state_unexpected_for_layout"
+ )
+ return {}
+ expected = set(RIGHT_19_MECHANICAL_ENDPOINT_JOINTS)
+ actual = set(stored)
+ if actual != expected:
+ missing = ",".join(sorted(expected - actual)) or "-"
+ extra = ",".join(sorted(actual - expected)) or "-"
+ raise RuntimeError(
+ "validated_endpoint_zero_state_incomplete:"
+ f"missing={missing};extra={extra}"
+ )
+ if not all(math.isfinite(value) for value in stored.values()):
+ raise RuntimeError("validated_endpoint_zero_state_non_finite")
+ if self.zero_result is None:
+ raise RuntimeError("URDF zero solution is missing")
+ solved = self.zero_result.direct_offsets_rad
+ diverged = sorted(
+ name
+ for name, value in stored.items()
+ if name not in solved
+ or not math.isclose(
+ value,
+ float(solved[name]),
+ rel_tol=0.0,
+ abs_tol=1.0e-12,
+ )
+ )
+ if diverged:
+ raise RuntimeError(
+ "validated_endpoint_zero_state_diverged:"
+ + ",".join(diverged)
+ )
+ return stored
+
def _restore_durable_task_checkpoint(self) -> int:
"""Restore independently validated complete tasks from a failed session."""
source = self.resume_raw_samples_path
@@ -3439,8 +4455,19 @@ class G20ThreeCameraCalibrationNode(Node):
).items()
}
current_tag_sizes = dict(getattr(self, "tag_sizes_m_by_id", {}))
+ try:
+ (
+ palm_axis_schema_compatible,
+ palm_axis_invalidated_tasks,
+ ) = _palm_axis_resume_policy(self.profile, start)
+ except ValueError as error:
+ raise RuntimeError(
+ "resume checkpoint algorithm capabilities differ"
+ ) from error
if (
- str(start.get("hand_type")) != self.hand_type
+ str(start.get("model", "G20")).upper()
+ != str(getattr(self, "model", "G20")).upper()
+ or str(start.get("hand_type")) != self.hand_type
or str(start.get("tag_layout")) != self.profile.layout_id
or start.get("view_tags")
!= {view: dict(tags) for view, tags in self.profile.view_tags.items()}
@@ -3450,7 +4477,8 @@ class G20ThreeCameraCalibrationNode(Node):
!= _file_sha256(self.source_urdf_path)
):
raise RuntimeError(
- "resume checkpoint geometry, Tag layout, baseline or source URDF differs"
+ "resume checkpoint geometry, algorithm capabilities, Tag "
+ "layout, baseline or source URDF differs"
)
try:
changed_tag_size_ids, size_invalidated_tasks = (
@@ -3465,6 +4493,7 @@ class G20ThreeCameraCalibrationNode(Node):
"resume checkpoint Tag-size geometry differs incompatibly"
) from error
invalidated_task_set = set(size_invalidated_tasks)
+ invalidated_task_set.update(palm_axis_invalidated_tasks)
if invalidated_task_set:
rows = [
row
@@ -3483,6 +4512,19 @@ class G20ThreeCameraCalibrationNode(Node):
kind = str(durable["kind"])
if kind == "synchronised_frame":
continue
+ if kind == "palm_axis_sample":
+ source_name = str(durable.get("source_joint", ""))
+ if source_name not in self.palm_axis_records_by_source:
+ raise RuntimeError(
+ "resume checkpoint contains unknown palm-axis source "
+ + source_name
+ )
+ record = dict(durable)
+ record["command_u8"] = int(
+ round(float(record["feedback_u8"]))
+ )
+ self.palm_axis_records_by_source[source_name].append(record)
+ continue
joint_name = str(durable["joint"])
if joint_name not in self.records_by_joint:
raise RuntimeError(
@@ -3512,6 +4554,38 @@ class G20ThreeCameraCalibrationNode(Node):
for row in reusable
if str(row.get("task_name", "")) in completed_set
)
+ # Attempts are durable identities, not counters local to one process.
+ # Without this floor a resumed task whose accepted data used attempt 2
+ # would label its next retry as attempt 2 again. The raw log then
+ # contained two different acquisitions with the same identity and an
+ # offline replay merged them into one invalid trajectory.
+ attempt_floor_by_task: dict[str, int] = {}
+ for row in reusable:
+ task_key = str(row.get("task_name", ""))
+ if task_key not in completed_set:
+ continue
+ try:
+ attempt = max(1, int(row.get("attempt", 1)))
+ except (TypeError, ValueError):
+ attempt = 1
+ attempt_floor_by_task[task_key] = max(
+ attempt_floor_by_task.get(task_key, 1), attempt
+ )
+ attempts = getattr(self, "sweep_attempts", None)
+ if attempts is None:
+ self.sweep_attempts = {
+ _sweep_storage_key(spec): 1
+ for spec in self.profile.sweep_specs
+ }
+ attempts = self.sweep_attempts
+ for spec in self.profile.sweep_specs:
+ if spec.key not in completed_set:
+ continue
+ storage_key = _sweep_storage_key(spec)
+ attempts[storage_key] = max(
+ int(attempts.get(storage_key, 1)),
+ attempt_floor_by_task.get(spec.key, 1),
+ )
self.resumed_task_keys = completed
G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(self)
self.resume_source_session = source.parent.name
@@ -3534,7 +4608,12 @@ class G20ThreeCameraCalibrationNode(Node):
"revalidation_dropped_tasks": dropped,
"tag_size_changed_ids": list(changed_tag_size_ids),
"tag_size_invalidated_task_keys": list(size_invalidated_tasks),
+ "palm_axis_schema_compatible": palm_axis_schema_compatible,
+ "palm_axis_invalidated_task_keys": list(
+ palm_axis_invalidated_tasks
+ ),
"imported_record_count": len(reusable),
+ "imported_attempt_floor_by_task": attempt_floor_by_task,
"source_raw_samples_sha256": _file_sha256(source),
},
)
@@ -3606,6 +4685,18 @@ class G20ThreeCameraCalibrationNode(Node):
):
if joint_name in store:
store[joint_name].clear()
+ observer = next(
+ (
+ item
+ for item in self.profile.palm_axis_observers
+ if item.task_name == task_key
+ ),
+ None,
+ )
+ if observer is not None:
+ self.palm_axis_records_by_source[
+ observer.source_name
+ ].clear()
if isinstance(completed, tuple):
accepted = tuple(accepted)
return accepted, dropped
@@ -3637,6 +4728,7 @@ class G20ThreeCameraCalibrationNode(Node):
self.started = True
self.startup_baseline_recovered = False
self.sweep_items = []
+ self.pnp_task_spec = None
selected_sweep_specs = list(self.profile.sweep_specs)
if self.cross_view_roll_diagnostic_finger:
selected_sweep_specs = [
@@ -3649,23 +4741,13 @@ class G20ThreeCameraCalibrationNode(Node):
raise RuntimeError(
"cross-view diagnostic could not resolve multiview task"
)
- for spec in selected_sweep_specs:
- if self.profile.layout_id == G20_RIGHT_19_LAYOUT:
- self.sweep_items.extend(
- SweepItem(spec, -1, direction, precheck=True)
- for direction in (
- DIRECTION_DECREASING,
- DIRECTION_INCREASING,
- )
- )
- self.sweep_items.extend(
- SweepItem(spec, cycle, direction)
- for cycle in range(self.repetitions)
- for direction in (
- DIRECTION_DECREASING,
- DIRECTION_INCREASING,
- )
- )
+ selected_profile = replace(
+ self.profile,
+ sweep_specs=tuple(selected_sweep_specs),
+ )
+ self.sweep_items.extend(
+ _build_sweep_plan(selected_profile, self.repetitions)
+ )
self.sweep_index = 0
self.retry_sweep_spec = None
self.retry_resume_index = None
@@ -3693,10 +4775,11 @@ class G20ThreeCameraCalibrationNode(Node):
self.command_records_by_joint = {
name: [] for name in self.profile.record_joints
}
- self.axis_measurements.clear()
- self.zero_result = None
- self.corrected_urdf_path = None
- self.validation_errors_rad.clear()
+ self.palm_axis_records_by_source = {
+ observer.source_name: []
+ for observer in self.profile.palm_axis_observers
+ }
+ G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(self)
self.combination_validation_items.clear()
self.combination_validation_index = 0
self.active_combination_validation = None
@@ -3714,17 +4797,24 @@ class G20ThreeCameraCalibrationNode(Node):
self.sweep_baseline_pending = False
self.sweep_baseline_hold_since = None
self.sweep_start_frames.clear()
+ self.carried_sweep_start_frames.clear()
append_jsonl(
self.raw_path,
{
"kind": "session_start",
+ "model": self.model,
"hand_type": self.hand_type,
"tag_layout": self.profile.layout_id,
+ "command_names": list(_command_names(self)),
+ "capabilities": sorted(self.profile.capabilities),
"reference_finger": self.profile.reference_finger,
"view_tags": {
view: dict(tags)
for view, tags in self.profile.view_tags.items()
},
+ "palm_axis_observers": _palm_axis_observer_schema(
+ self.profile
+ ),
"tag_family": "36h11",
"tag_size_m": float(self.tag_size_m),
"tag_sizes_m_by_id": {
@@ -3911,7 +5001,7 @@ class G20ThreeCameraCalibrationNode(Node):
return
message = JointState()
message.header.stamp = self.get_clock().now().to_msg()
- message.name = list(COMMAND_NAMES)
+ message.name = list(_command_names(self))
message.position = [float(value) for value in values]
self.command_publisher.publish(message)
@@ -4071,9 +5161,17 @@ class G20ThreeCameraCalibrationNode(Node):
if not required.issubset(by_direction):
return
- enabled = bool(
+ profile = _node_profile(self)
+ configured_enabled = bool(
getattr(self, "adaptive_formal_speed_enabled", False)
)
+ # G20 right repeatability is evaluated across independent sessions.
+ # Letting camera-dependent precheck density alter the physical scan
+ # speed changes velocity lag and backlash, so two runs of the same hand
+ # no longer measure the same process. Keep the reviewed base speed for
+ # every product task; adaptive speed remains available to legacy tools.
+ stability_speed_lock = profile.layout_id == G20_RIGHT_19_LAYOUT
+ enabled = configured_enabled and not stability_speed_lock
# MCP roll is both the fastest native mechanism and the one guarded by
# the strict 0.5-degree baseline backlash limit. Field data at speed
# 7 exceeded that limit after a clean speed-5 precheck, so sampling
@@ -4124,10 +5222,14 @@ class G20ThreeCameraCalibrationNode(Node):
"task_name": item.spec.key,
"motor_index": int(item.spec.motor_index),
"enabled": enabled,
+ "configured_enabled": configured_enabled,
+ "stability_speed_lock": stability_speed_lock,
"eligible": eligible,
"ineligible_reason": (
- "roll_baseline_hysteresis_sensitive"
- if enabled and not eligible
+ "g20_right_deterministic_acquisition_speed"
+ if stability_speed_lock
+ else "roll_baseline_hysteresis_sensitive"
+ if configured_enabled and not eligible
else ""
),
"base_speed": base_speed,
@@ -4203,6 +5305,10 @@ class G20ThreeCameraCalibrationNode(Node):
def _begin_return_baseline(self, after: str) -> None:
self.baseline_after = str(after)
+ if str(after) not in {"next_cycle", "resume_sweep"}:
+ # A mechanical task boundary is also the PnP ownership boundary.
+ # Same-task cycle resets and localized fit recovery retain it.
+ self.pnp_task_spec = None
target_command = (
G20ThreeCameraCalibrationNode._return_command_for_transition(
self, after
@@ -4881,6 +5987,24 @@ class G20ThreeCameraCalibrationNode(Node):
self, spec.motor_index, int(endpoint_u8)
)
+ def _synchronised_endpoint_tolerance_for_spec(
+ self, spec: SweepSpec, endpoint_u8: int
+ ) -> float:
+ """Return the endpoint bin deadband for image-timestamped feedback.
+
+ Motion completion deliberately keeps the physical endpoint deadband.
+ A synchronised image observation uses interpolated feedback at the
+ camera timestamp and can trail that latest feedback by a fraction of
+ one u8 count. One additional count prevents a reached endpoint from
+ being discarded solely at this continuous-to-integer bin boundary.
+ """
+ mechanical_tolerance = self._endpoint_tolerance_for_spec(
+ spec, endpoint_u8
+ )
+ return mechanical_tolerance + float(
+ getattr(self, "synchronised_endpoint_tolerance_margin_u8", 1.0)
+ )
+
def _motor_endpoint_tolerance(
self, motor_index: int, endpoint_u8: int
) -> float:
@@ -4939,6 +6063,11 @@ class G20ThreeCameraCalibrationNode(Node):
return baseline not in {item.start_u8, item.target_u8}
def _start_next_sweep(self) -> None:
+ carried_start_frames = list(
+ getattr(self, "carried_sweep_start_frames", ())
+ )
+ if hasattr(self, "carried_sweep_start_frames"):
+ self.carried_sweep_start_frames.clear()
retry_items = getattr(self, "retry_sweep_items", [])
if retry_items:
self.active_sweep = retry_items.pop(0)
@@ -4952,13 +6081,37 @@ class G20ThreeCameraCalibrationNode(Node):
self.active_sweep = self.sweep_items[self.sweep_index]
self.active_sweep_is_fit_retry = False
item = self.active_sweep
- if item.direction == DIRECTION_DECREASING:
+ profile = _node_profile(self)
+ if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ self.pnp_task_spec = item.spec
+ is_fit_retry = bool(
+ getattr(self, "active_sweep_is_fit_retry", False)
+ )
+ if _requires_pnp_tracker_reset_for_sweep(
+ profile,
+ item,
+ is_fit_retry=is_fit_retry,
+ ):
reset_trackers = getattr(self, "_reset_view_trackers", None)
- preserve_task_reference = bool(
- not item.precheck and item.cycle > 0
+ preserve_task_reference = _preserve_pnp_task_reference_for_sweep(
+ item,
+ is_fit_retry=is_fit_retry,
+ has_precheck_anchor=(
+ profile.layout_id == G20_RIGHT_19_LAYOUT
+ ),
)
- reset_views = set(_sweep_views(_node_profile(self), item.spec))
- if getattr(self, "active_sweep_is_fit_retry", False):
+ reset_views = set(_sweep_views(profile, item.spec))
+ observer = next(
+ (
+ candidate
+ for candidate in profile.palm_axis_observers
+ if candidate.task_name == item.spec.key
+ ),
+ None,
+ )
+ if observer is not None:
+ reset_views.add(observer.view)
+ if is_fit_retry:
scoped = set(getattr(self, "retry_joint_names", set()))
if scoped:
reset_views = {
@@ -4966,21 +6119,24 @@ class G20ThreeCameraCalibrationNode(Node):
for name in scoped
}
reset_views.discard(None)
+ if observer is not None:
+ reset_views.add(observer.view)
for view in reset_views:
runtime = getattr(self, "views", {}).get(view)
if runtime is None or reset_trackers is None:
continue
- # Clear frame-to-frame state so every cycle receives a full
- # static initialization window, but retain the first formal
- # cycle's endpoint-relative branch anchor. Without that
- # task-level anchor, later cycles can independently settle on
- # opposite stable IPPE mirror solutions.
+ # A normal G20-right task enters here only for its first
+ # decreasing precheck. All formal cycles keep this exact
+ # frame-to-frame branch instead of independently selecting a
+ # new stable IPPE solution. A fit retry deliberately starts
+ # fresh but retains the task-relative anchor.
if preserve_task_reference:
reset_trackers(
runtime, preserve_task_reference=True
)
else:
reset_trackers(runtime)
+ self._reset_view_pnp_diagnostics(runtime)
runtime.pnp_invalid_since = None
runtime.pnp_reset_count += 1
raw_path = getattr(self, "raw_path", None)
@@ -4988,10 +6144,12 @@ class G20ThreeCameraCalibrationNode(Node):
append_jsonl(
raw_path,
{
- "kind": "pnp_cycle_initialization",
+ "kind": "pnp_task_initialization",
"view": view,
+ "task_name": item.spec.key,
"motor_index": item.spec.motor_index,
"cycle": item.cycle,
+ "fit_retry": is_fit_retry,
"task_reference_preserved": (
preserve_task_reference
),
@@ -5002,6 +6160,11 @@ class G20ThreeCameraCalibrationNode(Node):
self.sweep_baseline_pending = False
self.sweep_baseline_hold_since = None
getattr(self, "sweep_start_frames", []).clear()
+ getattr(self, "sweep_start_frames", []).extend(carried_start_frames)
+ self.sweep_detection_total_frames = 0
+ self.sweep_detection_valid_frames = 0
+ self.sweep_detection_total_by_view = {}
+ self.sweep_detection_valid_by_view = {}
self.position_hold_since = None
self.motion_stage_started_at = time.monotonic()
current_state = getattr(self, "latest_state_u8", ())
@@ -5044,6 +6207,62 @@ class G20ThreeCameraCalibrationNode(Node):
)
self._publish_command(list(self.preparation_command_u8))
+ def _stage_immediate_reverse_start_frames(
+ self, completed: SweepItem, following: SweepItem
+ ) -> int:
+ """Carry a proven terminal endpoint into an immediate reverse pass."""
+ carried = getattr(self, "carried_sweep_start_frames", None)
+ if carried is None:
+ self.carried_sweep_start_frames = []
+ carried = self.carried_sweep_start_frames
+ carried.clear()
+ if (
+ completed.spec != following.spec
+ or completed.target_u8 != following.start_u8
+ or completed.direction == following.direction
+ ):
+ return 0
+ tolerance = (
+ G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec(
+ self, following.spec, following.start_u8
+ )
+ )
+ limit_per_view = max(1, int(getattr(self, "preflight_frames", 30)))
+ selected: list[FrameObservation] = []
+ for view in _sweep_views(_node_profile(self), following.spec):
+ view_frames = [
+ frame
+ for frame in self.sweep_frames
+ if frame.view == view
+ and abs(
+ float(frame.state_u8[following.spec.motor_index])
+ - following.start_u8
+ )
+ <= tolerance
+ ]
+ selected.extend(view_frames[-limit_per_view:])
+ selected.sort(key=lambda frame: frame.stamp_ns)
+ carried.extend(selected)
+ if selected and getattr(self, "raw_path", None) is not None:
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "sweep_endpoint_frames_carried",
+ "task_name": completed.spec.key,
+ "from_cycle": completed.cycle,
+ "from_direction": completed.direction,
+ "to_cycle": following.cycle,
+ "to_direction": following.direction,
+ "endpoint_u8": following.start_u8,
+ "valid_frames": len(selected),
+ "valid_frames_by_joint": {
+ name: len(_frames_for_joint(selected, name))
+ for name in following.spec.joints
+ },
+ },
+ )
+ return len(selected)
+
def _begin_active_sweep(self, now: float) -> None:
assert self.active_sweep is not None
self.sweep_frames.clear()
@@ -5282,7 +6501,13 @@ class G20ThreeCameraCalibrationNode(Node):
)
if (
profile.layout_id == G20_RIGHT_19_LAYOUT
- and joint_name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
+ and (
+ joint_name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
+ or (
+ joint_name.endswith("_mcp_roll")
+ and not joint_name.startswith("thumb_")
+ )
+ )
):
return fit_joint_image_curve(
model_records,
@@ -5386,6 +6611,20 @@ class G20ThreeCameraCalibrationNode(Node):
for finger in ("index", "middle", "ring", "pinky")
},
}.get(joint_name)
+ if (
+ profile.layout_id == G20_RIGHT_19_LAYOUT
+ and joint_name.endswith("_mcp_roll")
+ and not joint_name.endswith("_mcp_roll_side")
+ and not joint_name.startswith("thumb_")
+ ):
+ reference_roll = f"{profile.reference_finger}_mcp_roll"
+ if joint_name != reference_roll:
+ # The four finger roll axes are exactly parallel in the
+ # product URDF. The reference-finger task is acquired first,
+ # so reuse its measured common-frame direction for the other
+ # three fingers. This removes session-to-session planar-PnP
+ # branch tilt without changing any fitted axis-line position.
+ upstream_joint = reference_roll
if upstream_joint is not None:
# Resolve recursively so thumb_ip receives the already constrained
# thumb_mcp direction (and index_dip the constrained index_pip
@@ -5449,9 +6688,14 @@ class G20ThreeCameraCalibrationNode(Node):
)
if extrinsics is None:
return measurement
- return replace(
- measurement,
- view_normal_common_xyz=tuple(float(value) for value in view_normal),
+ return with_depth_free_axis_projection(
+ replace(
+ measurement,
+ view_normal_common_xyz=tuple(
+ float(value) for value in view_normal
+ ),
+ ),
+ extrinsics.transform(spec.view)[:3, 3],
)
def _fit_axis_measurement(
@@ -5492,6 +6736,71 @@ class G20ThreeCameraCalibrationNode(Node):
np.cross(secondary_point - primary_point, primary_axis)
)
)
+ validation_spec = profile.record_specs[validation_name]
+ if validation_spec.zero_kind == "axis_cross_view_validation":
+ # This Tag is attached to the downstream PIP link. During MCP
+ # roll its orientation contains linkage/pitch coupling and is not
+ # an independent observation of the published MCP angle or axis
+ # direction. It remains the physical axis-line-position source;
+ # its own radial, line-repeatability, visibility and trajectory
+ # quality gates are all applied before reaching this point.
+ decision = (
+ "diagnostic_only_gross_pose_disagreement_use_primary"
+ if axis_difference
+ > getattr(
+ self,
+ "cross_view_roll_maximum_axis_difference_rad",
+ math.radians(15.0),
+ )
+ else "diagnostic_only_pose_disagreement_use_primary"
+ )
+ G20ThreeCameraCalibrationNode._record_cross_view_roll_axis_diagnostic(
+ self,
+ joint_name,
+ cycle,
+ primary=primary,
+ secondary=secondary,
+ axis_difference_rad=axis_difference,
+ line_distance_m=line_distance,
+ decision=decision,
+ )
+ extrinsics = getattr(self, "extrinsics", None)
+ if extrinsics is None or validation_spec.view is None:
+ raise ValueError(
+ f"{validation_name}: cross-view camera geometry is unavailable"
+ )
+ primary_view = profile.record_specs[joint_name].view
+ if primary_view is None:
+ raise ValueError(
+ f"{joint_name}: primary point source has no view"
+ )
+ point_camera_center = extrinsics.transform(primary_view)[:3, 3]
+ point_ray = primary_point - point_camera_center
+ interpretation_plane_normal = np.cross(
+ point_ray, primary_axis
+ )
+ interpretation_plane_normal /= np.linalg.norm(
+ interpretation_plane_normal
+ )
+ return replace(
+ primary,
+ point_common_xyz_m=tuple(
+ float(value) for value in primary.point_common_xyz_m
+ ),
+ axis_point_source=(
+ "front_interpretation_plane_cross_view_validated"
+ ),
+ pose_axis_line_rms_m=primary.pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(joint_name,),
+ axis_point_camera_center_common_xyz_m=tuple(
+ float(value)
+ for value in point_camera_center
+ ),
+ axis_point_interpretation_plane_normal_common_xyz=tuple(
+ float(value)
+ for value in interpretation_plane_normal
+ ),
+ )
# Monocular planar-tag orientation on the side view carries a
# systematic line-of-sight IPPE bias of several degrees through roll
# sweeps (tags tilted ~13-20 deg from the ray), which sub-pixel
@@ -5500,22 +6809,21 @@ class G20ThreeCameraCalibrationNode(Node):
# so sub-degree cross-view agreement is not achievable at this
# geometry. Disagreement above the fusion gate now falls back to the
# trusted front-only axis with a recorded diagnostic instead of
- # failing the joint; only the gross bound (wrong-link or loose tag)
- # still fails.
+ # failing the joint; only a gross direction error (wrong-link or a
+ # loose Tag that changes the observed motion axis) still fails here.
gross_axis_limit = getattr(
self,
"cross_view_roll_maximum_axis_difference_rad",
math.radians(15.0),
)
- gross_line_limit = getattr(
- self,
- "cross_view_roll_maximum_axis_line_difference_m",
- 0.030,
- )
- if (
- axis_difference > gross_axis_limit
- or line_distance > gross_line_limit
- ):
+ # Do not apply a universal front/side line-distance gate here. The
+ # front roll link rides the splay screw, so its pure-revolute pose fit
+ # is a displaced pseudo-line whose offset varies by finger (about
+ # 15 mm for pinky and 37 mm for ring on the same fixed setup). It is
+ # not the physical line ultimately published. Retain the distance in
+ # diagnostics; validate the selected side physical line through its
+ # own radial quality, cycle RMS and final URDF geometry instead.
+ if axis_difference > gross_axis_limit:
raise ValueError(
"cross_view_roll_axis_gross_disagreement:"
f"{math.degrees(axis_difference):.6f}deg,"
@@ -5541,15 +6849,7 @@ class G20ThreeCameraCalibrationNode(Node):
if observer_name is None
else self.records_by_joint.get(observer_name, [])
)
- if any(
- int(record.get("cycle", -1)) == int(cycle)
- for record in observer_records
- ):
- observer = (
- G20ThreeCameraCalibrationNode._fit_axis_measurement_raw(
- self, observer_name, cycle
- )
- )
+ if observer_records:
model = UrdfKinematicModel(self.source_urdf_path)
parent_axis, _ = model.axis_line(
joint_name, zero_offsets={}, joint_angles={}
@@ -5564,11 +6864,11 @@ class G20ThreeCameraCalibrationNode(Node):
)
)
)
- measured_observer_axis = np.asarray(
- observer.axis_common_xyz, dtype=float
- )
- def cone_residual(candidate_axis: np.ndarray) -> float:
+ def cone_residual(
+ candidate_axis: np.ndarray,
+ measured_observer_axis: np.ndarray,
+ ) -> float:
measured_cone = math.acos(
abs(
float(
@@ -5582,19 +6882,128 @@ class G20ThreeCameraCalibrationNode(Node):
)
return abs(measured_cone - expected_cone)
- primary_cone_residual = cone_residual(primary_axis)
- secondary_cone_residual = cone_residual(secondary_axis)
# A zero rotates the downstream axis around this parent and
- # cannot change their mutual cone angle. Use that invariant
- # to choose between the two independently accepted views only
- # when the current primary is invalid and the secondary is
- # inside the unchanged geometry gate.
- if (
- primary_cone_residual
- > self.zero_maximum_axis_cone_mismatch_rad
- and secondary_cone_residual
- <= self.zero_maximum_axis_cone_mismatch_rad
- ):
+ # cannot change their mutual cone angle. Select the camera
+ # once for the complete repeated-sweep group. Per-cycle
+ # selection is invalid because a residual close to the gate
+ # can alternate views and turn their fixed systematic bias
+ # into a false cycle-axis spread.
+ group_cycles = tuple(range(int(self.repetitions)))
+ required_names = (
+ joint_name,
+ validation_name,
+ observer_name,
+ )
+ group_is_complete = all(
+ any(
+ int(record.get("cycle", -1)) == group_cycle
+ for record in self.records_by_joint.get(name, ())
+ )
+ for name in required_names
+ for group_cycle in group_cycles
+ )
+ group_source = "primary"
+ if group_is_complete:
+ group_signature = tuple(
+ (
+ name,
+ group_cycle,
+ len(cycle_records),
+ max(
+ (
+ int(record.get("attempt", 0))
+ for record in cycle_records
+ ),
+ default=0,
+ ),
+ max(
+ (
+ int(record.get("image_stamp_ns", 0))
+ for record in cycle_records
+ ),
+ default=0,
+ ),
+ )
+ for name in required_names
+ for group_cycle in group_cycles
+ for cycle_records in (
+ [
+ record
+ for record in self.records_by_joint.get(
+ name, ()
+ )
+ if int(record.get("cycle", -1))
+ == group_cycle
+ ],
+ )
+ )
+ cache_key = (
+ joint_name,
+ validation_name,
+ observer_name,
+ group_signature,
+ )
+ source_cache = getattr(
+ self, "_cross_view_roll_group_source_cache", {}
+ )
+ cached_source = source_cache.get(cache_key)
+ if cached_source is not None:
+ group_source = str(cached_source)
+ else:
+ primary_residuals: list[float] = []
+ secondary_residuals: list[float] = []
+ fit_raw = (
+ G20ThreeCameraCalibrationNode
+ ._fit_axis_measurement_raw
+ )
+ try:
+ for group_cycle in group_cycles:
+ group_primary = fit_raw(
+ self, joint_name, group_cycle
+ )
+ group_secondary = fit_raw(
+ self, validation_name, group_cycle
+ )
+ group_observer = fit_raw(
+ self, observer_name, group_cycle
+ )
+ observer_axis_common = np.asarray(
+ group_observer.axis_common_xyz,
+ dtype=float,
+ )
+ primary_residuals.append(
+ cone_residual(
+ np.asarray(
+ group_primary.axis_common_xyz,
+ dtype=float,
+ ),
+ observer_axis_common,
+ )
+ )
+ secondary_residuals.append(
+ cone_residual(
+ np.asarray(
+ group_secondary.axis_common_xyz,
+ dtype=float,
+ ),
+ observer_axis_common,
+ )
+ )
+ except (KeyError, ValueError):
+ primary_residuals = []
+ secondary_residuals = []
+ group_source = (
+ select_cross_view_roll_direction_source(
+ primary_residuals,
+ secondary_residuals,
+ self.zero_maximum_axis_cone_mismatch_rad,
+ )
+ )
+ source_cache[cache_key] = group_source
+ if len(source_cache) > 32:
+ source_cache.pop(next(iter(source_cache)))
+ self._cross_view_roll_group_source_cache = source_cache
+ if group_source == "secondary":
selected_direction = replace(
primary,
axis_common_xyz=tuple(
@@ -5604,7 +7013,9 @@ class G20ThreeCameraCalibrationNode(Node):
"cross_view_cone_selected_secondary"
),
)
- decision = "use_secondary_zero_invariant_cone"
+ decision = (
+ "use_secondary_zero_invariant_cone_consensus"
+ )
G20ThreeCameraCalibrationNode._record_cross_view_roll_axis_diagnostic(
self,
joint_name,
@@ -5619,8 +7030,9 @@ class G20ThreeCameraCalibrationNode(Node):
# tracks the rotation faithfully, but its centre trajectory
# carries the screw translation, displacing the fitted axis
# line by ~21 mm from the finger's physical MCP axis. The side
- # PIP-link circle recovers that physical line. Keep the trusted
- # front direction and take the line position from the side view.
+ # PIP-link circle recovers that physical line. Keep the one
+ # direction selected consistently for all cycles and take the
+ # line position from the side view.
return replace(
selected_direction,
point_common_xyz_m=tuple(
@@ -5629,6 +7041,7 @@ class G20ThreeCameraCalibrationNode(Node):
),
axis_point_source="side_circle_cross_view",
pose_axis_line_rms_m=secondary.pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(validation_name,),
)
primary_variance = max(
primary.radial_rms_m ** 2 + primary.pose_axis_line_rms_m ** 2,
@@ -5666,9 +7079,56 @@ class G20ThreeCameraCalibrationNode(Node):
secondary.pose_axis_line_rms_m,
line_distance,
),
+ pose_axis_line_source_joints=(joint_name, validation_name),
axis_direction_source="cross_view_weighted_fusion",
)
+ def _refit_cross_view_axis_line_group(
+ self,
+ joint_name: str,
+ measurements: Sequence[JointAxisMeasurement],
+ ) -> list[JointAxisMeasurement]:
+ """Apply the repeated-cycle physical-radius constraint when eligible."""
+ group = list(measurements)
+ sources = {
+ source
+ for measurement in group
+ if (source := cross_view_side_line_source(measurement)) is not None
+ }
+ if (
+ len(group) != int(self.repetitions)
+ or len(sources) != 1
+ or not all(
+ cross_view_side_line_source(measurement) is not None
+ for measurement in group
+ )
+ or any(
+ axis_line_uses_depth_free_interpretation_plane(measurement)
+ for measurement in group
+ )
+ ):
+ return group
+ source = next(iter(sources))
+ profile = getattr(self, "profile", LEFT_HAND_PROFILE)
+ source_spec = profile.record_specs[source]
+ source_records = G20ThreeCameraCalibrationNode._records_with_baseline_holds(
+ self,
+ source,
+ self.records_by_joint[source],
+ )
+ return list(
+ refit_axis_line_group_with_shared_radius(
+ group,
+ source_records,
+ zero_command_u8=int(
+ self.baseline_command[source_spec.motor_index]
+ ),
+ canonical_zero_direction=canonical_zero_direction(
+ profile, source
+ ),
+ )
+ )
+
def _record_cross_view_roll_axis_diagnostic(
self,
joint_name: str,
@@ -5710,7 +7170,9 @@ class G20ThreeCameraCalibrationNode(Node):
if callable(logger_factory):
selected_text = (
"using the side axis selected by the zero-invariant cone"
- if decision == "use_secondary_zero_invariant_cone"
+ if decision.startswith(
+ "use_secondary_zero_invariant_cone"
+ )
else "keeping the front-only axis"
)
logger_factory().warning(
@@ -5760,10 +7222,25 @@ class G20ThreeCameraCalibrationNode(Node):
and spec.key
in set(getattr(self, "resumed_task_keys", ()) or ())
)
+ dense_trajectory_coverage = bool(
+ profile.layout_id == G20_RIGHT_19_LAYOUT
+ and _records_have_formal_sweep_coverage(
+ self.records_by_joint,
+ view_joints,
+ repetitions=self.repetitions,
+ minimum_bins=int(
+ getattr(self, "minimum_sweep_bins", 32)
+ ),
+ maximum_bin_gap=int(
+ getattr(self, "maximum_bin_gap", 16)
+ ),
+ )
+ )
if (
not imported_without_capture
and valid_rate < self.minimum_detection_rate
and not validation_only_view
+ and not dense_trajectory_coverage
):
failures.append(
{
@@ -5820,7 +7297,7 @@ class G20ThreeCameraCalibrationNode(Node):
}
)
continue
- if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ if profile.supports("steady_command_checkpoints"):
command_store = getattr(
self, "command_records_by_joint", None
)
@@ -5913,6 +7390,10 @@ class G20ThreeCameraCalibrationNode(Node):
profile, joint_name
)
if canonical_direction is not None:
+ validation_only_pose = (
+ profile.record_specs[joint_name].zero_kind
+ == "axis_cross_view_validation"
+ )
branch_gap_range = (
maximum_baseline_hysteresis
- min(baseline_hysteresis)
@@ -5927,20 +7408,6 @@ class G20ThreeCameraCalibrationNode(Node):
"directional_zero_maximum_branch_gap_range_rad",
math.radians(0.3),
)
- if (
- profile.record_specs[joint_name].zero_kind
- == "axis_cross_view_validation"
- ):
- # The validation-only side angle curve rides on a
- # near-grazing planar tag, so its cross-round backlash
- # repeatability carries IPPE drift beyond the strict
- # 0.3 deg production bound; the absolute gap limit
- # below still applies unchanged.
- branch_gap_range_limit = getattr(
- self,
- "cross_view_roll_alias_maximum_branch_gap_range_rad",
- math.radians(0.5),
- )
if maximum_baseline_hysteresis > branch_gap_limit:
failures.append(
{
@@ -5962,7 +7429,10 @@ class G20ThreeCameraCalibrationNode(Node):
],
}
)
- if branch_gap_range > branch_gap_range_limit:
+ if (
+ branch_gap_range > branch_gap_range_limit
+ and not validation_only_pose
+ ):
failures.append(
{
"joint": joint_name,
@@ -5982,6 +7452,39 @@ class G20ThreeCameraCalibrationNode(Node):
],
}
)
+ elif (
+ branch_gap_range > branch_gap_range_limit
+ and validation_only_pose
+ ):
+ # This side-view alias is never published as a joint
+ # angle. Its orientation is reconstructed from a
+ # near-grazing planar Tag, so IPPE pose drift can move
+ # the apparent branch gap between rounds even when
+ # the image trajectory and the authoritative front
+ # axis are repeatable. Keep the production threshold
+ # as an audit reference, but do not rescan identical
+ # motion based on a non-published pose component.
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "validation_only_quality_diagnostic",
+ "joint": joint_name,
+ "metric": (
+ "baseline_directional_gap_range_deg"
+ ),
+ "actual": round(
+ math.degrees(branch_gap_range), 6
+ ),
+ "reference_limit": round(
+ math.degrees(branch_gap_range_limit), 6
+ ),
+ "cycle_values_deg": [
+ round(math.degrees(value), 6)
+ for value in baseline_hysteresis
+ ],
+ "decision": "diagnostic_only",
+ },
+ )
elif (
maximum_baseline_hysteresis
> self.baseline_maximum_hysteresis_rad
@@ -6075,7 +7578,7 @@ class G20ThreeCameraCalibrationNode(Node):
"maximum",
),
)
- if profile.layout_id != G20_RIGHT_19_LAYOUT:
+ if not profile.supports("directional_zero"):
checks = checks + (
(
"hysteresis_deg",
@@ -6104,6 +7607,7 @@ class G20ThreeCameraCalibrationNode(Node):
cycle_travels: list[float] = []
cycle_axes: list[np.ndarray] = []
cycle_axis_sources: list[str] = []
+ cycle_axis_measurements: list[JointAxisMeasurement] = []
for cycle in range(self.repetitions):
cycle_records = [
record
@@ -6133,17 +7637,32 @@ class G20ThreeCameraCalibrationNode(Node):
try:
axis = self._fit_axis_measurement(joint_name, cycle)
except Exception as error:
- failures.append(
- {
- "joint": joint_name,
- "metric": "axis_fit",
- "cycle": cycle + 1,
- "reason": str(error),
- }
- )
+ reason = str(error)
+ failure: dict[str, Any] = {
+ "joint": joint_name,
+ "metric": "axis_fit",
+ "cycle": cycle + 1,
+ "reason": reason,
+ }
+ if reason.startswith(
+ "cross_view_roll_axis_gross_disagreement:"
+ ):
+ validation_name = (
+ profile.axis_validation_sources or {}
+ ).get(joint_name)
+ if validation_name in spec.joints:
+ # A gross direction disagreement does not identify
+ # which independently observed view is wrong. A
+ # retry must therefore reacquire both sources.
+ failure["quality_source_joints"] = [
+ joint_name,
+ validation_name,
+ ]
+ failures.append(failure)
continue
cycle_axes.append(np.asarray(axis.axis_common_xyz, dtype=float))
cycle_axis_sources.append(axis.axis_direction_source)
+ cycle_axis_measurements.append(axis)
plane_limit = (
self.axis_maximum_plane_rms_m
if joint_spec.active
@@ -6158,10 +7677,15 @@ class G20ThreeCameraCalibrationNode(Node):
]
if (
joint_spec.zero_kind != "axis_cross_view_validation"
+ and cross_view_side_line_source(axis) is None
):
- # The validation-only side axis line is not published once
- # fusion is skipped, so its pose-line RMS stays a recorded
- # diagnostic instead of an admissibility gate.
+ # A cross-view fallback deliberately takes its direction
+ # from the front and its line position from the side. The
+ # side Tag's monocular orientation is not the published
+ # direction, so its per-frame ideal-revolute residual is
+ # not an accuracy measure for the combined line. Those
+ # lines are checked for independent-cycle position
+ # repeatability below, at this same threshold.
axis_checks.append(
(
"axis_pose_line_rms_mm",
@@ -6200,16 +7724,117 @@ class G20ThreeCameraCalibrationNode(Node):
# error is an admissibility check for constrained circles.
for metric, actual, limit in axis_checks:
if actual > limit:
- failures.append(
- {
- "joint": joint_name,
- "metric": metric,
- "cycle": cycle + 1,
- "actual": round(float(actual), 6),
- "limit": round(float(limit), 6),
- "comparison": "maximum",
- }
+ failure_joint = joint_name
+ quality_sources: tuple[str, ...] = ()
+ if metric == "axis_pose_line_rms_mm":
+ quality_sources = tuple(
+ str(source)
+ for source in getattr(
+ axis,
+ "pose_axis_line_source_joints",
+ (),
+ )
+ if str(source) in spec.joints
+ )
+ if len(quality_sources) == 1:
+ failure_joint = quality_sources[0]
+ failure = {
+ "joint": failure_joint,
+ "metric": metric,
+ "cycle": cycle + 1,
+ "actual": round(float(actual), 6),
+ "limit": round(float(limit), 6),
+ "comparison": "maximum",
+ }
+ if quality_sources:
+ failure["quality_source_joints"] = list(
+ quality_sources
+ )
+ if failure_joint != joint_name:
+ failure["model_joint"] = joint_name
+ failures.append(failure)
+ side_line_sources = {
+ source
+ for measurement in cycle_axis_measurements
+ if (
+ source := cross_view_side_line_source(measurement)
+ ) is not None
+ }
+ if side_line_sources:
+ try:
+ cycle_axis_measurements = (
+ G20ThreeCameraCalibrationNode
+ ._refit_cross_view_axis_line_group(
+ self, joint_name, cycle_axis_measurements
)
+ )
+ except Exception as error:
+ source = next(iter(side_line_sources))
+ failures.append(
+ {
+ "joint": source,
+ "model_joint": joint_name,
+ "quality_source_joints": [source],
+ "metric": "axis_line_shared_radius_fit",
+ "reason": str(error),
+ }
+ )
+ side_line_sources = {
+ source
+ for measurement in cycle_axis_measurements
+ if (
+ source := cross_view_side_line_source(measurement)
+ ) is not None
+ }
+ if (
+ len(cycle_axis_measurements) == self.repetitions
+ and len(side_line_sources) == 1
+ and all(
+ cross_view_side_line_source(measurement) is not None
+ for measurement in cycle_axis_measurements
+ )
+ and not any(
+ axis_line_uses_depth_free_interpretation_plane(measurement)
+ for measurement in cycle_axis_measurements
+ )
+ ):
+ line_rms = axis_line_cycle_rms_m(
+ cycle_axis_measurements
+ )
+ if line_rms > self.axis_maximum_pose_line_rms_m:
+ source = next(iter(side_line_sources))
+ outliers = _isolated_axis_line_cycle_outliers(
+ cycle_axis_measurements,
+ self.axis_maximum_pose_line_rms_m,
+ )
+ failure = {
+ "joint": source,
+ "model_joint": joint_name,
+ "quality_source_joints": [source],
+ "metric": "axis_line_cycle_rms_mm",
+ "actual": round(1000.0 * line_rms, 6),
+ "limit": round(
+ 1000.0 * self.axis_maximum_pose_line_rms_m,
+ 6,
+ ),
+ "maximum_pairwise_mm": round(
+ 1000.0
+ * maximum_axis_line_cycle_spread_m(
+ cycle_axis_measurements
+ ),
+ 6,
+ ),
+ "comparison": "maximum",
+ }
+ if outliers:
+ outlier = next(iter(outliers))
+ failure["cycle"] = outlier + 1
+ failure["inlier_cycles"] = [
+ index + 1
+ for index in range(len(cycle_axis_measurements))
+ if index != outlier
+ ]
+ failures.append(failure)
if len(cycle_travels) == self.repetitions:
travel_range = max(cycle_travels) - min(cycle_travels)
cycle_limit = (
@@ -6247,7 +7872,38 @@ class G20ThreeCameraCalibrationNode(Node):
abs(float(np.clip(left @ right, -1.0, 1.0)))
),
)
- if maximum_difference > self.zero_maximum_axis_cycle_difference_rad:
+ if (
+ maximum_difference
+ > self.zero_maximum_axis_cycle_difference_rad
+ and joint_spec.zero_kind
+ == "axis_cross_view_validation"
+ ):
+ # As above, the side alias' independently reconstructed
+ # pose axis is not the axis written to the URDF. The
+ # published front direction and the side translation
+ # circle retain their own strict cross-round gates.
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "validation_only_quality_diagnostic",
+ "joint": joint_name,
+ "metric": "axis_cycle_difference_deg",
+ "actual": round(
+ math.degrees(maximum_difference), 6
+ ),
+ "reference_limit": round(
+ math.degrees(
+ self.zero_maximum_axis_cycle_difference_rad
+ ),
+ 6,
+ ),
+ "decision": "diagnostic_only",
+ },
+ )
+ elif (
+ maximum_difference
+ > self.zero_maximum_axis_cycle_difference_rad
+ ):
outliers = _isolated_axis_cycle_outliers(
cycle_axes,
self.zero_maximum_axis_cycle_difference_rad,
@@ -6273,6 +7929,112 @@ class G20ThreeCameraCalibrationNode(Node):
if index != outlier
]
failures.append(failure)
+ if profile.supports("cross_view_roll_curve"):
+ for primary_name, validation_name in (
+ profile.axis_validation_sources or {}
+ ).items():
+ if not {primary_name, validation_name}.issubset(spec.joints):
+ continue
+ try:
+ cycle_fits = [
+ (
+ self._fit_joint_records(
+ primary_name,
+ [
+ record
+ for record in self.records_by_joint[
+ primary_name
+ ]
+ if int(record.get("cycle", -1)) == cycle
+ ],
+ ),
+ self._fit_joint_records(
+ validation_name,
+ [
+ record
+ for record in self.records_by_joint[
+ validation_name
+ ]
+ if int(record.get("cycle", -1)) == cycle
+ ],
+ ),
+ )
+ for cycle in range(self.repetitions)
+ ]
+ training_cycles = set(range(max(1, self.repetitions - 1)))
+ scopes = (
+ (
+ "training",
+ [
+ record
+ for record in self.records_by_joint[primary_name]
+ if int(record.get("cycle", -1))
+ in training_cycles
+ ],
+ [
+ record
+ for record in self.records_by_joint[
+ validation_name
+ ]
+ if int(record.get("cycle", -1))
+ in training_cycles
+ ],
+ ),
+ (
+ "all_cycles",
+ self.records_by_joint[primary_name],
+ self.records_by_joint[validation_name],
+ ),
+ )
+ for scope, primary_records, validation_records in scopes:
+ failure = _cross_view_curve_failure(
+ primary_name,
+ validation_name,
+ self._fit_joint_records(
+ primary_name, primary_records
+ ),
+ self._fit_joint_records(
+ validation_name, validation_records
+ ),
+ scope=scope,
+ maximum_rms_difference_rad=(
+ self.cross_view_roll_maximum_shape_rms_rad
+ ),
+ maximum_branch_gap_difference_rad=(
+ self.cross_view_roll_maximum_branch_gap_difference_rad
+ ),
+ allow_projection_scale=True,
+ maximum_projection_scale_ratio=(
+ self.cross_view_roll_maximum_projection_scale_ratio
+ ),
+ cycle_fits=cycle_fits,
+ )
+ if failure is not None:
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": (
+ "validation_only_quality_diagnostic"
+ ),
+ **failure,
+ "decision": "diagnostic_only",
+ },
+ )
+ # One structured result contains all per-cycle
+ # evidence; avoid duplicate training/final errors.
+ break
+ except Exception as error:
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "validation_only_quality_diagnostic",
+ "joint": primary_name,
+ "metric": "cross_view_roll_curve",
+ "reason": str(error),
+ "quality_source_joints": [validation_name],
+ "decision": "diagnostic_only",
+ },
+ )
return failures
def _cross_view_roll_diagnostic_role(
@@ -6476,6 +8238,9 @@ class G20ThreeCameraCalibrationNode(Node):
_fit_failure_is_systematic(failures, self.repetitions)
or repeated_branch_clusters
)
+ fit_retry_limit = int(
+ getattr(self, "automatic_fit_retry_limit", 0)
+ )
self.fit_failure = {
"kind": "fit_failure",
"view": spec.view,
@@ -6484,6 +8249,10 @@ class G20ThreeCameraCalibrationNode(Node):
"joints": list(spec.joints),
"joints_to_rescan": sorted(self.retry_joint_names),
"attempt": attempt,
+ "fit_attempt": attempt,
+ "fit_attempt_limit": fit_retry_limit + 1,
+ "automatic_retry_count": max(0, attempt - 1),
+ "automatic_retry_limit": fit_retry_limit,
"cycles_to_rescan": [
cycle + 1 for cycle in sorted(self.retry_cycles)
],
@@ -6508,7 +8277,7 @@ class G20ThreeCameraCalibrationNode(Node):
else "joint_fit_systematic_failure"
)
return True
- if attempt <= getattr(self, "automatic_fit_retry_limit", 0):
+ if attempt <= fit_retry_limit:
self.paused_reason = "joint_fit_check_failed"
self.reason = "automatic_retry_joint_fit_check_failed"
self._prepare_failed_sweep_retry()
@@ -6689,7 +8458,18 @@ class G20ThreeCameraCalibrationNode(Node):
return
retries += 1
self.sweep_retry_counts[key] = retries
- for view in _sweep_views(_node_profile(self), item.spec):
+ observer = next(
+ (
+ candidate
+ for candidate in _node_profile(self).palm_axis_observers
+ if candidate.task_name == item.spec.key
+ ),
+ None,
+ )
+ retry_views = set(_sweep_views(_node_profile(self), item.spec))
+ if observer is not None:
+ retry_views.add(observer.view)
+ for view in retry_views:
runtime = getattr(self, "views", {}).get(view)
if runtime is None:
continue
@@ -6707,6 +8487,18 @@ class G20ThreeCameraCalibrationNode(Node):
and str(record.get("direction")) == item.direction
)
]
+ if observer is not None:
+ records = self.palm_axis_records_by_source.get(
+ observer.source_name, []
+ )
+ records[:] = [
+ record
+ for record in records
+ if not (
+ int(record.get("cycle", -999)) == item.cycle
+ and str(record.get("direction", "")) == item.direction
+ )
+ ]
getattr(self, "sweep_frames", []).clear()
getattr(self, "sweep_start_frames", []).clear()
speed_scales = getattr(
@@ -6893,11 +8685,15 @@ class G20ThreeCameraCalibrationNode(Node):
assert self.active_sweep is not None
item = self.active_sweep
motor = item.spec.motor_index
- start_tolerance = self._endpoint_tolerance_for_spec(
- item.spec, item.start_u8
+ start_tolerance = (
+ G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec(
+ self, item.spec, item.start_u8
+ )
)
- target_tolerance = self._endpoint_tolerance_for_spec(
- item.spec, item.target_u8
+ target_tolerance = (
+ G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec(
+ self, item.spec, item.target_u8
+ )
)
record_joint_names = tuple(item.spec.joints)
if getattr(self, "active_sweep_is_fit_retry", False):
@@ -6906,10 +8702,13 @@ class G20ThreeCameraCalibrationNode(Node):
record_joint_names = tuple(
name for name in item.spec.joints if name in scoped
)
- joint_bins: dict[str, dict[int, list[FrameObservation]]] = {}
- for joint_name in record_joint_names:
+
+ def observation_bins(
+ joint_name: str,
+ ) -> dict[int, list[FrameObservation]]:
bins: dict[int, list[FrameObservation]] = {}
- for frame in _frames_for_joint(self.sweep_frames, joint_name):
+ frames = _frames_for_joint(self.sweep_frames, joint_name)
+ for frame in frames:
state = float(frame.state_u8[motor])
if abs(state - item.start_u8) <= start_tolerance:
command = item.start_u8
@@ -6918,6 +8717,11 @@ class G20ThreeCameraCalibrationNode(Node):
else:
command = int(np.clip(np.rint(state), 0, 255))
bins.setdefault(command, []).append(frame)
+ return bins
+
+ joint_bins: dict[str, dict[int, list[FrameObservation]]] = {}
+ for joint_name in record_joint_names:
+ bins = observation_bins(joint_name)
commands = sorted(bins)
if not commands or commands[0] != 0 or commands[-1] != 255:
self._retry_active_sweep_or_pause(
@@ -6930,8 +8734,11 @@ class G20ThreeCameraCalibrationNode(Node):
if item.precheck:
# This low-speed pass is a visibility/safety check at 0, 127 and
# 255, not a source for the command-angle curve. Its full-sweep
- # detection rate is counted directly in the camera callback;
- # dense feedback-bin coverage remains a formal-curve requirement.
+ # detection rate is counted directly in the camera callback. A
+ # raw-frame rate below the nominal threshold is not itself a data
+ # defect when synchronised valid poses still cover the complete
+ # trajectory densely: detector flicker then changes only how many
+ # duplicate observations exist, not what motion is observable.
for joint_name, bins in joint_bins.items():
commands = sorted(bins)
if min(abs(command - 127) for command in commands) > 2:
@@ -6951,6 +8758,24 @@ class G20ThreeCameraCalibrationNode(Node):
self, "sweep_detection_valid_by_view", {}
)
capture_views = _sweep_views(_node_profile(self), item.spec)
+ profile = _node_profile(self)
+ trajectory_coverage_by_view: dict[str, bool] = {}
+ for view in capture_views:
+ view_joint_bins = [
+ joint_bins[name]
+ for name in _sweep_joints_for_view(
+ profile, item.spec, view
+ )
+ if name in joint_bins
+ ]
+ trajectory_coverage_by_view[view] = bool(view_joint_bins) and all(
+ len(bins) >= self.minimum_sweep_bins
+ and max(np.diff(sorted(bins)), default=0)
+ <= self.maximum_bin_gap
+ for bins in view_joint_bins
+ )
+ detection_rate_below_threshold_views: list[str] = []
+ trajectory_coverage_override_views: list[str] = []
for view in capture_views:
if view in total_by_view:
total = int(total_by_view.get(view, 0))
@@ -6970,10 +8795,14 @@ class G20ThreeCameraCalibrationNode(Node):
rate = 0.0 if total <= 0 else valid / total
detection_rates[view] = rate
if rate < self.minimum_detection_rate:
- self._retry_active_sweep_or_pause(
- f"task_precheck_detection_rate_too_low:{view}"
- )
- return
+ detection_rate_below_threshold_views.append(view)
+ if trajectory_coverage_by_view.get(view, False):
+ trajectory_coverage_override_views.append(view)
+ else:
+ self._retry_active_sweep_or_pause(
+ f"task_precheck_detection_rate_too_low:{view}"
+ )
+ return
minimum_bin_count = min(
len(bins) for bins in joint_bins.values()
)
@@ -7011,6 +8840,18 @@ class G20ThreeCameraCalibrationNode(Node):
view: round(rate, 6)
for view, rate in detection_rates.items()
},
+ "minimum_detection_rate": round(
+ float(self.minimum_detection_rate), 6
+ ),
+ "detection_rate_below_threshold_views": (
+ detection_rate_below_threshold_views
+ ),
+ "trajectory_coverage_by_view": (
+ trajectory_coverage_by_view
+ ),
+ "trajectory_coverage_override_views": (
+ trajectory_coverage_override_views
+ ),
"passed": True,
},
)
@@ -7024,12 +8865,18 @@ class G20ThreeCameraCalibrationNode(Node):
if self.sweep_index >= len(self.sweep_items):
self._begin_return_baseline("fit")
elif self.sweep_items[self.sweep_index].spec == item.spec:
- if (
- self.profile.layout_id == G20_RIGHT_19_LAYOUT
- and item.direction == DIRECTION_INCREASING
- ):
+ following = self.sweep_items[self.sweep_index]
+ transition = _sweep_plan_transition(
+ self.profile, item, following
+ )
+ if transition == "cycle_reset":
self._begin_return_baseline("next_cycle")
else:
+ G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames(
+ self,
+ item,
+ following,
+ )
self._start_next_sweep()
else:
self._begin_return_baseline(
@@ -7056,12 +8903,14 @@ class G20ThreeCameraCalibrationNode(Node):
)
return
+ stored_joint_names = tuple(joint_bins)
+
def synchronised_frame_payload(
frame: FrameObservation,
) -> dict[str, Any]:
names = [
name
- for name in record_joint_names
+ for name in stored_joint_names
if name in frame.joint_quaternions_xyzw
]
return {
@@ -7112,12 +8961,12 @@ class G20ThreeCameraCalibrationNode(Node):
for frame in self.sweep_frames
if any(
name in frame.joint_quaternions_xyzw
- for name in record_joint_names
+ for name in stored_joint_names
)
),
)
- for joint_name in record_joint_names:
+ for joint_name in stored_joint_names:
bins = joint_bins[joint_name]
commands = sorted(bins)
for command in commands:
@@ -7222,6 +9071,7 @@ class G20ThreeCameraCalibrationNode(Node):
)
append_jsonl(self.raw_path, durable)
+ G20ThreeCameraCalibrationNode._persist_palm_axis_samples(self, item)
G20ThreeCameraCalibrationNode._record_dedicated_baseline_hold(
self, item
)
@@ -7236,13 +9086,18 @@ class G20ThreeCameraCalibrationNode(Node):
self.active_sweep_is_fit_retry = False
if was_fit_retry:
if self.retry_sweep_items:
- if (
- self.profile.layout_id == G20_RIGHT_19_LAYOUT
- and item.direction == DIRECTION_INCREASING
- and self.retry_sweep_items[0].spec == previous_spec
- ):
+ following = self.retry_sweep_items[0]
+ transition = _sweep_plan_transition(
+ self.profile, item, following
+ )
+ if transition == "cycle_reset":
self._begin_return_baseline("next_cycle")
else:
+ G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames(
+ self,
+ item,
+ following,
+ )
self._start_next_sweep()
return
failures = self._provisional_fit_failures(previous_spec)
@@ -7311,19 +9166,26 @@ class G20ThreeCameraCalibrationNode(Node):
self, previous_spec
)
)
- elif (
- self.profile.layout_id == G20_RIGHT_19_LAYOUT
- and item.direction == DIRECTION_INCREASING
- ):
+ elif _sweep_plan_transition(
+ self.profile,
+ item,
+ self.sweep_items[self.sweep_index],
+ ) == "cycle_reset":
# Keep the task's complete avoidance pose between its three
- # rounds. Only the active joint returns to its standard-side
+ # repetitions. Only the active joint returns to its standard-side
# baseline (roll: 255->127); global unfolding is deferred until
# the task really changes.
self._begin_return_baseline("next_cycle")
else:
+ G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames(
+ self,
+ item,
+ self.sweep_items[self.sweep_index],
+ )
self._start_next_sweep()
def _fit_all_curves(self) -> None:
+ G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(self)
self.state = STATE_FITTING
self.reason = "fitting_3d_axes_and_urdf_zero_offsets"
# Fitting is deliberately synchronous so no capture callback can
@@ -7485,40 +9347,18 @@ class G20ThreeCameraCalibrationNode(Node):
self.validation_only_fits[validation_name] = (
validation_training_fit
)
- try:
- training_metrics = compare_cross_view_roll_curves(
- training_fits[name],
- validation_training_fit,
- maximum_rms_difference_rad=self.maximum_validation_mae_rad,
- maximum_branch_gap_difference_rad=(
- self.cross_view_roll_maximum_branch_gap_difference_rad
- ),
- )
- final_metrics = compare_cross_view_roll_curves(
- measured[name],
- validation_fit,
- maximum_rms_difference_rad=self.maximum_validation_mae_rad,
- maximum_branch_gap_difference_rad=(
- self.cross_view_roll_maximum_branch_gap_difference_rad
- ),
- )
- except ValueError as error:
- failed_spec = next(
- spec
- for spec in self.profile.sweep_specs
- if validation_name in spec.joints
- )
- self._pause_for_provisional_fit_failure(
- failed_spec,
- [
- {
- "joint": name,
- "metric": "cross_view_roll_curve",
- "reason": str(error),
- }
- ],
- )
- return
+ # The structured task-level check above records threshold
+ # overruns with per-cycle evidence. At publication time this
+ # secondary view remains diagnostic: the primary front curve and
+ # the side physical axis line retain their independent hard
+ # quality gates. Reuse the same policy as offline replay instead
+ # of reintroducing an unlocalized retry from a second code path.
+ training_metrics = cross_view_roll_diagnostic_metrics(
+ training_fits[name], validation_training_fit
+ )
+ final_metrics = cross_view_roll_diagnostic_metrics(
+ measured[name], validation_fit
+ )
validation_errors = joint_curve_holdout_errors(
validation_training_fit,
validation_holdout_model_records,
@@ -7595,25 +9435,90 @@ class G20ThreeCameraCalibrationNode(Node):
}
axes: list[JointAxisMeasurement] = []
for name in self.zero_profile.axis_joints:
- for cycle in range(self.repetitions):
- axes.append(self._fit_axis_measurement(name, cycle))
+ group = [
+ self._fit_axis_measurement(name, cycle)
+ for cycle in range(self.repetitions)
+ ]
+ axes.extend(self._refit_cross_view_axis_line_group(name, group))
+
+ try:
+ palm_orientation_measurements, palm_orientation_rejections = (
+ fit_partial_palm_orientation_measurements(
+ sources=self.profile.palm_orientation_sources,
+ records_by_joint=self.palm_axis_records_by_source,
+ motor_by_source=self.profile.palm_axis_motor_by_source,
+ baseline_command_u8=self.baseline_command,
+ cycles=range(self.repetitions),
+ minimum_sources=int(
+ self.profile.minimum_palm_orientation_sources
+ ),
+ minimum_arc_rad=self.trajectory_minimum_arc_rad,
+ maximum_rotation_orthogonal_rms_rad=(
+ self.active_maximum_rotation_orthogonal_rms_rad
+ ),
+ )
+ )
+ except ValueError as error:
+ failure = str(error)
+ self.palm_orientation_rejections = {"coverage": failure}
+ self.fit_failure = {
+ "task": "palm_orientation_side_channel",
+ "failures": [
+ {
+ "joint": "palm_orientation",
+ "metric": "palm_orientation_coverage",
+ "reason": failure,
+ }
+ ],
+ "directions_to_rescan": 0,
+ }
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "palm_orientation_optional_source_diagnostic",
+ "rejections": {"coverage": failure},
+ "decision": "reject_unobservable_palm_phase",
+ },
+ )
+ self._pause("palm_orientation_quality_failed")
+ return
+ if palm_orientation_rejections:
+ append_jsonl(
+ self.raw_path,
+ {
+ "kind": "palm_orientation_optional_source_diagnostic",
+ "rejections": dict(palm_orientation_rejections),
+ "decision": "use_qualified_redundant_sources",
+ },
+ )
motor_by_joint = {
name: int(spec.motor_index)
for name, spec in self.profile.joint_specs.items()
}
- endpoint_zero_offsets = (
- derive_right_19_flexion_endpoint_offsets(
- self.source_urdf_path,
+ if self.profile.layout_id == G20_RIGHT_19_LAYOUT:
+ command_fits = anchor_right_19_mechanical_endpoint_curves(
command_fits,
- maximum_offset_rad=self.flexion_endpoint_maximum_offset_rad,
+ self.command_records_by_joint,
+ maximum_direction_difference_rad=(
+ self.command_maximum_direction_gap_rad
+ ),
)
- if self.profile.layout_id == G20_RIGHT_19_LAYOUT
- else {}
- )
+ endpoint_zero_offsets = (
+ derive_right_19_mechanical_endpoint_offsets(
+ self.source_urdf_path,
+ command_fits,
+ maximum_offset_rad=(
+ self.mechanical_endpoint_maximum_offset_rad
+ ),
+ )
+ )
+ else:
+ endpoint_zero_offsets = {}
holdout_zero_result = solve_urdf_zero_offsets(
source_urdf=self.source_urdf_path,
measurements=axes,
+ palm_orientation_measurements=palm_orientation_measurements,
curves=training_fits,
motor_by_joint=motor_by_joint,
maximum_offset_rad=self.zero_maximum_offset_rad,
@@ -7633,6 +9538,9 @@ class G20ThreeCameraCalibrationNode(Node):
maximum_axis_cone_mismatch_rad=(
self.zero_maximum_axis_cone_mismatch_rad
),
+ maximum_systematic_axis_cone_bias_rad=(
+ self.cross_view_roll_maximum_axis_difference_rad
+ ),
maximum_observability_condition_number=(
self.zero_maximum_observability_condition_number
),
@@ -7706,6 +9614,13 @@ class G20ThreeCameraCalibrationNode(Node):
"failure_reasons": dict(
holdout_zero_result.failure_reasons
),
+ "axis_cone_mismatch_by_joint_rad": dict(
+ holdout_zero_result.axis_cone_mismatch_by_joint_rad
+ ),
+ "axis_cone_bias_classification_by_joint": dict(
+ holdout_zero_result
+ .axis_cone_bias_classification_by_joint
+ ),
},
)
holdout_errors = np.abs(
@@ -7862,6 +9777,12 @@ class G20ThreeCameraCalibrationNode(Node):
"offset_covariance_rad2": dict(
zero_result.offset_covariance_rad2
),
+ "axis_cone_mismatch_by_joint_rad": dict(
+ zero_result.axis_cone_mismatch_by_joint_rad
+ ),
+ "axis_cone_bias_classification_by_joint": dict(
+ zero_result.axis_cone_bias_classification_by_joint
+ ),
},
)
# Runtime/MuJoCo consumes requested commands, never feedback bins.
@@ -7877,7 +9798,14 @@ class G20ThreeCameraCalibrationNode(Node):
endpoint_anchored_offsets_rad=endpoint_zero_offsets,
)
self.axis_measurements = axes
+ self.palm_orientation_measurements = list(
+ palm_orientation_measurements
+ )
+ self.palm_orientation_rejections = dict(
+ palm_orientation_rejections
+ )
self.zero_result = zero_result
+ self.validated_endpoint_zero_offsets_rad = dict(endpoint_zero_offsets)
self.validation_errors_rad = [
float(value)
for values in holdout_by_joint.values()
@@ -8403,6 +10331,10 @@ class G20ThreeCameraCalibrationNode(Node):
passed = bool(self.fit_quality_passed and validation_passed)
if not passed:
raise RuntimeError("final_calibration_quality_failed")
+ endpoint_zero_offsets = (
+ G20ThreeCameraCalibrationNode
+ ._endpoint_zero_offsets_for_publication(self)
+ )
stamp = (
self.session_dir.name
if re.fullmatch(r"\d{8}_\d{6}", self.session_dir.name)
@@ -8488,6 +10420,29 @@ class G20ThreeCameraCalibrationNode(Node):
"offset_covariance_rad2": (
self.zero_result.offset_covariance_rad2
),
+ "palm_orientation_sources": [
+ {
+ "source_joint": item.source_joint,
+ "model_joint": item.model_joint,
+ "cycle": item.cycle,
+ "observed_arc_rad": item.observed_arc_rad,
+ "rotation_orthogonal_rms_rad": (
+ item.rotation_orthogonal_rms_rad
+ ),
+ }
+ for item in getattr(
+ self,
+ "palm_orientation_measurements",
+ (),
+ )
+ ],
+ "palm_orientation_rejections": dict(
+ getattr(
+ self,
+ "palm_orientation_rejections",
+ {},
+ )
+ ),
}
if self.profile.layout_id == G20_RIGHT_19_LAYOUT
else None
@@ -8526,6 +10481,48 @@ class G20ThreeCameraCalibrationNode(Node):
return
retries += 1
self.motion_retry_counts[reason] = retries
+ reset_views: list[str] = []
+ task_reference_preserved = False
+ if (
+ reason == "sweep_start_tag_timeout"
+ and self.state == STATE_PREPARE_SWEEP
+ and self.active_sweep is not None
+ ):
+ # Re-publishing the same endpoint command cannot repair a PnP
+ # tracker stuck on a planar mirror branch. Give every active
+ # camera a genuinely fresh bounded initialization window while
+ # retaining the task-level endpoint reference established by the
+ # visibility precheck/earlier cycles.
+ profile = _node_profile(self)
+ task_reference_preserved = (
+ _preserve_pnp_task_reference_for_sweep(
+ self.active_sweep,
+ is_fit_retry=bool(
+ getattr(self, "active_sweep_is_fit_retry", False)
+ ),
+ has_precheck_anchor=(
+ profile.layout_id == G20_RIGHT_19_LAYOUT
+ ),
+ )
+ )
+ reset_trackers = getattr(self, "_reset_view_trackers", None)
+ reset_diagnostics = getattr(
+ self, "_reset_view_pnp_diagnostics", None
+ )
+ for view in _sweep_views(profile, self.active_sweep.spec):
+ runtime = getattr(self, "views", {}).get(view)
+ if runtime is None or not callable(reset_trackers):
+ continue
+ reset_trackers(
+ runtime,
+ preserve_task_reference=task_reference_preserved,
+ )
+ if callable(reset_diagnostics):
+ reset_diagnostics(runtime)
+ runtime.pnp_invalid_since = None
+ runtime.pnp_reset_count += 1
+ reset_views.append(view)
+ getattr(self, "sweep_start_frames", []).clear()
append_jsonl(
self.raw_path,
{
@@ -8534,6 +10531,8 @@ class G20ThreeCameraCalibrationNode(Node):
"reason": reason,
"retry": retries,
"retry_limit": self.automatic_motion_retry_limit,
+ "pnp_trackers_reset": reset_views,
+ "task_reference_preserved": task_reference_preserved,
},
)
self.motion_stage_started_at = now
@@ -8837,6 +10836,9 @@ class G20ThreeCameraCalibrationNode(Node):
self.motion_retry_counts.pop(
"sweep_start_position_timeout", None
)
+ self.motion_retry_counts.pop(
+ "sweep_start_tag_timeout", None
+ )
self.position_hold_since = None
self._begin_active_sweep(now)
else:
@@ -9186,6 +11188,34 @@ class G20ThreeCameraCalibrationNode(Node):
),
"pnp_rejections": dict(runtime.latest_pnp_rejections),
"group_pnp_reason": runtime.latest_group_pnp_reason,
+ "group_missing_candidate_roles": list(
+ runtime.latest_group_missing_candidate_roles
+ ),
+ "pnp_candidate_diagnostics": {
+ role: {
+ "tag_id": runtime.view_tags[role],
+ **dict(diagnostics),
+ }
+ for role, diagnostics in (
+ runtime.tracker.last_candidate_diagnostics_by_role.items()
+ )
+ if role in required_roles and role not in locked_roles
+ },
+ "pnp_pose_valid": runtime.latest_pnp_valid,
+ "pnp_rejection_counts": dict(
+ runtime.pnp_rejection_counts
+ ),
+ "group_pnp_rejection_counts": dict(
+ runtime.group_pnp_rejection_counts
+ ),
+ "pnp_initialization_progress": (
+ None
+ if runtime.pnp_initialization_progress is None
+ else {
+ "accepted": runtime.pnp_initialization_progress[0],
+ "required": runtime.pnp_initialization_progress[1],
+ }
+ ),
"pnp_reset_count": runtime.pnp_reset_count,
"pnp_invalid_seconds": (
0.0
@@ -9198,10 +11228,24 @@ class G20ThreeCameraCalibrationNode(Node):
active = dict(self.motion_stall_details)
elif self.active_sweep is not None:
motor = self.active_sweep.spec.motor_index
- start_tolerance = self._endpoint_tolerance_for_spec(
+ start_tolerance = (
+ G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec(
+ self,
+ self.active_sweep.spec,
+ self.active_sweep.start_u8,
+ )
+ )
+ target_tolerance = (
+ G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec(
+ self,
+ self.active_sweep.spec,
+ self.active_sweep.target_u8,
+ )
+ )
+ mechanical_start_tolerance = self._endpoint_tolerance_for_spec(
self.active_sweep.spec, self.active_sweep.start_u8
)
- target_tolerance = self._endpoint_tolerance_for_spec(
+ mechanical_target_tolerance = self._endpoint_tolerance_for_spec(
self.active_sweep.spec, self.active_sweep.target_u8
)
values = [
@@ -9287,13 +11331,31 @@ class G20ThreeCameraCalibrationNode(Node):
storage_key, 1
),
"fit_attempt_limit": self.automatic_fit_retry_limit + 1,
- "automatic_retry_count": self.sweep_retry_counts.get(
- (
- storage_key,
- self.active_sweep.cycle,
- self.active_sweep.direction,
+ "fit_retry_cycles": (
+ [
+ cycle + 1
+ for cycle in sorted(
+ getattr(self, "retry_cycles", set())
+ )
+ ]
+ if getattr(self, "active_sweep_is_fit_retry", False)
+ else []
+ ),
+ "automatic_retry_count": max(
+ self.sweep_retry_counts.get(
+ (
+ storage_key,
+ self.active_sweep.cycle,
+ self.active_sweep.direction,
+ ),
+ 0,
+ ),
+ self.motion_retry_counts.get(
+ "sweep_start_tag_timeout", 0
+ ),
+ self.motion_retry_counts.get(
+ "sweep_start_position_timeout", 0
),
- 0,
),
"automatic_retry_limit": self.automatic_sweep_retry_limit,
"retry_speed_scale": (
@@ -9397,6 +11459,17 @@ class G20ThreeCameraCalibrationNode(Node):
),
"start_endpoint_tolerance_u8": start_tolerance,
"target_endpoint_tolerance_u8": target_tolerance,
+ "mechanical_endpoint_tolerance_u8": max(
+ mechanical_start_tolerance,
+ mechanical_target_tolerance,
+ ),
+ "synchronised_endpoint_tolerance_margin_u8": float(
+ getattr(
+ self,
+ "synchronised_endpoint_tolerance_margin_u8",
+ 1.0,
+ )
+ ),
},
"auxiliary_motors": [
{
@@ -9479,6 +11552,37 @@ class G20ThreeCameraCalibrationNode(Node):
if group_pnp_reasons:
active = dict(active)
active["group_pnp_reasons"] = group_pnp_reasons
+ pnp_rejection_counts = {
+ name: dict(runtime.pnp_rejection_counts)
+ for name, runtime in self.views.items()
+ if runtime.pnp_rejection_counts
+ }
+ if pnp_rejection_counts:
+ active = dict(active)
+ active["pnp_rejection_counts"] = pnp_rejection_counts
+ group_pnp_rejection_counts = {
+ name: dict(runtime.group_pnp_rejection_counts)
+ for name, runtime in self.views.items()
+ if runtime.group_pnp_rejection_counts
+ }
+ if group_pnp_rejection_counts:
+ active = dict(active)
+ active["group_pnp_rejection_counts"] = (
+ group_pnp_rejection_counts
+ )
+ pnp_initialization_progress = {
+ name: {
+ "accepted": runtime.pnp_initialization_progress[0],
+ "required": runtime.pnp_initialization_progress[1],
+ }
+ for name, runtime in self.views.items()
+ if runtime.pnp_initialization_progress is not None
+ }
+ if pnp_initialization_progress:
+ active = dict(active)
+ active["pnp_initialization_progress"] = (
+ pnp_initialization_progress
+ )
resumed_tasks = set(self.resumed_task_keys)
completed_sweep_count = sum(
1
diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py
index 5485704..56b94d1 100644
--- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py
+++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/urdf_zero.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from datetime import datetime
import math
import os
@@ -28,7 +28,12 @@ from .full_hand import (
JointCurveFit,
get_hand_calibration_profile,
)
-from .trajectory import _fit_circle_with_axis, _fit_joint_curve, _fit_plane_axis
+from .trajectory import (
+ _fit_circle_with_axis,
+ _fit_joint_curve,
+ _fit_plane_axis,
+ _plane_basis,
+)
@dataclass(frozen=True)
@@ -143,9 +148,11 @@ def _build_right_19_zero_profile(
Distal Tags recover all four PIP/DIP dynamic curves and axis quality, but
repeated motion with one unchanged camera/Tag installation cannot by
itself distinguish a fixed parallel-axis phase bias from an encoder zero.
- The caller therefore anchors MCP-pitch and PIP to the independently known
- full-flexion contact endpoint using their measured command curves. DIP
- stays passive and has no independently identifiable static zero.
+ The three coupled thumb-CMC offsets must therefore remain a visual
+ multi-axis solve; their electrical endpoints are not assumed to coincide
+ with source-CAD limits. Independently repeatable contact endpoints still
+ anchor thumb MCP, finger MCP-pitch and PIP from each session. DIP stays
+ passive and has no independently identifiable static zero.
"""
fingers = ("index", "middle", "ring", "pinky")
direct = (
@@ -236,41 +243,179 @@ def _build_right_19_zero_profile(
axis_parent_joint=axis_parent,
phase_parent_joint=phase_parent,
offset_observer_joint=observer,
- # thumb_mcp retains the commit-proven CAD reference. Four-finger
- # flexion offsets are supplied per session from the measured endpoint,
- # not stored as constants in this profile.
- fixed_direct_zero_offsets_rad={"thumb_mcp": 0.0},
+ # Endpoint-observable offsets are supplied by the caller. CMC remains
+ # visually solved and no serial-specific zero is hidden in the shared
+ # profile.
+ fixed_direct_zero_offsets_rad={},
static_output_zero_offsets_rad={},
)
-RIGHT_19_FLEXION_ENDPOINT_JOINTS = frozenset(
- f"{finger}_{suffix}"
- for finger in ("index", "middle", "ring", "pinky")
- for suffix in ("mcp_pitch", "pip")
+RIGHT_19_MECHANICAL_ENDPOINT_JOINTS = frozenset(
+ {
+ "thumb_mcp",
+ *(
+ f"{finger}_{suffix}"
+ for finger in ("index", "middle", "ring", "pinky")
+ for suffix in ("mcp_pitch", "pip")
+ ),
+ }
)
-def derive_right_19_flexion_endpoint_offsets(
+def anchor_right_19_mechanical_endpoint_curves(
+ curves: Mapping[str, JointCurveFit],
+ records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
+ *,
+ maximum_direction_difference_rad: float = math.radians(1.0),
+ maximum_curve_correction_rad: float = math.radians(3.0),
+) -> dict[str, JointCurveFit]:
+ """Set mechanical-endpoint curve scale from direct SO(3) travel.
+
+ A fitted-axis projection is useful for a dense, signed command curve, but
+ a small view-dependent orthogonal PnP component can change that
+ projection's full-scale value. The rotation magnitude between the two
+ settled endpoint poses is the revolute-joint travel itself: conjugating
+ both poses by a rigid hand/camera transform or either fixed Tag mounting
+ rotation cannot change it.
+
+ Use both sweep directions as independent endpoint measurements, reject
+ disagreement, and apply their robust mean as one scale correction to the
+ already validated curve. Curve shape, hysteresis, axis diagnostics and
+ all existing quality gates remain unchanged.
+ """
+ if (
+ not math.isfinite(maximum_direction_difference_rad)
+ or maximum_direction_difference_rad <= 0.0
+ ):
+ raise ValueError("endpoint direction difference limit must be positive")
+ if (
+ not math.isfinite(maximum_curve_correction_rad)
+ or maximum_curve_correction_rad <= 0.0
+ ):
+ raise ValueError("endpoint curve correction limit must be positive")
+
+ result = dict(curves)
+ for name in sorted(RIGHT_19_MECHANICAL_ENDPOINT_JOINTS):
+ fit = curves.get(name)
+ records = list(records_by_joint.get(name, ()))
+ if fit is None:
+ raise ValueError(f"missing measured endpoint curve for {name}")
+
+ direction_travel: dict[str, float] = {}
+ for direction in ("decreasing", "increasing"):
+ endpoint_rotations: dict[int, Rotation] = {}
+ for command in (0, 255):
+ quaternions = [
+ _relative_rotation(record)
+ for record in records
+ if str(record.get("direction")) == direction
+ and int(
+ record.get(
+ "requested_command_u8",
+ record.get("command_u8", -1),
+ )
+ )
+ == command
+ ]
+ if not quaternions:
+ raise ValueError(
+ f"{name} {direction} is missing settled endpoint {command}"
+ )
+ endpoint_rotations[command] = Rotation.from_quat(
+ robust_rotation_summary(quaternions)[0]
+ )
+ direction_travel[direction] = float(
+ (
+ endpoint_rotations[255].inv()
+ * endpoint_rotations[0]
+ ).magnitude()
+ )
+
+ travel_values = np.asarray(list(direction_travel.values()), dtype=float)
+ direction_difference = float(np.ptp(travel_values))
+ if direction_difference > maximum_direction_difference_rad:
+ raise ValueError(
+ f"{name} settled endpoint directions disagree: "
+ f"{math.degrees(direction_difference):.3f}deg"
+ )
+ direct_travel = float(np.median(travel_values))
+ curve = np.asarray(fit.angle_rad, dtype=float)
+ if curve.shape != (256,) or not np.all(np.isfinite(curve)):
+ raise ValueError(f"{name} endpoint curve must contain 256 finite bins")
+ projected_travel = float(curve[0] - curve[255])
+ if direct_travel <= 0.0 or projected_travel <= 0.0:
+ raise ValueError(f"{name} endpoint travel must be positive")
+ correction = abs(projected_travel - direct_travel)
+ if correction > maximum_curve_correction_rad:
+ raise ValueError(
+ f"{name} fitted/direct endpoint travel differs by "
+ f"{math.degrees(correction):.3f}deg"
+ )
+ scale = direct_travel / projected_travel
+
+ def scaled(values: Sequence[float]) -> tuple[float, ...]:
+ return tuple(float(value) * scale for value in values)
+
+ circle = dict(fit.circle)
+ circle.update(
+ {
+ "mechanical_endpoint_direct_travel_rad": direct_travel,
+ "mechanical_endpoint_direction_difference_rad": (
+ direction_difference
+ ),
+ "mechanical_endpoint_raw_curve_travel_rad": projected_travel,
+ "mechanical_endpoint_curve_scale": scale,
+ }
+ )
+ quality = dict(fit.quality)
+ if "arc_rad" in quality:
+ quality["arc_rad"] = float(quality["arc_rad"]) * scale
+ result[name] = replace(
+ fit,
+ angle_rad=scaled(fit.angle_rad),
+ decreasing_rad=scaled(fit.decreasing_rad),
+ increasing_rad=scaled(fit.increasing_rad),
+ circle=circle,
+ maximum_monotonic_correction_rad=(
+ float(fit.maximum_monotonic_correction_rad) * scale
+ ),
+ maximum_hysteresis_rad=float(fit.maximum_hysteresis_rad) * scale,
+ quality=quality,
+ zero_offset_rad=float(fit.zero_offset_rad) * scale,
+ )
+ return result
+
+
+def derive_right_19_mechanical_endpoint_offsets(
source_urdf: str | Path,
curves: Mapping[str, JointCurveFit],
*,
maximum_offset_rad: float = math.radians(5.0),
) -> dict[str, float]:
- """Estimate flexion origins from the measured full-flexion contact.
+ """Estimate encoder origins from repeatable measured mechanical endpoints.
- G20 feedback 0 is the independently verified palm-contact endpoint. The
- source CAD upper angle describes that same physical endpoint. Therefore
- ``origin_offset + measured_endpoint == CAD_upper``. This makes every
- returned value session-derived while avoiding the fixed planar-PnP phase
- bias that affected the parallel MCP/PIP/DIP axes.
+ For the four fingers feedback 0 is the independently verified palm-contact
+ endpoint; thumb MCP uses its independently verified actuator endpoint.
+ The source URDF upper limit describes those same physical endpoints.
+ Therefore ``origin_offset + measured_travel == CAD_upper``.
+
+ The coupled thumb-CMC axes are deliberately excluded. Physical
+ inspection of G20_RIGHT_001 proved that their electrical actuator
+ endpoints do not equal the source-CAD upper coordinates. Treating them
+ as equal produced repeatable but physically wrong zero offsets.
+
+ The returned corrections are measured, generally non-zero encoder zeros;
+ they do not retain the CAD origin. Using full relative rotation travel
+ makes them invariant to rigid hand movement and arbitrary fixed Tag
+ mounting rotation, unlike a cross-camera absolute planar-PnP phase.
"""
if not math.isfinite(maximum_offset_rad) or maximum_offset_rad <= 0.0:
- raise ValueError("flexion endpoint maximum offset must be positive")
+ raise ValueError("mechanical endpoint maximum offset must be positive")
root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot()
joints = {str(node.get("name")): node for node in root.findall("joint")}
result: dict[str, float] = {}
- for name in sorted(RIGHT_19_FLEXION_ENDPOINT_JOINTS):
+ for name in sorted(RIGHT_19_MECHANICAL_ENDPOINT_JOINTS):
fit = curves.get(name)
joint = joints.get(name)
limit = None if joint is None else joint.find("limit")
@@ -279,7 +424,18 @@ def derive_right_19_flexion_endpoint_offsets(
if limit is None or limit.get("upper") is None:
raise ValueError(f"source URDF joint {name} has no upper limit")
upper = float(limit.get("upper"))
- measured_endpoint = float(np.max(np.asarray(fit.angle_rad, dtype=float)))
+ curve = np.asarray(fit.angle_rad, dtype=float)
+ if curve.shape != (256,) or not np.all(np.isfinite(curve)):
+ raise ValueError(f"{name} endpoint curve must contain 256 finite bins")
+ measured_endpoint = float(curve[0])
+ if measured_endpoint <= 0.0 or abs(float(curve[255])) > math.radians(0.05):
+ raise ValueError(
+ f"{name} endpoint curve does not use command 255 as its zero reference"
+ )
+ if abs(measured_endpoint - float(np.max(curve))) > math.radians(0.05):
+ raise ValueError(
+ f"{name} command 0 is not the measured upper mechanical endpoint"
+ )
offset = upper - measured_endpoint
if not math.isfinite(offset) or abs(offset) > maximum_offset_rad:
raise ValueError(
@@ -319,6 +475,47 @@ def circle_direction_is_constrained(
name = str(joint_name)
return name in constrained_circle_joints or name.endswith("_side")
+
+def select_cross_view_roll_direction_source(
+ primary_cone_residuals_rad: Sequence[float],
+ secondary_cone_residuals_rad: Sequence[float],
+ maximum_cone_mismatch_rad: float,
+) -> str:
+ """Choose one roll-axis view for the complete repeated-sweep group.
+
+ The choice must not be made independently for every cycle. A residual
+ sitting just either side of the cone gate can otherwise alternate the
+ selected camera and turn a fixed cross-view bias into a false cycle-axis
+ spread. The secondary view wins only with a three-quarter consensus and
+ a median residual inside the unchanged geometry gate; ambiguous groups
+ retain the primary view.
+ """
+ primary = np.asarray(primary_cone_residuals_rad, dtype=float)
+ secondary = np.asarray(secondary_cone_residuals_rad, dtype=float)
+ if (
+ primary.ndim != 1
+ or secondary.ndim != 1
+ or len(primary) != len(secondary)
+ or len(primary) < 3
+ or not np.all(np.isfinite(primary))
+ or not np.all(np.isfinite(secondary))
+ or float(maximum_cone_mismatch_rad) <= 0.0
+ ):
+ return "primary"
+ limit = float(maximum_cone_mismatch_rad)
+ required_consensus = int(math.ceil(0.75 * len(primary)))
+ primary_passes = int(np.count_nonzero(primary <= limit))
+ secondary_passes = int(np.count_nonzero(secondary <= limit))
+ if (
+ secondary_passes >= required_consensus
+ and primary_passes < required_consensus
+ and float(np.median(secondary)) <= limit
+ and float(np.median(primary)) > limit
+ ):
+ return "secondary"
+ return "primary"
+
+
ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 = 16
# Fit an axis-line point from the complete relative SE(3) trajectory instead
@@ -833,6 +1030,555 @@ class JointAxisMeasurement:
circle_axis_observability: float = 0.0
axis_point_source: str = "circle_center"
pose_axis_line_rms_m: float = 0.0
+ # Measurement record(s) that supplied pose_axis_line_rms_m. A combined
+ # cross-view axis may keep the front direction but take its physical line
+ # point and line-quality residual from the side alias. Retry logic must
+ # clear the actual quality source instead of blindly rescanning ``joint``.
+ pose_axis_line_source_joints: tuple[str, ...] = ()
+ # Camera centre that observed ``point_common_xyz_m``. When populated,
+ # the zero solver can use only the ray from this centre to the fitted axis
+ # point. That ray is the depth-free interpretation-plane observation of
+ # the physical axis; translating a monocular planar-PnP solution along
+ # its optical ray therefore cannot rotate the recovered palm frame.
+ axis_point_camera_center_common_xyz_m: (
+ tuple[float, float, float] | None
+ ) = None
+ # Normal of the source camera's interpretation plane for this axis line.
+ # Unlike a 3-D PnP line point, the plane is unchanged by optical-depth
+ # error. Several named parallel root lines jointly recover their common
+ # physical direction as the null direction of these plane normals.
+ axis_point_interpretation_plane_normal_common_xyz: (
+ tuple[float, float, float] | None
+ ) = None
+
+
+@dataclass(frozen=True)
+class PalmOrientationMeasurement:
+ """Direction-only joint observation from a partially visible sweep.
+
+ The moving Tag may disappear before the motor reaches its far endpoint.
+ Only the relative SO(3) trajectory is retained, so fixed Tag translation
+ and mounting rotation cannot define the palm phase.
+ """
+
+ source_joint: str
+ model_joint: str
+ cycle: int
+ axis_common_xyz: tuple[float, float, float]
+ condition_state_u8: tuple[float, ...]
+ observed_arc_rad: float
+ rotation_orthogonal_rms_rad: float
+
+
+def fit_partial_palm_orientation_measurement(
+ source_joint: str,
+ model_joint: str,
+ records: Sequence[Mapping[str, Any]],
+ *,
+ cycle: int,
+ zero_command_u8: int,
+ minimum_arc_rad: float = math.radians(15.0),
+ maximum_rotation_orthogonal_rms_rad: float = math.radians(2.5),
+) -> PalmOrientationMeasurement:
+ """Fit a physical axis direction without requiring full command travel."""
+ samples = [
+ dict(record)
+ for record in records
+ if int(record.get("cycle", -1)) == int(cycle)
+ ]
+ if len(samples) < 12:
+ raise ValueError(
+ f"{source_joint} cycle {cycle + 1} has too few visible samples"
+ )
+ if not 0.0 < float(minimum_arc_rad) < math.pi:
+ raise ValueError("palm orientation minimum arc must be in (0, pi)")
+ if float(maximum_rotation_orthogonal_rms_rad) <= 0.0:
+ raise ValueError(
+ "palm orientation rotation residual limit must be positive"
+ )
+ reference = Rotation.from_quat(
+ _baseline_reference(samples, int(zero_command_u8))
+ )
+ samples_by_bin: dict[tuple[str, int], list[Mapping[str, Any]]] = {}
+ for record in samples:
+ key = (
+ str(record.get("direction", "")),
+ int(record["command_u8"]),
+ )
+ samples_by_bin.setdefault(key, []).append(record)
+ if len(samples_by_bin) < 6:
+ raise ValueError(
+ f"{source_joint} cycle {cycle + 1} has too few visible command bins"
+ )
+ binned_rotations = [
+ Rotation.from_quat(
+ robust_rotation_summary(
+ [_relative_rotation(record) for record in group]
+ )[0]
+ )
+ for group in samples_by_bin.values()
+ ]
+ vectors = [
+ (reference.inv() * rotation).as_rotvec()
+ for rotation in binned_rotations
+ ]
+ axis_child = fit_rotation_axis(
+ vectors, [command for _direction, command in samples_by_bin]
+ )
+ angles = np.asarray(
+ [float(vector @ axis_child) for vector in vectors], dtype=float
+ )
+ observed_arc = float(np.ptp(angles))
+ orthogonal_rms = float(
+ np.sqrt(
+ np.mean(
+ [
+ np.linalg.norm(
+ vector - float(vector @ axis_child) * axis_child
+ )
+ ** 2
+ for vector in vectors
+ ]
+ )
+ )
+ )
+ if observed_arc < float(minimum_arc_rad):
+ raise ValueError(
+ f"{source_joint} cycle {cycle + 1} visible rotation arc "
+ f"{math.degrees(observed_arc):.3f}deg is below "
+ f"{math.degrees(minimum_arc_rad):.3f}deg"
+ )
+ if orthogonal_rms > float(maximum_rotation_orthogonal_rms_rad):
+ raise ValueError(
+ f"{source_joint} cycle {cycle + 1} rotation residual "
+ f"{math.degrees(orthogonal_rms):.3f}deg exceeds "
+ f"{math.degrees(maximum_rotation_orthogonal_rms_rad):.3f}deg"
+ )
+ zero_records = _near_zero_records(samples, int(zero_command_u8))
+ if not zero_records:
+ raise ValueError(
+ f"{source_joint} cycle {cycle + 1} has no visible zero pose"
+ )
+ parent_poses = np.asarray(
+ [_pose_matrix(record["parent_pose_common"]) for record in zero_records]
+ )
+ parent_quaternion = robust_rotation_summary(
+ [
+ Rotation.from_matrix(matrix[:3, :3]).as_quat()
+ for matrix in parent_poses
+ ]
+ )[0]
+ axis_parent = reference.apply(axis_child)
+ axis_common = Rotation.from_quat(parent_quaternion).apply(axis_parent)
+ axis_common /= np.linalg.norm(axis_common)
+ state = np.median(
+ np.asarray(
+ [record["state_u8"] for record in zero_records], dtype=float
+ ),
+ axis=0,
+ )
+ return PalmOrientationMeasurement(
+ source_joint=str(source_joint),
+ model_joint=str(model_joint),
+ cycle=int(cycle),
+ axis_common_xyz=tuple(float(value) for value in axis_common),
+ condition_state_u8=tuple(float(value) for value in state),
+ observed_arc_rad=observed_arc,
+ rotation_orthogonal_rms_rad=orthogonal_rms,
+ )
+
+
+def fit_partial_palm_orientation_measurements(
+ *,
+ sources: Mapping[str, str],
+ records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
+ motor_by_source: Mapping[str, int],
+ baseline_command_u8: Sequence[int],
+ cycles: Sequence[int],
+ minimum_sources: int,
+ minimum_arc_rad: float = math.radians(15.0),
+ maximum_rotation_orthogonal_rms_rad: float = math.radians(2.5),
+) -> tuple[tuple[PalmOrientationMeasurement, ...], Mapping[str, str]]:
+ """Fit every usable optional source and require redundant coverage."""
+ source_map = {str(name): str(model) for name, model in sources.items()}
+ minimum = int(minimum_sources)
+ if not source_map:
+ if minimum != 0:
+ raise ValueError(
+ "palm orientation minimum is non-zero without sources"
+ )
+ return (), {}
+ if minimum < 2 or minimum > len(source_map):
+ raise ValueError(
+ "palm orientation minimum source count must be between two "
+ "and the configured source count"
+ )
+ missing_motors = sorted(set(source_map) - set(motor_by_source))
+ if missing_motors:
+ raise ValueError(
+ "palm orientation sources are missing motor mappings: "
+ + ",".join(missing_motors)
+ )
+ fitted: list[PalmOrientationMeasurement] = []
+ rejected: dict[str, str] = {}
+ for cycle in (int(value) for value in cycles):
+ cycle_fitted: list[PalmOrientationMeasurement] = []
+ for source_joint, model_joint in source_map.items():
+ motor = int(motor_by_source[source_joint])
+ try:
+ measurement = fit_partial_palm_orientation_measurement(
+ source_joint,
+ model_joint,
+ records_by_joint.get(source_joint, ()),
+ cycle=cycle,
+ zero_command_u8=int(baseline_command_u8[motor]),
+ minimum_arc_rad=minimum_arc_rad,
+ maximum_rotation_orthogonal_rms_rad=(
+ maximum_rotation_orthogonal_rms_rad
+ ),
+ )
+ except Exception as error:
+ rejected[f"{source_joint}:cycle{cycle + 1}"] = str(error)
+ continue
+ cycle_fitted.append(measurement)
+ if len(cycle_fitted) < minimum:
+ cycle_reasons = {
+ key: value
+ for key, value in rejected.items()
+ if key.endswith(f":cycle{cycle + 1}")
+ }
+ raise ValueError(
+ f"palm orientation cycle {cycle + 1} has "
+ f"{len(cycle_fitted)}/{minimum} usable sources: "
+ + "; ".join(
+ f"{key}={value}" for key, value in cycle_reasons.items()
+ )
+ )
+ fitted.extend(cycle_fitted)
+ return tuple(fitted), rejected
+
+
+def with_depth_free_axis_projection(
+ measurement: JointAxisMeasurement,
+ camera_center_common_xyz_m: Sequence[float],
+) -> JointAxisMeasurement:
+ """Attach the source-camera interpretation plane of an axis line.
+
+ The plane is defined by the camera centre and the fitted 3-D line, but is
+ only a projective observation: moving either fitted line point along its
+ optical ray leaves the plane unchanged. A fixed Tag mount changes the
+ moving point trajectory, not its recovered physical screw axis.
+ """
+ camera_center = _vector(
+ camera_center_common_xyz_m, 3, name="axis camera centre"
+ )
+ point = np.asarray(measurement.point_common_xyz_m, dtype=float)
+ axis = np.asarray(measurement.axis_common_xyz, dtype=float)
+ axis /= np.linalg.norm(axis)
+ ray = point - camera_center
+ ray_norm = float(np.linalg.norm(ray))
+ if ray_norm <= 1.0e-6:
+ raise ValueError("axis point coincides with its source camera")
+ plane_normal = np.cross(ray / ray_norm, axis)
+ plane_norm = float(np.linalg.norm(plane_normal))
+ if plane_norm <= 1.0e-6:
+ raise ValueError("axis projection is degenerate in its source camera")
+ plane_normal /= plane_norm
+ return replace(
+ measurement,
+ axis_point_camera_center_common_xyz_m=tuple(
+ float(value) for value in camera_center
+ ),
+ axis_point_interpretation_plane_normal_common_xyz=tuple(
+ float(value) for value in plane_normal
+ ),
+ )
+
+
+def cross_view_side_line_source(
+ measurement: JointAxisMeasurement,
+) -> str | None:
+ """Return the side alias that supplied a fallback axis-line point."""
+ sources = tuple(
+ str(source)
+ for source in getattr(
+ measurement, "pose_axis_line_source_joints", ()
+ )
+ )
+ if (
+ getattr(measurement, "axis_point_source", "")
+ in {
+ "side_circle_cross_view",
+ "side_circle_shared_radius_cross_view",
+ "side_interpretation_plane_cross_view",
+ }
+ and len(sources) == 1
+ and sources[0].endswith("_side")
+ ):
+ return sources[0]
+ return None
+
+
+def axis_line_uses_depth_free_interpretation_plane(
+ measurement: JointAxisMeasurement,
+) -> bool:
+ """Return whether only the source-camera bearing is geometrically used."""
+ return bool(
+ measurement.axis_point_source
+ in {
+ "side_interpretation_plane_cross_view",
+ "front_interpretation_plane_cross_view_validated",
+ }
+ and measurement.axis_point_camera_center_common_xyz_m is not None
+ )
+
+
+def refit_axis_line_group_with_shared_radius(
+ measurements: Sequence[JointAxisMeasurement],
+ records: Sequence[Mapping[str, Any]],
+ *,
+ zero_command_u8: int,
+ canonical_zero_direction: str | None,
+) -> tuple[JointAxisMeasurement, ...]:
+ """Refit repeated cross-view axis lines with one physical radius.
+
+ A side-view roll alias observes the same child Tag and the same physical
+ lever arm in every cycle. Fitting an independent radius to each short,
+ near-edge-on arc leaves radius and circle centre strongly correlated;
+ sub-pixel PnP noise can then move the reported axis line by several
+ millimetres even though every trajectory has a low radial residual.
+
+ Keep a separate centre (and therefore an independent line-repeatability
+ check) for every cycle, but solve one shared radius from all cycles. This
+ is a physical constraint rather than a relaxed quality gate: the returned
+ lines are still checked against the unchanged cycle RMS limit, and each
+ cycle's radial residual remains an independent hard check.
+ """
+ group = tuple(measurements)
+ if len(group) < 2:
+ return group
+ cycles = [int(measurement.cycle) for measurement in group]
+ if len(set(cycles)) != len(cycles):
+ raise ValueError("shared-radius axis group contains duplicate cycles")
+
+ entries: list[dict[str, Any]] = []
+ initial_parameters: list[float] = []
+ initial_radii: list[float] = []
+ for measurement in group:
+ cycle_records = [
+ dict(record)
+ for record in records
+ if int(record.get("cycle", -1)) == int(measurement.cycle)
+ ]
+ motion_records = [
+ record
+ for record in cycle_records
+ if str(record.get("kind", "sample")) != "baseline_hold_sample"
+ ]
+ if len(motion_records) < 6:
+ motion_records = cycle_records
+ if len(motion_records) < 6:
+ raise ValueError(
+ f"cycle {measurement.cycle + 1} has too few shared-radius samples"
+ )
+
+ reference_records = _canonical_reference_records(
+ cycle_records, canonical_zero_direction
+ )
+ zero_records = _near_zero_records(
+ reference_records, int(zero_command_u8)
+ )
+ if not zero_records:
+ raise ValueError(
+ f"cycle {measurement.cycle + 1} has no shared-radius zero pose"
+ )
+ parent_poses = np.asarray(
+ [_pose_matrix(record["parent_pose_common"]) for record in zero_records]
+ )
+ parent_translation = np.median(parent_poses[:, :3, 3], axis=0)
+ parent_quaternion = robust_rotation_summary(
+ [
+ Rotation.from_matrix(matrix[:3, :3]).as_quat()
+ for matrix in parent_poses
+ ]
+ )[0]
+ parent_rotation = Rotation.from_quat(parent_quaternion)
+ common_axis = _vector(
+ measurement.axis_common_xyz, 3, name="shared-radius common axis"
+ )
+ common_axis /= np.linalg.norm(common_axis)
+ parent_axis = parent_rotation.inv().apply(common_axis)
+ parent_axis /= np.linalg.norm(parent_axis)
+
+ points = np.asarray(
+ [record["relative_translation_xyz_m"] for record in motion_records],
+ dtype=float,
+ )
+ basis_x, basis_y = _plane_basis(parent_axis)
+ origin = np.mean(points, axis=0)
+ local = points - origin
+ points_xy = np.column_stack((local @ basis_x, local @ basis_y))
+ initial = _fit_circle_with_axis(points, parent_axis)
+ initial_center = _vector(
+ initial["center_xyz_m"], 3, name="initial shared-radius centre"
+ )
+ initial_parameters.extend(
+ [
+ float((initial_center - origin) @ basis_x),
+ float((initial_center - origin) @ basis_y),
+ ]
+ )
+ initial_radii.append(float(initial["radius_m"]))
+ entries.append(
+ {
+ "measurement": measurement,
+ "points": points,
+ "points_xy": points_xy,
+ "origin": origin,
+ "basis_x": basis_x,
+ "basis_y": basis_y,
+ "parent_axis": parent_axis,
+ "parent_rotation": parent_rotation,
+ "parent_translation": parent_translation,
+ }
+ )
+
+ median_radius = float(np.median(initial_radii))
+ if median_radius <= 1.0e-6 or not math.isfinite(median_radius):
+ raise ValueError("shared-radius axis group has an invalid initial radius")
+ initial_value = np.asarray(
+ [*initial_parameters, median_radius], dtype=float
+ )
+
+ def residual(parameters: np.ndarray) -> np.ndarray:
+ radius = float(parameters[-1])
+ return np.concatenate(
+ [
+ np.linalg.norm(
+ entry["points_xy"]
+ - parameters[2 * index : 2 * index + 2],
+ axis=1,
+ )
+ - radius
+ for index, entry in enumerate(entries)
+ ]
+ ) / 0.0005
+
+ lower = np.full(initial_value.shape, -np.inf, dtype=float)
+ upper = np.full(initial_value.shape, np.inf, dtype=float)
+ lower[-1] = max(1.0e-6, 0.5 * median_radius)
+ upper[-1] = 2.0 * median_radius
+ solution = least_squares(
+ residual,
+ initial_value,
+ bounds=(lower, upper),
+ loss="soft_l1",
+ f_scale=1.0,
+ max_nfev=3000,
+ )
+ if not solution.success:
+ raise ValueError(
+ "shared-radius axis optimization failed: " + solution.message
+ )
+
+ shared_radius = float(solution.x[-1])
+ if not math.isfinite(shared_radius):
+ raise ValueError("shared-radius axis optimization is non-finite")
+ result: list[JointAxisMeasurement] = []
+ for index, entry in enumerate(entries):
+ center_xy = solution.x[2 * index : 2 * index + 2]
+ center_parent = (
+ entry["origin"]
+ + float(center_xy[0]) * entry["basis_x"]
+ + float(center_xy[1]) * entry["basis_y"]
+ )
+ # The coordinate along an infinite axis is a gauge. Retain the
+ # robust centre of this cycle's observed axial coordinates.
+ axial_offsets = (
+ entry["points"] - center_parent
+ ) @ entry["parent_axis"]
+ center_parent += float(np.median(axial_offsets)) * entry["parent_axis"]
+ center_common = (
+ entry["parent_rotation"].apply(center_parent)
+ + entry["parent_translation"]
+ )
+ radial_residual = (
+ np.linalg.norm(
+ entry["points_xy"] - center_xy,
+ axis=1,
+ )
+ - shared_radius
+ )
+ radial_rms = float(np.sqrt(np.mean(np.square(radial_residual))))
+ measurement = entry["measurement"]
+ result.append(
+ replace(
+ measurement,
+ point_common_xyz_m=tuple(
+ float(value) for value in center_common
+ ),
+ radial_rms_m=max(float(measurement.radial_rms_m), radial_rms),
+ axis_point_source="side_circle_shared_radius_cross_view",
+ )
+ )
+ return tuple(result)
+
+
+def maximum_axis_line_cycle_spread_m(
+ measurements: Sequence[JointAxisMeasurement],
+) -> float:
+ """Measure repeatability of independently fitted near-parallel lines.
+
+ Axis-line points have an arbitrary coordinate along their own direction.
+ Compare only the perpendicular displacement, symmetrically against both
+ fitted directions, so that the result remains meaningful with the small
+ allowed cycle-to-cycle direction variation.
+ """
+ maximum = 0.0
+ for left_index, left in enumerate(measurements):
+ left_axis = np.asarray(left.axis_common_xyz, dtype=float)
+ left_axis /= np.linalg.norm(left_axis)
+ left_point = np.asarray(left.point_common_xyz_m, dtype=float)
+ for right in measurements[left_index + 1 :]:
+ right_axis = np.asarray(right.axis_common_xyz, dtype=float)
+ right_axis /= np.linalg.norm(right_axis)
+ delta = np.asarray(right.point_common_xyz_m, dtype=float) - left_point
+ maximum = max(
+ maximum,
+ float(np.linalg.norm(np.cross(delta, left_axis))),
+ float(np.linalg.norm(np.cross(delta, right_axis))),
+ )
+ return maximum
+
+
+def axis_line_cycle_rms_m(
+ measurements: Sequence[JointAxisMeasurement],
+) -> float:
+ """Return RMS line-position scatter about the four-cycle consensus.
+
+ For parallel lines, the sum of squared pairwise distances divided by
+ ``n**2`` equals the mean squared distance from their centroid. Averaging
+ each pair's distance against both near-parallel directions preserves that
+ identity while avoiding an arbitrary choice of one cycle's direction.
+ """
+ count = len(measurements)
+ if count < 2:
+ return 0.0
+ squared_pairwise_sum = 0.0
+ for left_index, left in enumerate(measurements):
+ left_axis = np.asarray(left.axis_common_xyz, dtype=float)
+ left_axis /= np.linalg.norm(left_axis)
+ left_point = np.asarray(left.point_common_xyz_m, dtype=float)
+ for right in measurements[left_index + 1 :]:
+ right_axis = np.asarray(right.axis_common_xyz, dtype=float)
+ right_axis /= np.linalg.norm(right_axis)
+ delta = np.asarray(right.point_common_xyz_m, dtype=float) - left_point
+ left_distance = float(np.linalg.norm(np.cross(delta, left_axis)))
+ right_distance = float(np.linalg.norm(np.cross(delta, right_axis)))
+ squared_pairwise_sum += 0.5 * (
+ left_distance**2 + right_distance**2
+ )
+ return float(math.sqrt(squared_pairwise_sum / (count**2)))
def _fit_axis_point_from_pose_trajectory(
@@ -1223,6 +1969,7 @@ def fit_joint_axis_measurement(
circle_axis_observability=float(circle_axis_observability),
axis_point_source=axis_point_source,
pose_axis_line_rms_m=pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(str(joint),),
)
@@ -1405,6 +2152,8 @@ class ZeroSolveResult:
observability_parameter_count: int
observability_condition_number: float
offset_covariance_rad2: Mapping[str, float]
+ axis_cone_mismatch_by_joint_rad: Mapping[str, float]
+ axis_cone_bias_classification_by_joint: Mapping[str, str]
failure_reasons: Mapping[str, str]
@@ -1430,6 +2179,9 @@ def solve_urdf_zero_offsets(
*,
source_urdf: str | Path,
measurements: Sequence[JointAxisMeasurement],
+ palm_orientation_measurements: Sequence[
+ PalmOrientationMeasurement
+ ] = (),
curves: Mapping[str, JointCurveFit],
motor_by_joint: Mapping[str, int],
training_cycles: Sequence[int] = (0, 1),
@@ -1445,6 +2197,7 @@ def solve_urdf_zero_offsets(
maximum_validation_error_rad: float | None = None,
maximum_confidence_half_width_rad: float | None = None,
maximum_axis_cone_mismatch_rad: float = math.radians(5.0),
+ maximum_systematic_axis_cone_bias_rad: float | None = None,
maximum_pose_axis_line_rms_m: float = 0.001,
maximum_observability_condition_number: float = 1.0e10,
hand_type: str = "left",
@@ -1454,12 +2207,12 @@ def solve_urdf_zero_offsets(
) -> ZeroSolveResult:
"""Solve all directly observable zero offsets without fitting CAD errors.
- The palm pose uses the most observable root-axis direction together with
- the separation of the two root lines. Encoder zeros are then fitted from
- one angular residual per joint: downstream-axis direction for non-parallel
- pairs and the radial phase between parallel axis lines. This prevents link
- length, along-axis Tag placement, and monocular depth bias from being
- absorbed as an encoder-zero correction.
+ The product palm pose uses the robust common root-axis direction together
+ with independent partial-sweep MCP-pitch directions; root-line points
+ determine translation only. Legacy layouts retain their single observed
+ reference chain. Encoder zeros are then fitted from one angular residual
+ per joint. This prevents link length, along-axis Tag placement and
+ monocular depth bias from becoming encoder-zero corrections.
"""
profile = get_zero_calibration_profile(hand_type, tag_layout)
model = UrdfKinematicModel(source_urdf)
@@ -1471,6 +2224,62 @@ def solve_urdf_zero_offsets(
or {m.joint for m in validation} != expected
):
raise ValueError("axis measurements do not contain all required joints/cycles")
+ configured_palm_sources = {
+ str(name): str(model_joint)
+ for name, model_joint in (
+ profile.hand.palm_orientation_sources or {}
+ ).items()
+ }
+ orientation_measurements = tuple(palm_orientation_measurements)
+ if configured_palm_sources:
+ minimum_orientation_sources = int(
+ profile.hand.minimum_palm_orientation_sources
+ )
+ unknown_orientation_sources = sorted(
+ {
+ item.source_joint
+ for item in orientation_measurements
+ if item.source_joint not in configured_palm_sources
+ or configured_palm_sources[item.source_joint]
+ != item.model_joint
+ }
+ )
+ if unknown_orientation_sources:
+ raise ValueError(
+ "palm orientation measurements do not match the product "
+ "profile: "
+ + ",".join(unknown_orientation_sources)
+ )
+ required_orientation_cycles = {
+ *(int(value) for value in training_cycles),
+ int(validation_cycle),
+ }
+ for cycle in required_orientation_cycles:
+ cycle_sources = {
+ item.source_joint
+ for item in orientation_measurements
+ if int(item.cycle) == cycle
+ }
+ if len(cycle_sources) < minimum_orientation_sources:
+ raise ValueError(
+ f"palm orientation cycle {cycle + 1} contains "
+ f"{len(cycle_sources)}/{minimum_orientation_sources} "
+ "qualified sources"
+ )
+ elif orientation_measurements:
+ raise ValueError(
+ "palm orientation measurements were supplied without a profile"
+ )
+ orientation_training = tuple(
+ item
+ for item in orientation_measurements
+ if int(item.cycle) in set(int(value) for value in training_cycles)
+ )
+ orientation_validation = tuple(
+ item
+ for item in orientation_measurements
+ if int(item.cycle) == int(validation_cycle)
+ )
if not 0.0 < finger_maximum_offset_rad <= maximum_offset_rad:
raise ValueError("finger maximum offset must be positive and no larger than thumb")
joint_limits = {
@@ -1494,6 +2303,18 @@ def solve_urdf_zero_offsets(
raise ValueError("zero significance thresholds must be non-negative")
if maximum_axis_cone_mismatch_rad <= 0.0:
raise ValueError("maximum axis cone mismatch must be positive")
+ if (
+ maximum_systematic_axis_cone_bias_rad is not None
+ and (
+ maximum_systematic_axis_cone_bias_rad
+ <= maximum_axis_cone_mismatch_rad
+ or maximum_systematic_axis_cone_bias_rad > math.radians(90.0)
+ )
+ ):
+ raise ValueError(
+ "maximum systematic axis cone bias must exceed the precision "
+ "limit and be at most 90deg"
+ )
if maximum_pose_axis_line_rms_m <= 0.0:
raise ValueError("maximum pose axis-line RMS must be positive")
if maximum_observability_condition_number <= 1.0:
@@ -1530,6 +2351,23 @@ def solve_urdf_zero_offsets(
zero_offsets=offsets,
joint_angles=angles,
)
+
+ def predicted_palm_orientation_local(
+ measurement: PalmOrientationMeasurement,
+ offsets: Mapping[str, float],
+ ) -> np.ndarray:
+ angles = _angles_from_state(
+ measurement.condition_state_u8,
+ curves=curves,
+ motor_by_joint=motor_by_joint,
+ inherited_zero_joints=profile.inherited_zero_joints,
+ )
+ axis, _ = model.axis_line(
+ measurement.model_joint,
+ zero_offsets=offsets,
+ joint_angles=angles,
+ )
+ return axis / np.linalg.norm(axis)
fixed_offsets = {
str(name): float(value)
for name, value in (
@@ -1600,30 +2438,7 @@ def solve_urdf_zero_offsets(
raise ValueError("root axis average is degenerate")
return result / norm
- # The two CAD root axes are parallel, so their directions alone cannot
- # resolve palm rotation about that common direction. Do not use the
- # 3-D separation of two monocularly fitted axis lines for this purpose:
- # a fixed optical-depth error rotates the fitted palm frame and appears
- # as an equally stable thumb-roll zero bias. Four-finger static splay
- # is deliberately fixed to the source-CAD straight pose, so the
- # measured reference-finger MCP-pitch direction is the independent,
- # non-parallel orientation anchor. Root lines remain translation-only
- # anchors.
root_names = tuple(sorted(profile.root_anchor_joints))
- primary = max(
- root_names,
- key=lambda name: float(np.ptp(np.asarray(curves[name].angle_rad))),
- )
- secondary = next(name for name in root_names if name != primary)
- orientation_anchor = f"{profile.reference_finger}_mcp_pitch"
- orientation_items = [
- item for item in selected if item.joint == orientation_anchor
- ]
- if not orientation_items:
- raise ValueError(
- f"zero solve is missing palm orientation anchor {orientation_anchor}"
- )
-
predicted_axes: dict[str, np.ndarray] = {}
predicted_points: dict[str, np.ndarray] = {}
observed_axes: dict[str, np.ndarray] = {}
@@ -1921,6 +2736,24 @@ def solve_urdf_zero_offsets(
)
return rotation, translation
+ # Legacy layouts have only one observed finger chain. Their
+ # non-parallel reference-finger pitch axis remains the orientation
+ # anchor; the product profile above deliberately does not use it.
+ primary = max(
+ root_names,
+ key=lambda name: float(
+ np.ptp(np.asarray(curves[name].angle_rad))
+ ),
+ )
+ orientation_anchor = f"{profile.reference_finger}_mcp_pitch"
+ orientation_items = [
+ item for item in selected if item.joint == orientation_anchor
+ ]
+ if not orientation_items:
+ raise ValueError(
+ f"zero solve is missing palm orientation anchor "
+ f"{orientation_anchor}"
+ )
predicted_orientation_axis = undirected_axis_average(
[predicted_local(item, zero_offsets)[0] for item in orientation_items]
)
@@ -2164,6 +2997,11 @@ def solve_urdf_zero_offsets(
).items()
}
+ product_finger_rolls = tuple(
+ name
+ for name in profile.direct_zero_joints
+ if name.endswith("_mcp_roll") and not name.startswith("thumb_")
+ )
offset_limits = np.asarray(
[
joint_limits.get(
@@ -2187,6 +3025,21 @@ def solve_urdf_zero_offsets(
math.radians(90.0),
np.maximum(3.0 * offset_limits, offset_limits + math.radians(5.0)),
)
+ if profile.hand.layout_id == G20_RIGHT_19_LAYOUT:
+ # The absolute rotation of the fitted palm frame about the four
+ # parallel roll axes is a shared gauge. Each physical finger zero is
+ # only its deviation from the four-finger median, but the raw scalar
+ # solves include that common mode. Give those four diagnostic solves
+ # enough range for the unchanged global common-mode limit plus the
+ # unchanged per-finger deviation limit; applying the per-finger bound
+ # before removing the gauge clips every solve to the same value and
+ # destroys the observable deviations.
+ for index, name in enumerate(profile.direct_zero_joints):
+ if name in product_finger_rolls:
+ diagnostic_offset_limits[index] = min(
+ math.radians(90.0),
+ maximum_offset_rad + offset_limits[index],
+ )
def optimise_offsets(
selected: Sequence[JointAxisMeasurement],
@@ -2360,6 +3213,9 @@ def solve_urdf_zero_offsets(
for index, name in enumerate(profile.direct_zero_joints)
}
+ axis_cone_mismatch_by_joint: dict[str, float] = {}
+ axis_cone_bias_classification_by_joint: dict[str, str] = {}
+
def zero_observation_failure_reasons(
selected: Sequence[JointAxisMeasurement],
offsets: Mapping[str, float],
@@ -2376,7 +3232,7 @@ def solve_urdf_zero_offsets(
]
if observer_joint in profile.axis_parent_joint:
parent_joint = profile.axis_parent_joint[observer_joint]
- maximum_cone_mismatch = 0.0
+ cone_mismatches: list[float] = []
for item in observer_items:
state = (
item.condition_state_u8
@@ -2401,18 +3257,43 @@ def solve_urdf_zero_offsets(
zero_offsets=offsets,
joint_angles=angles,
)
- maximum_cone_mismatch = max(
- maximum_cone_mismatch,
+ cone_mismatches.append(
_axis_cone_mismatch_rad(
rotation.apply(predicted_axis),
item.axis_common_xyz,
rotation.apply(parent_axis),
- ),
+ )
)
+ maximum_cone_mismatch = max(cone_mismatches)
+ axis_cone_mismatch_by_joint[offset_joint] = (
+ maximum_cone_mismatch
+ )
if maximum_cone_mismatch > maximum_axis_cone_mismatch_rad:
- failures[offset_joint] = (
- "zero_axis_cone_mismatch_too_large"
+ cone_range = float(np.ptp(cone_mismatches))
+ stable_product_bias = bool(
+ profile.hand.supports("stable_cross_view_cone_bias")
+ and maximum_systematic_axis_cone_bias_rad is not None
+ and len(cone_mismatches) >= 4
+ and maximum_cone_mismatch
+ <= maximum_systematic_axis_cone_bias_rad
+ and cone_range <= maximum_cycle_difference_rad
)
+ if stable_product_bias:
+ # A parent zero rotates the downstream direction
+ # around the parent axis and cannot change their cone
+ # angle. The zero-sensitive residual above already
+ # projects both directions onto the parent-normal
+ # plane, so a repeatable cross-camera/planar-PnP cone
+ # bias cannot corrupt the written encoder zero. Keep
+ # it visible in the result while retaining the gross
+ # gate for a wrong axis, loose Tag, or moved camera.
+ axis_cone_bias_classification_by_joint[offset_joint] = (
+ "stable_cross_view_or_planar_pnp_bias"
+ )
+ else:
+ failures[offset_joint] = (
+ "zero_axis_cone_mismatch_too_large"
+ )
else:
parent_joint = profile.phase_parent_joint[observer_joint]
phase_items: list[JointAxisMeasurement] = []
@@ -2551,6 +3432,38 @@ def solve_urdf_zero_offsets(
improvement_by_joint: dict[str, float] = {}
improvement_confidence_lower: dict[str, float] = {}
improvement_passed = True
+ palm_orientation_validation_errors = tuple(
+ math.acos(
+ abs(
+ float(
+ np.clip(
+ base_rotation.apply(
+ predicted_palm_orientation_local(
+ item, zero_offsets
+ )
+ )
+ @ np.asarray(item.axis_common_xyz, dtype=float),
+ -1.0,
+ 1.0,
+ )
+ )
+ )
+ )
+ for item in orientation_validation
+ )
+ palm_orientation_validation_limit = (
+ maximum_validation_error_rad
+ if maximum_validation_error_rad is not None
+ else maximum_axis_cone_mismatch_rad
+ )
+ palm_orientation_validation_passed = bool(
+ not configured_palm_sources
+ or (
+ palm_orientation_validation_errors
+ and float(np.median(palm_orientation_validation_errors))
+ <= palm_orientation_validation_limit
+ )
+ )
for offset_joint, observer_joint in profile.offset_observer_joint.items():
if offset_joint in fixed_offsets:
improvement_by_joint[offset_joint] = 0.0
@@ -2664,11 +3577,6 @@ def solve_urdf_zero_offsets(
np.sqrt(np.mean(np.square(all_validation_line_errors)))
)
)
- product_finger_rolls = tuple(
- name
- for name in profile.direct_zero_joints
- if name.endswith("_mcp_roll") and not name.startswith("thumb_")
- )
finger_roll_common_mode = (
float(
np.median(
@@ -2716,6 +3624,10 @@ def solve_urdf_zero_offsets(
if abs(checked_offset) >= limit - math.radians(0.01):
diagnostic_bound_hits.append(name)
failure_reasons: dict[str, str] = {}
+ if not palm_orientation_validation_passed:
+ failure_reasons["palm_orientation"] = (
+ "palm_orientation_holdout_too_large"
+ )
if (
profile.hand.layout_id == G20_RIGHT_19_LAYOUT
and observability_rank < observability_parameter_count
@@ -2788,6 +3700,7 @@ def solve_urdf_zero_offsets(
for reason in failure_reasons.values()
)
and improvement_passed
+ and palm_orientation_validation_passed
)
# Never refit a model that has passed its holdout with the validation
@@ -2848,6 +3761,10 @@ def solve_urdf_zero_offsets(
observability_parameter_count=observability_parameter_count,
observability_condition_number=observability_condition_number,
offset_covariance_rad2=offset_covariance,
+ axis_cone_mismatch_by_joint_rad=axis_cone_mismatch_by_joint,
+ axis_cone_bias_classification_by_joint=(
+ axis_cone_bias_classification_by_joint
+ ),
failure_reasons=failure_reasons,
)
diff --git a/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py b/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py
index 48b5ca1..e0b0661 100644
--- a/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py
+++ b/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py
@@ -39,16 +39,21 @@ def _default_source_urdf(hand_type: str) -> Path:
def _launch_stack(context):
+ from g20_thumb_apriltag_calibration.product import (
+ get_product_calibration_contract,
+ )
+
+ model = LaunchConfiguration("model").perform(context).strip().upper()
hand_type = LaunchConfiguration("hand_type").perform(context).lower()
if hand_type not in {"left", "right"}:
raise RuntimeError("hand_type must be left or right")
tag_layout = LaunchConfiguration("tag_layout").perform(context).lower()
- if tag_layout not in {"legacy_11", "g20_right_15", "g20_right_19"}:
- raise RuntimeError(
- "tag_layout must be legacy_11, g20_right_15 or g20_right_19"
+ try:
+ contract = get_product_calibration_contract(
+ model, hand_type, tag_layout
)
- if tag_layout in {"g20_right_15", "g20_right_19"} and hand_type != "right":
- raise RuntimeError("G20 right product layout requires hand_type:=right")
+ except ValueError as error:
+ raise RuntimeError(str(error)) from error
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
@@ -68,9 +73,10 @@ def _launch_stack(context):
)
if not tag_config.is_file():
raise RuntimeError(f"tag config does not exist: {tag_config}")
- command_topic = f"/g20/cb_{hand_type}_hand_control_cmd"
- state_topic = f"/g20/cb_{hand_type}_hand_state"
- info_topic = f"/g20/cb_{hand_type}_hand_info"
+ topic_prefix = f"/{model.lower()}"
+ command_topic = f"{topic_prefix}/cb_{hand_type}_hand_control_cmd"
+ state_topic = f"{topic_prefix}/cb_{hand_type}_hand_state"
+ info_topic = f"{topic_prefix}/cb_{hand_type}_hand_info"
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
source_urdf = (
Path(requested_source).expanduser().resolve()
@@ -82,7 +88,7 @@ def _launch_stack(context):
expected_source_hash = LaunchConfiguration(
"source_urdf_expected_sha256"
).perform(context).strip().lower()
- if tag_layout in {"g20_right_15", "g20_right_19"}:
+ if contract.profile.supports("urdf_zero_publication"):
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
raise RuntimeError(
"G20 right product layout requires source_urdf_expected_sha256 confirmed "
@@ -239,10 +245,10 @@ def _launch_stack(context):
parameters=[
{
"hand_type": hand_type,
- "hand_joint": "G20",
+ "hand_joint": model,
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
- "topic_prefix": "/g20",
+ "topic_prefix": topic_prefix,
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"), value_type=int
@@ -274,6 +280,7 @@ def _launch_stack(context):
LaunchConfiguration("calibration_config"),
{
"serial_number": hand_serial,
+ "model": model,
"hand_type": hand_type,
"tag_layout": tag_layout,
"session_dir": str(session_dir),
@@ -396,6 +403,7 @@ def generate_launch_description() -> LaunchDescription:
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(package_share / "config" / "fastdds_large_images.xml"),
),
+ DeclareLaunchArgument("model", default_value="G20"),
DeclareLaunchArgument("hand_type", default_value="left"),
DeclareLaunchArgument("tag_layout", default_value="legacy_11"),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
diff --git a/src/g20_thumb_apriltag_calibration/setup.py b/src/g20_thumb_apriltag_calibration/setup.py
index ca6c069..c5a1fb1 100644
--- a/src/g20_thumb_apriltag_calibration/setup.py
+++ b/src/g20_thumb_apriltag_calibration/setup.py
@@ -26,7 +26,7 @@ setup(
zip_safe=True,
maintainer="lxp",
maintainer_email="support@linker-robotics.com",
- description="One-command three-camera AprilTag calibration for the G20 right hand",
+ description="Profile-driven three-camera AprilTag hand calibration",
license="MIT",
entry_points={
"console_scripts": [
@@ -68,6 +68,10 @@ setup(
"calibrate_g20_right = "
"g20_thumb_apriltag_calibration.one_command:main"
),
+ (
+ "calibrate_hand = "
+ "g20_thumb_apriltag_calibration.one_command:main"
+ ),
],
},
)
diff --git a/src/g20_thumb_apriltag_calibration/test/test_config.py b/src/g20_thumb_apriltag_calibration/test/test_config.py
index 1d28d68..f4fcd9a 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_config.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_config.py
@@ -206,7 +206,7 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
assert parameters["zero_maximum_observability_condition_number"] >= 1.0
assert parameters["zero_maximum_offset_deg"] <= 20.0
assert parameters["zero_finger_maximum_offset_deg"] <= 3.0
- assert parameters["flexion_endpoint_maximum_offset_deg"] == 5.0
+ assert parameters["mechanical_endpoint_maximum_offset_deg"] == 5.0
assert parameters["image_trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["image_trajectory_maximum_radial_p95_px"] <= 3.5
assert parameters["image_trajectory_minimum_radius_px"] >= 20.0
diff --git a/src/g20_thumb_apriltag_calibration/test/test_full_hand.py b/src/g20_thumb_apriltag_calibration/test/test_full_hand.py
index 6762364..e06b927 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_full_hand.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_full_hand.py
@@ -28,6 +28,7 @@ from g20_thumb_apriltag_calibration.full_hand import (
clamp_runtime_fits_to_urdf_limits,
center_splay_curve,
compare_cross_view_roll_curves,
+ cross_view_roll_diagnostic_metrics,
derive_mimic_passive_fits,
fit_joint_center_curve,
fit_joint_image_curve,
@@ -98,6 +99,8 @@ def test_right_19_profile_has_exact_layout_tasks_and_measured_dips() -> None:
"pinky_mcp_roll",
"pinky_mcp_roll_side",
)
+ assert profile.palm_axis_observers == ()
+ assert profile.minimum_palm_orientation_sources == 0
assert profile.sweep_specs[6].joints == ("pinky_pip", "pinky_dip")
assert {
name: spec.source_joint
@@ -411,6 +414,98 @@ def test_cross_view_roll_curve_must_agree_before_fusion() -> None:
compare_cross_view_roll_curves(fit, reverse)
+def test_cross_view_roll_curve_allows_bounded_projection_scale() -> None:
+ values = tuple(0.5 * (127 - command) / 127 for command in range(256))
+ primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {})
+ projected = tuple(0.8 * value for value in values)
+ secondary = replace(
+ primary,
+ angle_rad=projected,
+ decreasing_rad=projected,
+ increasing_rad=projected,
+ )
+
+ with pytest.raises(ValueError, match="cross_view_roll_curve"):
+ compare_cross_view_roll_curves(primary, secondary)
+
+ metrics = compare_cross_view_roll_curves(
+ primary,
+ secondary,
+ allow_projection_scale=True,
+ )
+
+ assert metrics["projection_scale"] == pytest.approx(1.25)
+ assert metrics["angle_rad_rms_difference_rad"] == pytest.approx(0.0)
+ assert metrics["raw_angle_rad_rms_difference_rad"] > 0.0
+
+
+def test_projected_cross_view_keeps_branch_gap_as_diagnostic() -> None:
+ values = tuple(0.5 * (127 - command) / 127 for command in range(256))
+ primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {})
+ shifted_increasing = tuple(
+ value + math.radians(0.5) for value in values
+ )
+ secondary = replace(primary, increasing_rad=shifted_increasing)
+
+ metrics = compare_cross_view_roll_curves(
+ primary,
+ secondary,
+ allow_projection_scale=True,
+ )
+
+ assert metrics["baseline_branch_gap_difference_rad"] > math.radians(0.3)
+ assert metrics["baseline_branch_gap_gate_applied"] == 0.0
+
+
+def test_cross_view_roll_curve_rejects_implausible_projection_scale() -> None:
+ values = tuple(0.5 * (127 - command) / 127 for command in range(256))
+ primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {})
+ collapsed = tuple(0.2 * value for value in values)
+ secondary = replace(
+ primary,
+ angle_rad=collapsed,
+ decreasing_rad=collapsed,
+ increasing_rad=collapsed,
+ )
+
+ with pytest.raises(ValueError, match="projection_scale_ratio"):
+ compare_cross_view_roll_curves(
+ primary,
+ secondary,
+ allow_projection_scale=True,
+ )
+
+
+def test_validation_only_cross_view_metrics_never_create_a_retry_gate() -> None:
+ values = tuple(0.5 * (127 - command) / 127 for command in range(256))
+ primary = JointCurveFit(values, values, values, {}, 0.0, 0.0, {})
+ biased = tuple(
+ value
+ + math.radians(2.5) * math.sin(math.pi * command / 255.0)
+ for command, value in enumerate(values)
+ )
+ secondary = replace(
+ primary,
+ angle_rad=biased,
+ decreasing_rad=biased,
+ increasing_rad=biased,
+ )
+
+ metrics = cross_view_roll_diagnostic_metrics(primary, secondary)
+
+ assert metrics["angle_rad_rms_difference_rad"] > math.radians(1.0)
+
+ reversed_secondary = replace(
+ secondary,
+ angle_rad=tuple(-value for value in secondary.angle_rad),
+ decreasing_rad=tuple(-value for value in secondary.decreasing_rad),
+ increasing_rad=tuple(-value for value in secondary.increasing_rad),
+ )
+ assert cross_view_roll_diagnostic_metrics(
+ primary, reversed_secondary
+ ) == {"direction_disagrees": 1.0}
+
+
def _records() -> list[dict[str, object]]:
commands = list(range(0, 256, 16))
if commands[-1] != 255:
diff --git a/src/g20_thumb_apriltag_calibration/test/test_g20_right_product.py b/src/g20_thumb_apriltag_calibration/test/test_g20_right_product.py
index 88fc414..e1fe3dc 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_g20_right_product.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_g20_right_product.py
@@ -4,6 +4,7 @@ import json
import math
from pathlib import Path
from types import SimpleNamespace
+from xml.etree import ElementTree as ET
import numpy as np
import pytest
@@ -39,6 +40,7 @@ from g20_thumb_apriltag_calibration.publication import (
atomic_session_pointer,
build_mujoco_validation_commands,
finalize_session_artifacts,
+ session_artifact_paths,
validate_runtime_curves_against_urdf_limits,
verify_corrected_urdf,
verify_urdf_mesh_resources,
@@ -52,6 +54,8 @@ from g20_thumb_apriltag_calibration.three_camera_node import (
_combination_motor_directions,
_combination_validation_items,
_model_link_in_observer_base,
+ _palm_axis_observer_schema,
+ _palm_axis_resume_policy,
_steady_checkpoint_commands,
_unresolved_fit_failure_tasks,
combination_target_coverage,
@@ -59,7 +63,7 @@ from g20_thumb_apriltag_calibration.three_camera_node import (
resumable_completed_task_prefix,
)
from g20_thumb_apriltag_calibration.urdf_zero import (
- RIGHT_19_FLEXION_ENDPOINT_JOINTS,
+ RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
UrdfKinematicModel,
get_zero_calibration_profile,
write_zero_corrected_urdf,
@@ -79,8 +83,15 @@ def _config(tmp_path: Path, *, passes: int = 2):
)
-def _payload(serial: str = "G20_RIGHT_001") -> dict:
+def _payload(
+ serial: str = "G20_RIGHT_001",
+ *,
+ zero_offsets: dict[str, float] | None = None,
+) -> dict:
profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
+ offsets = zero_offsets or {
+ name: 0.01 for name in profile.active_joints
+ }
baseline = [255] * 20
baseline[6:10] = [127] * 4
fits = {}
@@ -100,7 +111,7 @@ def _payload(serial: str = "G20_RIGHT_001") -> dict:
return build_compact_payload(
serial_number=serial,
measured_fits=fits,
- urdf_zero_offsets_rad={name: 0.01 for name in profile.active_joints},
+ urdf_zero_offsets_rad=offsets,
validation_errors_rad=[0.0, math.radians(0.5)],
passed=True,
baseline=baseline,
@@ -109,10 +120,18 @@ def _payload(serial: str = "G20_RIGHT_001") -> dict:
)
-def _make_passed_session(config, stamp: str) -> Path:
+def _make_passed_session(
+ config,
+ stamp: str,
+ *,
+ zero_offsets: dict[str, float] | None = None,
+) -> Path:
session = config.session_root / stamp
session.mkdir(parents=True)
- payload = _payload(config.serial_number)
+ offsets = zero_offsets or {
+ name: 0.01 for name in ACTIVE_ZERO_JOINTS
+ }
+ payload = _payload(config.serial_number, zero_offsets=offsets)
atomic_write_json(
session / f"g20_right_{config.serial_number}_calibration.json", payload
)
@@ -120,9 +139,10 @@ def _make_passed_session(config, stamp: str) -> Path:
source_urdf=config.source_urdf,
output_directory=session,
serial_number=config.serial_number,
- offsets_rad={name: 0.01 for name in ACTIVE_ZERO_JOINTS},
+ offsets_rad=offsets,
endpoint_anchored_offsets_rad={
- name: 0.01 for name in RIGHT_19_FLEXION_ENDPOINT_JOINTS
+ name: offsets[name]
+ for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
},
timestamp=stamp,
)
@@ -156,6 +176,18 @@ def _passed_node_status() -> dict:
def test_product_config_locks_three_cameras_tags_and_artifact_hashes() -> None:
config = load_product_config(PRODUCT, workspace=REPO, check_can=False)
+ assert (config.model, config.side, config.tag_layout) == (
+ "G20",
+ "right",
+ G20_RIGHT_19_LAYOUT,
+ )
+ assert config.calibration_contract.profile.command_count == 20
+ assert config.calibration_contract.profile.supports(
+ "urdf_zero_publication"
+ )
+ assert config.calibration_contract.profile.supports(
+ "stable_cross_view_cone_bias"
+ )
assert config.serial_number == "G20_RIGHT_001"
assert config.required_independent_passes == 1
assert set(config.cameras) == {"front", "side", "top"}
@@ -266,6 +298,71 @@ def test_resume_reuses_only_a_fully_committed_task_prefix() -> None:
assert {row["task_name"] for row in reusable} == {first.key}
+def test_resume_preserves_palm_axis_side_channel_without_requiring_it() -> None:
+ profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
+ spec = next(
+ item for item in profile.sweep_specs if item.key == "pinky_pitch_side"
+ )
+ rows = _complete_resume_task_rows(profile, spec)
+ rows.append(
+ {
+ "kind": "palm_axis_sample",
+ "attempt": 1,
+ "task_name": spec.key,
+ "source_joint": "pinky_mcp_pitch_front_axis",
+ "cycle": 0,
+ "direction": "decreasing",
+ "feedback_u8": 240,
+ }
+ )
+
+ completed, reusable = resumable_completed_task_prefix(
+ profile,
+ 4,
+ [255] * 20,
+ rows,
+ allow_sparse=True,
+ )
+
+ assert completed == (spec.key,)
+ assert sum(
+ row.get("kind") == "palm_axis_sample" for row in reusable
+ ) == 1
+
+ completed_without_side_channel, _ = resumable_completed_task_prefix(
+ profile,
+ 4,
+ [255] * 20,
+ _complete_resume_task_rows(profile, spec),
+ allow_sparse=True,
+ )
+ assert completed_without_side_channel == (spec.key,)
+
+
+def test_checkpoint_needs_no_palm_axis_reacquisition_when_disabled() -> None:
+ profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
+ old_capabilities = set(profile.capabilities) - {
+ "palm_axis_side_channel_v1"
+ }
+
+ compatible, invalidated = _palm_axis_resume_policy(
+ profile, {"capabilities": sorted(old_capabilities)}
+ )
+
+ assert compatible is True
+ assert invalidated == ()
+
+ compatible, invalidated = _palm_axis_resume_policy(
+ profile,
+ {
+ "capabilities": sorted(profile.capabilities),
+ "palm_axis_observers": _palm_axis_observer_schema(profile),
+ },
+ )
+ assert compatible is True
+ assert invalidated == ()
+
+
def test_sparse_resume_keeps_complete_tasks_after_failed_task() -> None:
profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
first, failed, later = profile.sweep_specs[:3]
@@ -590,6 +687,41 @@ def test_resume_revalidates_retired_passive_dip_position_metrics() -> None:
assert _unresolved_fit_failure_tasks(profile, rows) == set()
+def test_resume_retires_validation_only_cross_view_curve_failure() -> None:
+ profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
+ spec = next(
+ item for item in profile.sweep_specs
+ if item.key == "ring_roll_multiview"
+ )
+ rows = [
+ {
+ "kind": "sample",
+ "task_name": spec.key,
+ "attempt": 3,
+ },
+ {
+ "kind": "fit_failure",
+ "task_name": spec.key,
+ "view": spec.view,
+ "motor_index": spec.motor_index,
+ "joints": list(spec.joints),
+ "attempt": 3,
+ "failures": [
+ {
+ "joint": "ring_mcp_roll",
+ "metric": "cross_view_roll_curve",
+ "reason": (
+ "cross_view_roll_curve_difference_too_large:"
+ "angle_rad:1.297957deg"
+ ),
+ }
+ ],
+ },
+ ]
+
+ assert _unresolved_fit_failure_tasks(profile, rows) == set()
+
+
def test_automatic_resume_requires_failed_matching_geometry(tmp_path: Path) -> None:
config = _config(tmp_path)
session = config.session_root / "20260819_150000"
@@ -718,6 +850,9 @@ def test_node_restores_complete_prefix_into_new_self_contained_raw(
},
*_complete_resume_task_rows(profile, profile.sweep_specs[0]),
]
+ for row in rows:
+ if row.get("task_name") == profile.sweep_specs[0].key:
+ row["attempt"] = 2
source_raw.write_text(
"".join(json.dumps(row) + "\n" for row in rows)
)
@@ -760,10 +895,14 @@ def test_node_restores_complete_prefix_into_new_self_contained_raw(
assert count == 1
assert node.sweep_index == 10
assert node.resumed_task_keys == (profile.sweep_specs[0].key,)
+ assert node.sweep_attempts[profile.sweep_specs[0].key] == 2
restored = [
json.loads(line) for line in current_raw.read_text().splitlines()
]
assert restored[0]["kind"] == "resume_checkpoint_import"
+ assert restored[0]["imported_attempt_floor_by_task"] == {
+ profile.sweep_specs[0].key: 2
+ }
assert any(row["kind"] == "sample" for row in restored)
@@ -839,10 +978,89 @@ def test_publication_numerically_binds_json_offsets_and_urdf_limits(
payload = _payload(config.serial_number)
validate_runtime_curves_against_urdf_limits(payload, config.source_urdf)
payload["joints"]["thumb_mcp"]["angle_rad"][0] = 2.0
- with pytest.raises(ValueError, match="exceeds source URDF limit"):
+ with pytest.raises(ValueError, match="exceeds runtime URDF limit"):
validate_runtime_curves_against_urdf_limits(payload, config.source_urdf)
+def test_publication_uses_corrected_coordinates_for_negative_endpoint_zero(
+ tmp_path: Path,
+) -> None:
+ config = _config(tmp_path, passes=1)
+ offsets = {name: 0.01 for name in ACTIVE_ZERO_JOINTS}
+ endpoint_offset = -0.00801549
+ offsets["index_mcp_pitch"] = endpoint_offset
+ session = _make_passed_session(
+ config,
+ "20260826_102518",
+ zero_offsets=offsets,
+ )
+ paths = session_artifact_paths(session, config.serial_number)
+ payload = json.loads(paths["json"].read_text(encoding="utf-8"))
+ curve_maximum = 1.22801310
+ payload["joints"]["index_mcp_pitch"]["angle_rad"][0] = curve_maximum
+ atomic_write_json(paths["json"], payload)
+
+ summary, release_ready = finalize_session_artifacts(
+ config,
+ session,
+ node_status=_passed_node_status(),
+ )
+
+ def upper_limit(path: Path) -> float:
+ joint = next(
+ node
+ for node in ET.parse(path).getroot().findall("joint")
+ if node.get("name") == "index_mcp_pitch"
+ )
+ return float(joint.find("limit").get("upper"))
+
+ source_upper = upper_limit(config.source_urdf)
+ corrected_upper = upper_limit(paths["urdf"])
+ assert release_ready is True
+ assert summary["result"] == "PASS"
+ assert corrected_upper == pytest.approx(source_upper - endpoint_offset)
+ assert curve_maximum <= corrected_upper
+ assert curve_maximum + endpoint_offset <= source_upper
+
+
+def test_publication_propagates_schema_v4_rounding_to_mimic_offset(
+ tmp_path: Path,
+) -> None:
+ config = _config(tmp_path, passes=1)
+ offsets = {name: 0.01 for name in ACTIVE_ZERO_JOINTS}
+ full_precision_offset = -0.06200179363832
+ offsets["middle_pip"] = full_precision_offset
+ session = _make_passed_session(
+ config,
+ "20260827_152432",
+ zero_offsets=offsets,
+ )
+ paths = session_artifact_paths(session, config.serial_number)
+ payload = json.loads(paths["json"].read_text(encoding="utf-8"))
+ assert payload["joints"]["middle_pip"]["zero_angles"][
+ "urdf_zero_offset_rad"
+ ] == -0.06200179
+
+ summary, release_ready = finalize_session_artifacts(
+ config,
+ session,
+ node_status=_passed_node_status(),
+ )
+
+ corrected = ET.parse(paths["urdf"]).getroot()
+ middle_dip = next(
+ node
+ for node in corrected.findall("joint")
+ if node.get("name") == "middle_dip"
+ )
+ assert float(middle_dip.find("mimic").get("offset")) == pytest.approx(
+ 0.89 * full_precision_offset,
+ abs=1.0e-14,
+ )
+ assert release_ready is True
+ assert summary["result"] == "PASS"
+
+
def test_publication_rejects_non_joint_urdf_changes(tmp_path: Path) -> None:
config = _config(tmp_path, passes=1)
session = _make_passed_session(config, "20260819_112000")
@@ -907,7 +1125,7 @@ def test_publication_accepts_half_lsb_schema_zero_quantisation(
assert release_ready is True
-def test_publication_saturates_legacy_runtime_curve_before_release(
+def test_publication_keeps_cmc_runtime_limit_independent_of_certified_zero(
tmp_path: Path,
) -> None:
config = _config(tmp_path, passes=1)
@@ -923,6 +1141,9 @@ def test_publication_saturates_legacy_runtime_curve_before_release(
published = json.loads(json_path.read_text(encoding="utf-8"))
assert release_ready is True
+ # CMC has a certified origin, not an assertion that its electrical
+ # endpoint equals the source-CAD upper coordinate. Its runtime coordinate
+ # limit therefore remains the protected source value.
assert published["joints"]["thumb_cmc_yaw"]["angle_rad"][0] == 1.57
assert summary["runtime_limit_clipped_bins"]["thumb_cmc_yaw"] == 1
@@ -977,7 +1198,19 @@ def test_synchronised_timeout_with_visible_tags_reports_pnp_geometry(tmp_path: P
"front": {
"ready": True,
"missing_tag_ids": [],
- "group_pnp_reason": "group_coupled_rotation",
+ "group_pnp_reason": "group_missing_pose_candidates",
+ "group_missing_candidate_roles": ["thumb_ip"],
+ "pnp_rejection_counts": {
+ "thumb_ip:no_pose_within_reprojection_or_tilt_limit": 12,
+ },
+ "pnp_candidate_diagnostics": {
+ "thumb_ip": {
+ "tag_id": 3,
+ "solved_candidate_count": 2,
+ "reprojection_candidate_count": 0,
+ "independent_tilt_candidate_count": 0,
+ }
+ },
},
"side": {"ready": True, "missing_tag_ids": []},
"top": {"ready": True, "missing_tag_ids": []},
@@ -989,7 +1222,12 @@ def test_synchronised_timeout_with_visible_tags_reports_pnp_geometry(tmp_path: P
)
assert payload["error_code"] == "CAM-GEOMETRY-201"
+ assert "轨迹在运动中失效" in payload["problem_zh"]
assert "整组PnP几何检查拒绝" in payload["explanation_zh"]
+ assert "缺候选=thumb_ip" in payload["explanation_zh"]
+ assert payload["pnp_diagnostics"]["front"][
+ "group_missing_candidate_roles"
+ ] == ["thumb_ip"]
assert "不要调整" in payload["automatic_action_zh"]
@@ -1007,7 +1245,14 @@ def test_sweep_start_timeout_with_visible_tags_reports_pnp_geometry(tmp_path: Pa
"side": {
"ready": True,
"missing_tag_ids": [],
- "group_pnp_reason": "group_normal_alignment",
+ "group_pnp_reason": "group_initializing:7/8",
+ "pnp_initialization_progress": {
+ "accepted": 7,
+ "required": 8,
+ },
+ "group_pnp_rejection_counts": {
+ "group_normal_alignment": 5,
+ },
},
"top": {"ready": True, "missing_tag_ids": []},
},
@@ -1019,6 +1264,8 @@ def test_sweep_start_timeout_with_visible_tags_reports_pnp_geometry(tmp_path: Pa
assert payload["error_code"] == "CAM-GEOMETRY-201"
assert "PnP" in payload["problem_zh"]
+ assert "初始化7/8" in payload["explanation_zh"]
+ assert "group_normal_alignment×5" in payload["explanation_zh"]
def test_multiview_sample_failure_has_stable_observation_code() -> None:
@@ -1030,6 +1277,33 @@ def test_multiview_sample_failure_has_stable_observation_code() -> None:
assert "轨迹不完整" in problem
+def test_palm_orientation_coverage_has_validation_error_code() -> None:
+ code, problem, suggestion = classify_error(
+ "palm_orientation_quality_failed", {}
+ )
+
+ assert code == "VAL-QUALITY-501"
+ assert "方向校正" in problem
+ assert "至少三枚" in suggestion
+
+
+@pytest.mark.parametrize(
+ "reason",
+ (
+ "name 'endpoint_zero_offsets' is not defined",
+ "validated_endpoint_zero_state_incomplete:missing=index_pip;extra=-",
+ ),
+)
+def test_endpoint_zero_lifecycle_failure_is_a_publication_error(
+ reason: str,
+) -> None:
+ code, problem, suggestion = classify_error(reason, {})
+
+ assert code == "PUB-ARTIFACT-601"
+ assert "发布阶段" in problem
+ assert "不要移动相机或Tag" in suggestion
+
+
def test_fitting_uses_long_watchdog_without_weakening_motion_timeout() -> None:
assert _status_timeout_seconds({"state": "FITTING"}) == 600.0
assert _status_timeout_seconds({"state": "SWEEP"}) == 90.0
@@ -1114,8 +1388,9 @@ def test_progress_contains_stage_eta_tags_cameras_and_feedback() -> None:
"actual_u8": 116.5,
"valid_frames": 80,
"automatic_retry_count": 1,
- "fit_attempt": 3,
- "fit_attempt_limit": 3,
+ "fit_attempt": 3,
+ "fit_attempt_limit": 3,
+ "fit_retry_cycles": [2],
},
"views": {
view: {
@@ -1136,7 +1411,7 @@ def test_progress_contains_stage_eta_tags_cameras_and_feedback() -> None:
assert "反馈:98.0 Hz" in text
assert "当前任务ID:正面[0✓] 侧面[0✓] 顶部[0✓]" in text
assert "断点:已恢复 3/16 个完整任务" in text
- assert "拟合:整关节第 3/3 次尝试" in text
+ assert "首次拟合定位第 2 轮异常,仅补采该轮双向" in text
def test_preflight_progress_marks_non_base_tag_occlusion_as_allowed() -> None:
@@ -1218,7 +1493,7 @@ def test_sweep_progress_lists_visible_and_missing_task_tag_ids() -> None:
"G20_RIGHT_001", status, ProgressEstimator(started_at=0.0)
)
- assert "Tag:4/5 有效" in text
+ assert "Tag:4/5 可见" in text
assert "正面[0✓]" in text
assert "侧面[4✓,5✗,14✓]" in text
assert "顶部[8✓]" in text
@@ -1254,6 +1529,14 @@ def test_multiview_progress_marks_occluded_front_base_as_locked() -> None:
"required_tag_ids": [4, 15],
"locked_reference_tag_ids": [],
"detected_tag_ids": [4, 15],
+ "pnp_pose_valid": False,
+ "pnp_initialization_progress": {
+ "accepted": 5,
+ "required": 8,
+ },
+ "pnp_rejection_counts": {
+ "middle_pip:no_pose_within_reprojection_or_tilt_limit": 7,
+ },
},
"top": {
"camera_info_valid": True,
@@ -1271,9 +1554,11 @@ def test_multiview_progress_marks_occluded_front_base_as_locked() -> None:
assert "已到扫描起点,等待任务Tag" in text
assert "命令/反馈:255/254.0" in text
- assert "Tag:5/5 有效(含锁定 1)" in text
+ assert "Tag:5/5 可见(含锁定 1)" in text
assert "正面[0锁,12✓]" in text
assert "预计剩余 等待Tag" in text
+ assert "PnP:侧面 初始化5/8" in text
+ assert "Tag可见不等于三维位姿有效" in text
def test_device_preflight_defers_tag_gate_until_after_baseline_recovery() -> None:
diff --git a/src/g20_thumb_apriltag_calibration/test/test_offline_replay.py b/src/g20_thumb_apriltag_calibration/test/test_offline_replay.py
index 6173ee2..5837d58 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_offline_replay.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_offline_replay.py
@@ -2,6 +2,7 @@ import pytest
from g20_thumb_apriltag_calibration.offline_replay import (
_latest_attempt_records,
+ _latest_palm_axis_records,
_output_suffix,
)
@@ -36,6 +37,30 @@ def test_latest_attempt_is_selected_per_joint_cycle_and_direction() -> None:
] == [1]
+def test_latest_palm_axis_attempt_is_selected_per_cycle_and_direction() -> None:
+ def sample(cycle: int, direction: str, attempt: int) -> dict:
+ return {
+ "kind": "palm_axis_sample",
+ "source_joint": "pinky_mcp_pitch_front_axis",
+ "cycle": cycle,
+ "direction": direction,
+ "attempt": attempt,
+ }
+
+ selected = _latest_palm_axis_records(
+ [
+ sample(0, "decreasing", 1),
+ sample(0, "decreasing", 2),
+ sample(0, "increasing", 1),
+ ]
+ )
+
+ assert [
+ record["attempt"]
+ for record in selected["pinky_mcp_pitch_front_axis"]
+ ] == [2, 1]
+
+
def test_output_suffix_is_safe_and_explicit() -> None:
assert _output_suffix(None) == ""
assert _output_suffix("MEASURED_ZERO_V2") == "_MEASURED_ZERO_V2"
diff --git a/src/g20_thumb_apriltag_calibration/test/test_pnp.py b/src/g20_thumb_apriltag_calibration/test/test_pnp.py
index 050b3d2..2f358d4 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_pnp.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_pnp.py
@@ -234,6 +234,83 @@ def _pose(
)
+def test_group_tracker_receives_oblique_reprojection_valid_candidates() -> None:
+ per_tag = SquareTagPoseTracker(
+ maximum_reprojection_error_px=1.5,
+ reprojection_tie_px=1.5,
+ maximum_pose_jump_rad=np.deg2rad(35.0),
+ maximum_translation_jump_m=0.04,
+ maximum_tag_tilt_rad=np.deg2rad(75.0),
+ reset_after_seconds=5.0,
+ )
+ oblique_rotation = Rotation.from_euler("y", 89.0, degrees=True)
+
+ independent, reason = per_tag.estimate(
+ "child",
+ _project(oblique_rotation, np.asarray([0.03, 0.0, 0.25])),
+ tag_size_m=0.01,
+ camera_matrix=_camera_matrix(),
+ stamp_ns=1_000_000_000,
+ )
+
+ assert independent is None
+ assert reason == "no_pose_within_reprojection_or_tilt_limit"
+ assert per_tag.last_candidates_by_role["child"]
+ diagnostics = per_tag.last_candidate_diagnostics_by_role["child"]
+ assert diagnostics["reprojection_candidate_count"] == 2
+ assert diagnostics["independent_tilt_candidate_count"] == 0
+ assert diagnostics["minimum_candidate_tilt_deg"] > 75.0
+
+ group = SquareTagGroupPoseTracker(
+ roles=("base", "child"),
+ adjacent_pairs=(("base", "child"),),
+ maximum_pose_jump_rad=np.deg2rad(35.0),
+ maximum_translation_jump_m=0.04,
+ relative_rotation_scale_rad=np.deg2rad(5.0),
+ relative_translation_scale_m=0.01,
+ reprojection_scale_px=0.1,
+ reprojection_weight=0.05,
+ reset_after_seconds=5.0,
+ )
+ selected, group_reason = group.select(
+ {
+ "base": (_pose(0.0, 0.0, 0.05),),
+ "child": per_tag.last_candidates_by_role["child"],
+ },
+ stamp_ns=1_000_000_000,
+ )
+
+ assert group_reason == ""
+ assert selected is not None
+ assert "child" in selected
+
+
+def test_group_tracker_exposes_roles_without_candidates() -> None:
+ tracker = SquareTagGroupPoseTracker(
+ roles=("base", "parent", "child"),
+ adjacent_pairs=(("base", "parent"), ("parent", "child")),
+ maximum_pose_jump_rad=np.deg2rad(35.0),
+ maximum_translation_jump_m=0.04,
+ relative_rotation_scale_rad=np.deg2rad(5.0),
+ relative_translation_scale_m=0.01,
+ reprojection_scale_px=0.1,
+ reprojection_weight=0.05,
+ reset_after_seconds=5.0,
+ )
+
+ selected, reason = tracker.select(
+ {
+ "base": (_pose(0.0, 0.0, 0.05),),
+ "parent": (),
+ },
+ stamp_ns=1_000_000_000,
+ )
+
+ assert selected is None
+ assert reason == "group_missing_pose_candidates"
+ assert tracker.last_missing_roles == ("parent", "child")
+
+
def test_group_tracker_uses_coupling_to_choose_branch_but_never_rejects_measurement() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("base", "mcp", "ip"),
diff --git a/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py b/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py
index 9124739..c17a504 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py
@@ -74,6 +74,47 @@ def test_missing_endpoint_has_specific_chinese_reason_and_recovery() -> None:
assert "/g20_calibration/resume" in text
+def test_endpoint_zero_lifecycle_failure_is_not_reported_as_quality() -> None:
+ payload = {
+ "state": "PAUSED",
+ "reason": (
+ "validated_endpoint_zero_state_incomplete:"
+ "missing=index_pip;extra=-"
+ ),
+ "active": {},
+ "progress": 1.0,
+ "completed_sweeps": 16,
+ "total_sweeps": 16,
+ "views": {},
+ }
+
+ text = render_three_camera_status_text_zh(payload)
+
+ assert "程序内部状态生命周期错误" in text
+ assert "不要移动相机、Tag或机械手底座" in text
+
+
+def test_artifact_failure_explains_that_collection_need_not_restart() -> None:
+ payload = {
+ "state": "PAUSED",
+ "reason": (
+ "PUB-ARTIFACT-601:runtime curve exceeds runtime URDF limit "
+ "for index_mcp_pitch"
+ ),
+ "active": {},
+ "progress": 1.0,
+ "completed_sweeps": 16,
+ "total_sweeps": 16,
+ "views": {},
+ }
+
+ text = render_three_camera_status_text_zh(payload)
+
+ assert "正式发布前" in text
+ assert "原始URDF未被覆盖" in text
+ assert "不要重新标定相机或调整Tag" in text
+
+
def test_combination_prediction_failure_is_not_reported_as_unclassified() -> None:
explanation, action = three_camera_reason_zh(
"PAUSED", "combination_pose_prediction_failed", {}
@@ -91,14 +132,58 @@ def test_sweep_start_visible_tags_reports_group_pnp_rejection() -> None:
"sweep_start_tag_timeout",
{
"group_pnp_reasons": {
- "side": "group_normal_alignment",
- }
+ "side": "group_initializing:5/8",
+ },
+ "pnp_initialization_progress": {
+ "side": {"accepted": 5, "required": 8},
+ },
+ "pnp_rejection_counts": {
+ "side": {
+ "ring_pip:no_pose_within_reprojection_or_tilt_limit": 12,
+ },
+ },
},
)
assert "所需Tag也可见" in explanation
- assert "group_normal_alignment" in explanation
- assert "不要重新粘贴" in action
+ assert "初始化5/8" in explanation
+ assert "no_pose_within_reprojection_or_tilt_limit×12" in explanation
+ assert "不要根据可见性重复粘贴" in action
+
+
+def test_mid_sweep_pnp_failure_names_missing_candidate_role() -> None:
+ explanation, action = three_camera_reason_zh(
+ "PAUSED",
+ "synchronised_tag_state_timeout:side",
+ {
+ "valid_frames": 150,
+ "group_pnp_reasons": {
+ "side": "group_missing_pose_candidates",
+ },
+ "group_missing_candidate_roles": {
+ "side": ["index_dip"],
+ },
+ "pnp_rejection_counts": {
+ "side": {
+ "index_dip:no_pose_within_reprojection_or_tilt_limit": 90,
+ },
+ },
+ "pnp_candidate_diagnostics": {
+ "side": {
+ "index_dip": {
+ "solved_candidate_count": 2,
+ "reprojection_candidate_count": 0,
+ "independent_tilt_candidate_count": 0,
+ },
+ },
+ },
+ },
+ )
+
+ assert "已经取得部分有效轨迹" in explanation
+ assert "缺候选=index_dip" in explanation
+ assert "solve=2,reproj=0,tilt=0" in explanation
+ assert "group_pnp_candidate_event" in action
def test_multiview_failure_names_the_camera_specific_joint() -> None:
@@ -183,6 +268,60 @@ def test_joint_fit_failure_names_metric_and_selective_retry() -> None:
assert "运动采样:" not in text
+def test_palm_orientation_failure_explains_direction_coverage() -> None:
+ explanation, suggestion = three_camera_reason_zh(
+ "PAUSED",
+ "palm_orientation_quality_failed",
+ {
+ "failures": [
+ {
+ "reason": "palm orientation cycle 2 has 2/3 usable sources"
+ }
+ ]
+ },
+ )
+
+ assert "至少三根手指" in explanation
+ assert "2/3 usable sources" in explanation
+ assert "Tag 10–13" in suggestion
+
+
+def test_cross_view_side_line_rms_names_side_source() -> None:
+ payload = {
+ "state": "PAUSED",
+ "reason": "joint_fit_check_failed",
+ "progress": 0.1,
+ "completed_sweeps": 8,
+ "total_sweeps": 80,
+ "active": {
+ "kind": "fit_failure",
+ "view": "front",
+ "motor_index": 9,
+ "joints": ["pinky_mcp_roll", "pinky_mcp_roll_side"],
+ "attempt": 1,
+ "directions_to_rescan": 8,
+ "failures": [
+ {
+ "joint": "pinky_mcp_roll_side",
+ "model_joint": "pinky_mcp_roll",
+ "quality_source_joints": ["pinky_mcp_roll_side"],
+ "metric": "axis_line_cycle_rms_mm",
+ "actual": 1.2,
+ "limit": 1.0,
+ "comparison": "maximum",
+ }
+ ],
+ },
+ "views": {},
+ "result_path": "",
+ }
+
+ text = render_three_camera_status_text_zh(payload)
+
+ assert "小指MCP侧摆(侧面校验)的四轮轴线位置RMS为1.20mm" in text
+ assert "要求不超过1.00mm" in text
+
+
def test_baseline_hysteresis_failure_shows_values_instead_of_unknown() -> None:
payload = {
"state": "PAUSED",
@@ -297,7 +436,7 @@ def test_zero_model_failure_explains_that_rescan_will_not_help() -> None:
assert "运动采样:" not in text
-def test_sweep_status_shows_full_joint_fit_retry_attempt() -> None:
+def test_sweep_status_shows_localized_fit_retry_cycle() -> None:
active = {
"kind": "sweep",
"view": "front",
@@ -309,9 +448,10 @@ def test_sweep_status_shows_full_joint_fit_retry_attempt() -> None:
"target_u8": 0,
"fit_attempt": 2,
"fit_attempt_limit": 3,
+ "fit_retry_cycles": [2],
}
- assert "整关节自动重采第2/3次" in _task_text(active)
+ assert "补采异常轮2" in _task_text(active)
def test_motor_stall_reason_is_explained_in_chinese() -> None:
diff --git a/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py b/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py
index 50da502..296b52e 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py
@@ -8,12 +8,15 @@ import numpy as np
import pytest
from scipy.spatial.transform import Rotation
+import g20_thumb_apriltag_calibration.three_camera_node as three_camera_node
from g20_thumb_apriltag_calibration.core import (
DIRECTION_DECREASING,
DIRECTION_INCREASING,
)
from g20_thumb_apriltag_calibration.acquisition import TagQuality
from g20_thumb_apriltag_calibration.full_hand import (
+ JointCurveFit,
+ PalmAxisObserver,
RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
RIGHT_19_HAND_PROFILE,
RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS,
@@ -27,20 +30,33 @@ from g20_thumb_apriltag_calibration.full_hand import (
from g20_thumb_apriltag_calibration.three_camera_node import (
FrameObservation,
G20ThreeCameraCalibrationNode,
+ STATE_COMPLETE,
STATE_PREPARE_SWEEP,
STATE_SWEEP,
SweepItem,
+ _build_sweep_plan,
_classify_cross_view_roll_hysteresis,
+ _cross_view_curve_failure,
_combination_observable_joints,
_fit_failure_is_systematic,
+ _fit_retry_joint_names,
_frames_cover_sweep_joints,
_isolated_axis_cycle_outliers,
+ _isolated_axis_line_cycle_outliers,
_overall_progress,
+ _preserve_pnp_task_reference_for_sweep,
+ _palm_axis_observer_for_sweep,
+ _requires_pnp_tracker_reset_for_sweep,
_selected_pose_qualities,
+ _sweep_plan_transition,
_sweep_views,
)
from g20_thumb_apriltag_calibration.pnp import SquareTagPose
from g20_thumb_apriltag_calibration.urdf_zero import (
+ RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
+ JointAxisMeasurement,
+ axis_line_cycle_rms_m,
+ cross_view_side_line_source,
get_zero_calibration_profile,
)
@@ -54,6 +70,390 @@ def _sweep_items() -> list[SweepItem]:
]
+def _validated_zero_result(
+ endpoint_offsets: dict[str, float],
+) -> SimpleNamespace:
+ all_offsets = {
+ name: float(endpoint_offsets.get(name, 0.0))
+ for name in RIGHT_19_HAND_PROFILE.active_joints
+ }
+ return SimpleNamespace(
+ passed=True,
+ direct_offsets_rad=dict(all_offsets),
+ all_active_offsets_rad=dict(all_offsets),
+ offset_confidence_half_width_rad={},
+ cycle_offsets_rad={},
+ training_cycles=(0, 1, 2),
+ validation_cycle=3,
+ axis_line_rms_m=0.0001,
+ validation_line_error_by_joint_m={},
+ observability_rank=22,
+ observability_parameter_count=22,
+ observability_condition_number=100.0,
+ offset_covariance_rad2={},
+ )
+
+
+def test_finalize_publishes_the_frozen_validated_endpoint_state(
+ tmp_path, monkeypatch
+) -> None:
+ endpoint_offsets = {
+ name: 0.001 * (index + 1)
+ for index, name in enumerate(
+ sorted(RIGHT_19_MECHANICAL_ENDPOINT_JOINTS)
+ )
+ }
+ zero_result = _validated_zero_result(endpoint_offsets)
+ session_dir = tmp_path / "20260826_100727"
+ session_dir.mkdir()
+ source_urdf = tmp_path / "source.urdf"
+ source_urdf.write_text("", encoding="utf-8")
+ extrinsics = tmp_path / "extrinsics.yaml"
+ extrinsics.write_text("test: true\n", encoding="utf-8")
+ corrected_urdf = tmp_path / "corrected.urdf"
+ captured: dict[str, object] = {}
+
+ def fake_write_zero_corrected_urdf(**kwargs):
+ captured.update(kwargs)
+ corrected_urdf.write_text("", encoding="utf-8")
+ return corrected_urdf
+
+ monkeypatch.setattr(
+ three_camera_node,
+ "write_zero_corrected_urdf",
+ fake_write_zero_corrected_urdf,
+ )
+ monkeypatch.setattr(
+ three_camera_node,
+ "build_compact_payload",
+ lambda **kwargs: {"passed": kwargs["passed"]},
+ )
+ monkeypatch.setattr(
+ three_camera_node,
+ "atomic_write_json",
+ lambda path, payload: captured.update(
+ {"final_path": path, "payload": payload}
+ ),
+ )
+ node = SimpleNamespace(
+ zero_result=zero_result,
+ validated_endpoint_zero_offsets_rad=dict(endpoint_offsets),
+ validation_errors_rad=[],
+ combination_validation_enabled=False,
+ combination_validation_completed=False,
+ validation_enabled=False,
+ fit_quality_passed=True,
+ session_dir=session_dir,
+ profile=RIGHT_19_HAND_PROFILE,
+ zero_profile=get_zero_calibration_profile("right", "g20_right_19"),
+ source_urdf_path=source_urdf,
+ corrected_urdf_output_dir=tmp_path,
+ serial_number="G20_RIGHT_001",
+ measured_fits={},
+ baseline_command=THREE_CAMERA_BASELINE_COMMAND,
+ hand_type="right",
+ camera_extrinsics_file=extrinsics,
+ final_path=session_dir / "calibration.json",
+ completed_payload=None,
+ state="RETURN_BASELINE",
+ reason="finalize",
+ get_logger=lambda: SimpleNamespace(info=lambda message: None),
+ )
+
+ G20ThreeCameraCalibrationNode._finalize(node)
+
+ assert captured["endpoint_anchored_offsets_rad"] == endpoint_offsets
+ assert captured["payload"] == {"passed": True}
+ assert node.state == STATE_COMPLETE
+ assert node.corrected_urdf_path == corrected_urdf
+
+
+def test_endpoint_state_must_match_the_validated_zero_solution() -> None:
+ endpoint_offsets = {
+ name: 0.001
+ for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
+ }
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ zero_result=_validated_zero_result(endpoint_offsets),
+ validated_endpoint_zero_offsets_rad={
+ name: value
+ for name, value in endpoint_offsets.items()
+ if name != "index_pip"
+ },
+ )
+
+ with pytest.raises(
+ RuntimeError, match="validated_endpoint_zero_state_incomplete"
+ ):
+ G20ThreeCameraCalibrationNode._endpoint_zero_offsets_for_publication(
+ node
+ )
+
+
+def test_refit_invalidates_all_derived_calibration_artifacts() -> None:
+ node = SimpleNamespace(
+ measured_fits={"index_pip": object()},
+ axis_measurements=[object()],
+ zero_result=object(),
+ validated_endpoint_zero_offsets_rad={"index_pip": 0.01},
+ corrected_urdf_path=object(),
+ fit_quality_passed=True,
+ validation_errors_rad=[0.01],
+ cross_view_roll_metrics={"index_mcp_roll": {"rms": 0.01}},
+ validation_only_fits={"index_mcp_roll_side": object()},
+ joint_dynamic_diagnostics={"index_pip": {"holdout": 0.01}},
+ )
+
+ G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(node)
+
+ assert node.measured_fits == {}
+ assert node.axis_measurements == []
+ assert node.zero_result is None
+ assert node.validated_endpoint_zero_offsets_rad == {}
+ assert node.corrected_urdf_path is None
+ assert node.fit_quality_passed is False
+ assert node.validation_errors_rad == []
+ assert node.cross_view_roll_metrics == {}
+ assert node.validation_only_fits == {}
+ assert node.joint_dynamic_diagnostics == {}
+
+
+def test_right_19_plan_is_one_deterministic_transaction_per_task() -> None:
+ plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4)
+
+ assert len(plan) == len(RIGHT_19_HAND_PROFILE.sweep_specs) * 10
+ for spec_index, spec in enumerate(RIGHT_19_HAND_PROFILE.sweep_specs):
+ task = plan[spec_index * 10 : (spec_index + 1) * 10]
+ assert all(item.spec == spec for item in task)
+ assert [
+ (item.precheck, item.cycle, item.direction)
+ for item in task
+ ] == [
+ (True, -1, DIRECTION_DECREASING),
+ (True, -1, DIRECTION_INCREASING),
+ *[
+ (False, cycle, direction)
+ for cycle in range(4)
+ for direction in (
+ DIRECTION_DECREASING,
+ DIRECTION_INCREASING,
+ )
+ ],
+ ]
+ transitions = [
+ _sweep_plan_transition(RIGHT_19_HAND_PROFILE, left, right)
+ for left, right in zip(task, task[1:])
+ ]
+ assert transitions == [
+ "immediate_reverse",
+ "immediate_reverse",
+ "immediate_reverse",
+ "cycle_reset",
+ "immediate_reverse",
+ "cycle_reset",
+ "immediate_reverse",
+ "cycle_reset",
+ "immediate_reverse",
+ ]
+ task_boundaries = [
+ (plan[index], plan[index + 1])
+ for index in range(9, len(plan) - 1, 10)
+ ]
+ assert all(
+ _sweep_plan_transition(RIGHT_19_HAND_PROFILE, left, right)
+ == "task_change"
+ for left, right in task_boundaries
+ )
+
+
+def test_right_19_complete_plan_has_no_unobserved_immediate_handoff() -> None:
+ """Every continuous boundary inherits the endpoint it already observed."""
+ plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4)
+ immediate_boundaries = 0
+ cycle_resets = 0
+ task_changes = 0
+
+ for completed, following in zip(plan, plan[1:]):
+ transition = _sweep_plan_transition(
+ RIGHT_19_HAND_PROFILE, completed, following
+ )
+ if transition == "cycle_reset":
+ cycle_resets += 1
+ continue
+ if transition == "task_change":
+ task_changes += 1
+ continue
+
+ immediate_boundaries += 1
+ endpoint_state = list(
+ build_calibration_motion_command(
+ completed.spec,
+ completed.target_u8,
+ baseline=THREE_CAMERA_BASELINE_COMMAND,
+ profile=RIGHT_19_HAND_PROFILE,
+ )
+ )
+ terminal_frames = [
+ _frame(view, endpoint_state)
+ for view in _sweep_views(
+ RIGHT_19_HAND_PROFILE, completed.spec
+ )
+ ]
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ sweep_frames=terminal_frames,
+ carried_sweep_start_frames=[],
+ preflight_frames=30,
+ raw_path=None,
+ synchronised_endpoint_tolerance_margin_u8=1.0,
+ _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0,
+ )
+
+ carried = (
+ G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames(
+ node, completed, following
+ )
+ )
+
+ assert carried == len(terminal_frames)
+ assert node.carried_sweep_start_frames == terminal_frames
+ assert _frames_cover_sweep_joints(
+ node.carried_sweep_start_frames,
+ following.spec,
+ minimum_per_joint=1,
+ )
+
+ # 16 tasks × (precheck out/back, precheck->formal, four formal reversals).
+ assert immediate_boundaries == 16 * 6
+ assert cycle_resets == 16 * 3
+ assert task_changes == 15
+
+
+def test_right_19_initializes_pnp_once_per_normal_task() -> None:
+ plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4)
+ reset_items = [
+ item
+ for item in plan
+ if _requires_pnp_tracker_reset_for_sweep(
+ RIGHT_19_HAND_PROFILE,
+ item,
+ is_fit_retry=False,
+ )
+ ]
+
+ assert len(reset_items) == len(RIGHT_19_HAND_PROFILE.sweep_specs)
+ assert all(item.precheck for item in reset_items)
+ assert all(item.cycle == -1 for item in reset_items)
+ assert all(
+ item.direction == DIRECTION_DECREASING for item in reset_items
+ )
+
+
+def test_pnp_reset_policy_preserves_legacy_and_retry_recovery() -> None:
+ right_spec = RIGHT_19_HAND_PROFILE.sweep_specs[0]
+ legacy_spec = RIGHT_HAND_PROFILE.sweep_specs[0]
+
+ assert not _requires_pnp_tracker_reset_for_sweep(
+ RIGHT_19_HAND_PROFILE,
+ SweepItem(right_spec, 2, DIRECTION_DECREASING),
+ is_fit_retry=False,
+ )
+ assert _requires_pnp_tracker_reset_for_sweep(
+ RIGHT_19_HAND_PROFILE,
+ SweepItem(right_spec, 2, DIRECTION_DECREASING),
+ is_fit_retry=True,
+ )
+ assert _requires_pnp_tracker_reset_for_sweep(
+ RIGHT_HAND_PROFILE,
+ SweepItem(legacy_spec, 2, DIRECTION_DECREASING),
+ is_fit_retry=False,
+ )
+
+
+def test_task_roles_remain_owned_during_intercycle_reset() -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.task_name == "ring_pitch_side"
+ )
+ view = "side"
+ all_roles = tuple(RIGHT_19_HAND_PROFILE.view_tags[view])
+ runtime = SimpleNamespace(
+ preflight_roles=all_roles,
+ roles=all_roles,
+ )
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ views={view: runtime},
+ active_combination_validation=None,
+ active_sweep=None,
+ active_validation=None,
+ retry_sweep_spec=None,
+ pnp_task_spec=spec,
+ )
+
+ required = G20ThreeCameraCalibrationNode._required_roles_for_view(
+ node, view
+ )
+
+ assert required == ("side_base", "ring_pip")
+ assert required != all_roles
+
+
+def _linear_curve_fit(offset_scale_rad: float = 0.0) -> JointCurveFit:
+ base = np.asarray(
+ [0.5 * (127 - command) / 127 for command in range(256)],
+ dtype=float,
+ )
+ shape = np.abs(np.arange(256, dtype=float) - 127.0) / 128.0
+ values = tuple(float(value) for value in base + offset_scale_rad * shape)
+ return JointCurveFit(values, values, values, {}, 0.0, 0.0, {})
+
+
+def test_cross_view_curve_stable_four_cycle_bias_is_not_rescanned() -> None:
+ primary = _linear_curve_fit()
+ biased = _linear_curve_fit(math.radians(1.8))
+
+ failure = _cross_view_curve_failure(
+ "pinky_mcp_roll",
+ "pinky_mcp_roll_side",
+ primary,
+ biased,
+ scope="all_cycles",
+ maximum_rms_difference_rad=math.radians(1.0),
+ maximum_branch_gap_difference_rad=math.radians(0.5),
+ cycle_fits=[(primary, biased)] * 4,
+ )
+
+ assert failure is not None
+ assert failure["systematic"] is True
+ assert failure["quality_source_joints"] == ["pinky_mcp_roll_side"]
+ assert "cycle" not in failure
+ assert _fit_failure_is_systematic([failure], 4) is True
+
+
+def test_cross_view_curve_single_bad_cycle_localizes_side_retry() -> None:
+ primary = _linear_curve_fit()
+ good = _linear_curve_fit(math.radians(0.6))
+ bad = _linear_curve_fit(math.radians(1.9))
+
+ failure = _cross_view_curve_failure(
+ "middle_mcp_roll",
+ "middle_mcp_roll_side",
+ primary,
+ bad,
+ scope="all_cycles",
+ maximum_rms_difference_rad=math.radians(1.0),
+ maximum_branch_gap_difference_rad=math.radians(0.5),
+ cycle_fits=[(primary, good)] * 3 + [(primary, bad)],
+ )
+
+ assert failure is not None
+ assert failure["cycle"] == 4
+ assert failure.get("systematic", False) is False
+
+
def _frame(view: str, state_u8: list[float]) -> FrameObservation:
return FrameObservation(
stamp_ns=1,
@@ -135,6 +535,101 @@ def test_right_roll_requires_both_views_task_local_tags() -> None:
) == ("side_base", "pinky_pip")
+def test_pitch_task_does_not_add_a_front_direction_observer() -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "pinky_pitch_side"
+ )
+
+ assert _sweep_views(RIGHT_19_HAND_PROFILE, spec) == ("side",)
+ observer = _palm_axis_observer_for_sweep(
+ RIGHT_19_HAND_PROFILE, spec, "front"
+ )
+ assert observer is None
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ views={
+ view: SimpleNamespace(
+ roles=tuple(RIGHT_19_HAND_PROFILE.view_tags[view]),
+ preflight_roles=(f"{view}_base",),
+ )
+ for view in ("front", "side", "top")
+ },
+ active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING),
+ active_validation=None,
+ retry_sweep_spec=None,
+ active_combination_validation=None,
+ )
+ assert G20ThreeCameraCalibrationNode._required_roles_for_view(
+ node, "front"
+ ) == ("front_base",)
+ assert G20ThreeCameraCalibrationNode._required_roles_for_view(
+ node, "side"
+ ) == ("side_base", "pinky_pip")
+
+
+def test_disabled_palm_axis_side_channel_is_not_persisted(tmp_path) -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "pinky_pitch_side"
+ )
+ observer = _palm_axis_observer_for_sweep(
+ RIGHT_19_HAND_PROFILE, spec, "front"
+ )
+ assert observer is None
+ observer = PalmAxisObserver(
+ source_name="pinky_mcp_pitch_front_axis",
+ task_name=spec.key,
+ view="front",
+ parent_role="front_base",
+ child_role="pinky_roll",
+ model_joint="pinky_mcp_pitch",
+ motor_index=9,
+ )
+ item = SweepItem(spec, 0, DIRECTION_DECREASING)
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ active_sweep=item,
+ sweep_attempts={spec.key: 1},
+ palm_axis_records_by_source={observer.source_name: []},
+ raw_path=tmp_path / "raw_samples.jsonl",
+ )
+ selected = {
+ "front_base": SquareTagPose(
+ (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0), 0.1
+ ),
+ "pinky_roll": SquareTagPose(
+ tuple(
+ Rotation.from_euler("y", 12.0, degrees=True).as_quat()
+ ),
+ (0.02, 0.0, 1.0),
+ 0.2,
+ ),
+ }
+ state = list(THREE_CAMERA_BASELINE_COMMAND)
+ state[observer.motor_index] = 230.0
+
+ G20ThreeCameraCalibrationNode._record_palm_axis_sample(
+ node,
+ observer,
+ selected,
+ np.eye(4),
+ state,
+ sync_error_ns=100_000,
+ stamp_ns=123,
+ )
+
+ records = node.palm_axis_records_by_source[observer.source_name]
+ assert len(records) == 1
+ assert records[0]["kind"] == "palm_axis_sample"
+ assert "joint" not in records[0]
+ assert records[0]["command_u8"] == 230
+ G20ThreeCameraCalibrationNode._persist_palm_axis_samples(node, item)
+ assert not node.raw_path.exists()
+
+
def test_multiview_roll_uses_locked_front_base_but_live_moving_tag() -> None:
spec = next(
item
@@ -518,6 +1013,88 @@ def test_right_19_task_requires_only_target_chain_after_clearance_pose() -> None
assert required == ("side_base", "index_pip", "index_dip")
+def test_group_pnp_failure_event_persists_candidate_boundary_once(
+ tmp_path,
+) -> None:
+ profile = RIGHT_19_HAND_PROFILE
+ spec = next(
+ item for item in profile.sweep_specs if item.key == "index_pip_side"
+ )
+ state_u8 = [255.0] * 20
+ state_u8[spec.motor_index] = 195.0
+ runtime = SimpleNamespace(
+ name="side",
+ tracker=SimpleNamespace(
+ last_candidate_diagnostics_by_role={
+ "index_pip": {
+ "solved_candidate_count": 2,
+ "reprojection_candidate_count": 2,
+ "independent_tilt_candidate_count": 2,
+ },
+ "index_dip": {
+ "solved_candidate_count": 2,
+ "reprojection_candidate_count": 0,
+ "independent_tilt_candidate_count": 0,
+ "minimum_reprojection_error_px": 1.7,
+ },
+ }
+ ),
+ latest_group_missing_candidate_roles=("index_dip",),
+ last_pnp_diagnostic_signature=None,
+ view_tags=profile.view_tags["side"],
+ intrinsics_sha256="side-intrinsics",
+ camera_matrix=np.eye(3),
+ )
+ node = SimpleNamespace(
+ profile=profile,
+ active_sweep=SweepItem(
+ spec, -1, DIRECTION_DECREASING, precheck=True
+ ),
+ state=STATE_SWEEP,
+ latest_state_u8=tuple(state_u8),
+ raw_path=tmp_path / "raw_samples.jsonl",
+ )
+ arguments = dict(
+ runtime=runtime,
+ stamp_ns=123,
+ required_roles=("side_base", "index_pip", "index_dip"),
+ pose_roles=("index_pip", "index_dip"),
+ corners_by_role={
+ "index_pip": np.zeros((4, 2)),
+ "index_dip": np.ones((4, 2)),
+ },
+ qualities={
+ "index_pip": TagQuality(0, 80.0, 40.0),
+ "index_dip": TagQuality(0, 70.0, 35.0),
+ },
+ matched_tracking=(tuple(state_u8), 2_000_000),
+ selected=None,
+ pnp_rejections={
+ "index_dip": "no_pose_within_reprojection_or_tilt_limit"
+ },
+ group_pnp_reason="group_missing_pose_candidates",
+ )
+
+ G20ThreeCameraCalibrationNode._record_group_pnp_candidate_event(
+ node, **arguments
+ )
+ G20ThreeCameraCalibrationNode._record_group_pnp_candidate_event(
+ node, **arguments
+ )
+
+ rows = [
+ json.loads(line)
+ for line in node.raw_path.read_text(encoding="utf-8").splitlines()
+ ]
+ assert len(rows) == 1
+ assert rows[0]["kind"] == "group_pnp_candidate_event"
+ assert rows[0]["feedback_u8"] == 195.0
+ assert rows[0]["group_missing_candidate_roles"] == ["index_dip"]
+ assert rows[0]["candidate_diagnostics"]["index_dip"][
+ "reprojection_candidate_count"
+ ] == 0
+
+
def test_right_pip_sweep_keeps_side_base_as_pnp_branch_anchor() -> None:
profile = RIGHT_HAND_PROFILE
spec = next(
@@ -614,6 +1191,54 @@ def test_begin_sweep_carries_start_endpoint_frame_into_sweep() -> None:
assert published[0][7:10] == [0, 0, 0]
+def test_immediate_reverse_reuses_proven_terminal_endpoint_frames(tmp_path) -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.motor_index == 1
+ )
+ completed = SweepItem(
+ spec, -1, DIRECTION_DECREASING, precheck=True
+ )
+ following = SweepItem(
+ spec, -1, DIRECTION_INCREASING, precheck=True
+ )
+ endpoint_state = list(
+ build_calibration_motion_command(
+ spec,
+ 0,
+ baseline=THREE_CAMERA_BASELINE_COMMAND,
+ profile=RIGHT_19_HAND_PROFILE,
+ )
+ )
+ endpoint_state[1] = 2.5
+ outside_state = list(endpoint_state)
+ outside_state[1] = 3.01
+ endpoint_frame = _frame("side", endpoint_state)
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ sweep_frames=[endpoint_frame, _frame("side", outside_state)],
+ carried_sweep_start_frames=[],
+ preflight_frames=30,
+ raw_path=tmp_path / "raw_samples.jsonl",
+ synchronised_endpoint_tolerance_margin_u8=1.0,
+ _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0,
+ )
+
+ count = (
+ G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames(
+ node, completed, following
+ )
+ )
+
+ assert count == 1
+ assert node.carried_sweep_start_frames == [endpoint_frame]
+ event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
+ assert event["kind"] == "sweep_endpoint_frames_carried"
+ assert event["endpoint_u8"] == 0
+ assert event["to_direction"] == DIRECTION_INCREASING
+
+
def test_right_19_roll_sweep_stops_at_settled_baseline_first() -> None:
spec = next(
item
@@ -1382,11 +2007,11 @@ def test_retry_return_anchors_roll_order_on_failed_target_finger() -> None:
def test_precheck_dense_coverage_selects_faster_formal_speed(tmp_path) -> None:
spec = next(
item
- for item in RIGHT_19_HAND_PROFILE.sweep_specs
- if item.task_name == "thumb_cmc_pitch_front"
+ for item in RIGHT_HAND_PROFILE.sweep_specs
+ if item.motor_index == 0
)
node = SimpleNamespace(
- profile=RIGHT_19_HAND_PROFILE,
+ profile=RIGHT_HAND_PROFILE,
active_sweep=None,
normal_calibration_speed=15,
index_roll_calibration_speed=5,
@@ -1421,6 +2046,49 @@ def test_precheck_dense_coverage_selects_faster_formal_speed(tmp_path) -> None:
assert event["formal_speed"] == 22
+def test_right_19_dense_precheck_keeps_deterministic_formal_speed(tmp_path) -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.task_name == "thumb_cmc_pitch_front"
+ )
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ active_sweep=None,
+ normal_calibration_speed=15,
+ index_roll_calibration_speed=5,
+ index_flex_calibration_speed=10,
+ adaptive_formal_speed_enabled=True,
+ adaptive_formal_speed_max_scale=1.5,
+ adaptive_formal_speed_minimum_bins=64,
+ adaptive_formal_speed_maximum_bin_gap=8,
+ precheck_speed_metrics={},
+ formal_speed_scales={},
+ sweep_retry_counts={},
+ raw_path=tmp_path / "raw_samples.jsonl",
+ )
+ for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING):
+ item = SweepItem(spec, -1, direction, precheck=True)
+ node.active_sweep = item
+ G20ThreeCameraCalibrationNode._record_precheck_speed_metric(
+ node,
+ item,
+ bin_count=220,
+ maximum_bin_gap=3,
+ valid_frames=380,
+ )
+
+ assert node.formal_speed_scales[spec.key] == 1.0
+ node.active_sweep = SweepItem(spec, 0, DIRECTION_DECREASING)
+ speeds = G20ThreeCameraCalibrationNode._speed_profile_for_spec(node, spec)
+ assert speeds[0] == 15
+ event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
+ assert event["stability_speed_lock"] is True
+ assert event["ineligible_reason"] == (
+ "g20_right_deterministic_acquisition_speed"
+ )
+
+
def test_precheck_large_gap_keeps_conservative_formal_speed(tmp_path) -> None:
spec = next(
item
@@ -1495,7 +2163,9 @@ def test_roll_precheck_never_accelerates_strict_backlash_scan(tmp_path) -> None:
assert node.formal_speed_scales[spec.key] == 1.0
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event["eligible"] is False
- assert event["ineligible_reason"] == "roll_baseline_hysteresis_sensitive"
+ assert event["ineligible_reason"] == (
+ "g20_right_deterministic_acquisition_speed"
+ )
def test_fit_retry_uses_base_speed_instead_of_adaptive_speed() -> None:
@@ -1552,9 +2222,9 @@ def test_thumb_yaw_recovery_zero_accepts_feedback_four() -> None:
def _image_cycle_records(
- travels_rad: list[float], *, depth_slope: float = 0.0
+ travels_rad: list[float], *, depth_slope: float = 0.0, command_step: int = 16
) -> list[dict]:
- commands = list(range(0, 256, 16)) + [255]
+ commands = list(range(0, 256, command_step)) + [255]
records = []
for cycle, travel in enumerate(travels_rad):
for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING):
@@ -1651,8 +2321,17 @@ def test_right_19_end_on_flexion_uses_image_curve_not_planar_pnp_tilt() -> None:
)
record["relative_quaternion_xyzw"] = rotation.as_quat().tolist()
- node = _fit_check_node({"pinky_pip": records, "pinky_dip": records})
+ node = _fit_check_node(
+ {
+ "pinky_mcp_roll": records,
+ "pinky_pip": records,
+ "pinky_dip": records,
+ }
+ )
node.profile = RIGHT_19_HAND_PROFILE
+ roll_fit = G20ThreeCameraCalibrationNode._fit_joint_records(
+ node, "pinky_mcp_roll", records
+ )
image_fit = G20ThreeCameraCalibrationNode._fit_joint_records(
node, "pinky_pip", records
)
@@ -1661,6 +2340,7 @@ def test_right_19_end_on_flexion_uses_image_curve_not_planar_pnp_tilt() -> None:
)
assert "pinky_pip" in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
+ assert roll_fit.circle["space"] == "image_2d"
assert image_fit.circle["space"] == "image_2d"
assert image_fit.quality["radial_rms_px"] < 1.0e-8
# DIP is measured against its moving PIP parent, so it must remain a
@@ -1668,6 +2348,39 @@ def test_right_19_end_on_flexion_uses_image_curve_not_planar_pnp_tilt() -> None:
assert passive_fit.circle["space"] == "relative_rotation_3d"
+def test_right_19_finger_rolls_share_reference_axis_direction() -> None:
+ pinky = _image_cycle_records([math.radians(47.0)] * 3)
+ ring = _image_cycle_records([math.radians(47.0)] * 3)
+ for record in ring:
+ command = int(record["command_u8"])
+ angle = math.radians(47.0) * (255.0 - command) / 255.0
+ record["relative_quaternion_xyzw"] = (
+ Rotation.from_rotvec([angle, 0.0, 0.0]).as_quat().tolist()
+ )
+
+ node = _fit_check_node(
+ {"pinky_mcp_roll": pinky, "ring_mcp_roll": ring}
+ )
+ node.profile = RIGHT_19_HAND_PROFILE
+ node.zero_profile = get_zero_calibration_profile(
+ "right", RIGHT_19_HAND_PROFILE.layout_id
+ )
+ reference = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw(
+ node, "pinky_mcp_roll", 0
+ )
+ measured = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw(
+ node, "ring_mcp_roll", 0
+ )
+
+ assert measured.axis_direction_source == "upstream_constraint"
+ assert abs(
+ float(
+ np.asarray(reference.axis_common_xyz)
+ @ np.asarray(measured.axis_common_xyz)
+ )
+ ) > math.cos(math.radians(1.0e-4))
+
+
def test_thumb_mcp_ip_group_uses_mimic_only_as_pnp_branch_prior() -> None:
node = SimpleNamespace(
profile=RIGHT_19_HAND_PROFILE,
@@ -1696,7 +2409,7 @@ def test_thumb_mcp_ip_group_uses_mimic_only_as_pnp_branch_prior() -> None:
)
-def test_side_pip_group_allows_arbitrary_distal_extension_tag_plane() -> None:
+def test_side_joint_group_does_not_assume_tag_mounting_planes() -> None:
node = SimpleNamespace(
profile=RIGHT_19_HAND_PROFILE,
pnp_maximum_pose_jump_rad=math.radians(35.0),
@@ -1720,9 +2433,7 @@ def test_side_pip_group_allows_arbitrary_distal_extension_tag_plane() -> None:
("side_base", "middle_pip"),
("middle_pip", "middle_dip"),
)
- assert tracker.normal_alignment_pairs == (
- ("side_base", "middle_pip"),
- )
+ assert tracker.normal_alignment_pairs == ()
def test_fit_failure_preserves_main_progress_and_selects_retry_scope(tmp_path) -> None:
@@ -1911,6 +2622,7 @@ def test_multiview_retry_discards_only_failed_side_measurement(tmp_path) -> None
task_valid_frames=4927,
task_total_frames=5212,
)
+ resets: list[bool] = []
node = SimpleNamespace(
profile=RIGHT_19_HAND_PROFILE,
retry_sweep_spec=spec,
@@ -1933,7 +2645,11 @@ def test_multiview_retry_discards_only_failed_side_measurement(tmp_path) -> None
)
],
views={"side": side_runtime},
- _reset_view_trackers=lambda runtime: None,
+ _reset_view_trackers=(
+ lambda runtime, preserve_task_reference=False: resets.append(
+ preserve_task_reference
+ )
+ ),
)
G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node)
@@ -1948,6 +2664,7 @@ def test_multiview_retry_discards_only_failed_side_measurement(tmp_path) -> None
assert node.retry_joint_names == {validation}
assert side_runtime.task_valid_frames == 0
assert side_runtime.task_total_frames == 0
+ assert resets == [True]
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event["joints_to_rescan"] == [validation]
assert event["cycles"] == [1]
@@ -2048,6 +2765,132 @@ def test_visibility_precheck_does_not_require_dense_feedback_bins(tmp_path) -> N
assert event["detection_rate"] == 1.0
+def test_visibility_precheck_accepts_interpolated_endpoint_fraction(tmp_path) -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.motor_index == 1
+ )
+ item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True)
+ # Field regression: the motor reached zero with the physical 2-u8
+ # deadband, while the last image-timestamped feedback was 2.502 u8.
+ commands = [2.5021463960247776, *range(3, 255)]
+ frames = []
+ for command in commands:
+ state = [255.0] * 20
+ state[1] = float(command)
+ frames.append(_frame("side", state))
+ transitions: list[str] = []
+ retries: list[str] = []
+ node = SimpleNamespace(
+ active_sweep=item,
+ sweep_frames=frames,
+ synchronised_endpoint_tolerance_margin_u8=1.0,
+ minimum_sweep_bins=32,
+ maximum_bin_gap=16,
+ minimum_detection_rate=0.95,
+ views={"side": SimpleNamespace(valid_rate=1.0)},
+ sweep_detection_total_frames=500,
+ sweep_detection_valid_frames=382,
+ raw_path=tmp_path / "raw_samples.jsonl",
+ active_sweep_is_fit_retry=False,
+ sweep_index=0,
+ sweep_items=[item],
+ profile=RIGHT_19_HAND_PROFILE,
+ normal_calibration_speed=15,
+ index_roll_calibration_speed=5,
+ index_flex_calibration_speed=10,
+ precheck_speed_metrics={},
+ formal_speed_scales={},
+ sweep_retry_counts={},
+ _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0,
+ _retry_active_sweep_or_pause=lambda reason: retries.append(reason),
+ _begin_return_baseline=lambda after: transitions.append(after),
+ )
+
+ G20ThreeCameraCalibrationNode._finish_active_sweep(node)
+
+ assert retries == []
+ assert transitions == ["fit"]
+ event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
+ assert event["detection_rate"] == 0.764
+ assert event["detection_rate_below_threshold_views"] == ["side"]
+ assert event["trajectory_coverage_override_views"] == ["side"]
+
+
+def test_low_precheck_rate_still_retries_when_trajectory_is_sparse(tmp_path) -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.motor_index == 1
+ )
+ item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True)
+ frames = []
+ for command in (0, 127, 255):
+ state = [255.0] * 20
+ state[1] = float(command)
+ frames.append(_frame("side", state))
+ retries: list[str] = []
+ node = SimpleNamespace(
+ active_sweep=item,
+ sweep_frames=frames,
+ minimum_sweep_bins=32,
+ maximum_bin_gap=16,
+ minimum_detection_rate=0.95,
+ sweep_detection_total_frames=100,
+ sweep_detection_valid_frames=3,
+ profile=RIGHT_19_HAND_PROFILE,
+ _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0,
+ _retry_active_sweep_or_pause=lambda reason: retries.append(reason),
+ )
+
+ G20ThreeCameraCalibrationNode._finish_active_sweep(node)
+
+ assert retries == ["task_precheck_detection_rate_too_low:side"]
+
+
+def test_visibility_precheck_rejects_sample_outside_sync_endpoint_margin() -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.motor_index == 1
+ )
+ item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True)
+ frames = []
+ for command in [3.01, *range(4, 255)]:
+ state = [255.0] * 20
+ state[1] = float(command)
+ frames.append(_frame("side", state))
+ retries: list[str] = []
+ node = SimpleNamespace(
+ active_sweep=item,
+ sweep_frames=frames,
+ synchronised_endpoint_tolerance_margin_u8=1.0,
+ _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0,
+ _retry_active_sweep_or_pause=lambda reason: retries.append(reason),
+ )
+
+ G20ThreeCameraCalibrationNode._finish_active_sweep(node)
+
+ assert retries == ["sweep_missing_endpoint_bin"]
+
+
+def test_synchronised_endpoint_margin_does_not_change_motion_deadband() -> None:
+ spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
+ node = SimpleNamespace(
+ synchronised_endpoint_tolerance_margin_u8=1.0,
+ _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0,
+ )
+
+ assert node._endpoint_tolerance_for_spec(spec, 0) == 2.0
+ assert (
+ G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec(
+ node, spec, 0
+ )
+ == 3.0
+ )
+
+
def test_formal_sweep_still_rejects_17_u8_feedback_gap(tmp_path) -> None:
spec = next(
item
@@ -2235,6 +3078,62 @@ def test_motion_timeout_republishes_twice_before_pause(tmp_path) -> None:
assert pauses == ["return_baseline_timeout"]
+def test_sweep_start_tag_timeout_resets_pnp_before_retry(tmp_path) -> None:
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "ring_pitch_side"
+ )
+ item = SweepItem(spec, 0, DIRECTION_DECREASING)
+ runtime = SimpleNamespace(pnp_invalid_since=10.0, pnp_reset_count=0)
+ resets: list[tuple[object, bool]] = []
+ diagnostic_resets: list[object] = []
+ commands: list[list[int]] = []
+ start_frames = [object()]
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ active_sweep=item,
+ active_sweep_is_fit_retry=False,
+ views={"side": runtime},
+ sweep_start_frames=start_frames,
+ motion_retry_counts={},
+ automatic_motion_retry_limit=2,
+ raw_path=tmp_path / "raw_samples.jsonl",
+ state=STATE_PREPARE_SWEEP,
+ position_hold_since=1.0,
+ reason="",
+ preparation_command_u8=tuple([255] * 20),
+ baseline_command=tuple([255] * 20),
+ latest_state_u8=tuple([255] * 20),
+ _reset_view_trackers=lambda selected, preserve_task_reference=False: (
+ resets.append((selected, preserve_task_reference))
+ ),
+ _reset_view_pnp_diagnostics=lambda selected: (
+ diagnostic_resets.append(selected)
+ ),
+ _command_vector_error_u8=lambda command: 0.0,
+ _normal_speed_profile=lambda: [15] * 5,
+ _publish_speed_profile=lambda profile: None,
+ _publish_command=lambda command: commands.append(command),
+ _reset_motion_progress=lambda now, error: None,
+ _pause=lambda reason: pytest.fail(f"unexpected pause: {reason}"),
+ )
+
+ G20ThreeCameraCalibrationNode._retry_motion_or_pause(
+ node, "sweep_start_tag_timeout", 20.0
+ )
+
+ assert resets == [(runtime, True)]
+ assert diagnostic_resets == [runtime]
+ assert runtime.pnp_invalid_since is None
+ assert runtime.pnp_reset_count == 1
+ assert start_frames == []
+ assert len(commands) == 1
+ event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
+ assert event["pnp_trackers_reset"] == ["side"]
+ assert event["task_reference_preserved"] is True
+
+
def test_motion_stall_pauses_without_consuming_sweep_retries(tmp_path) -> None:
pauses: list[str] = []
node = SimpleNamespace(
@@ -2349,6 +3248,71 @@ def test_axis_cycle_spread_localizes_one_outlier_for_four_rounds() -> None:
) == {0}
+def test_axis_line_spread_localizes_one_outlier_for_four_rounds() -> None:
+ axes = [
+ [-0.043565, 0.036698, -0.998376],
+ [-0.043462, 0.047539, -0.997923],
+ [-0.045335, 0.041627, -0.998104],
+ [-0.043689, 0.044394, -0.998058],
+ ]
+ points_mm = [
+ [-43.981, 52.532, 1101.515],
+ [-43.999, 50.684, 1101.530],
+ [-44.080, 49.366, 1101.310],
+ [-44.252, 50.672, 1101.511],
+ ]
+ measurements = [
+ SimpleNamespace(
+ axis_common_xyz=axis,
+ point_common_xyz_m=[0.001 * value for value in point],
+ )
+ for axis, point in zip(axes, points_mm)
+ ]
+
+ assert _isolated_axis_line_cycle_outliers(
+ measurements, 0.001
+ ) == {0}
+
+
+def test_axis_line_spread_does_not_localize_ambiguous_drift() -> None:
+ measurements = [
+ SimpleNamespace(
+ axis_common_xyz=[0.0, 0.0, 1.0],
+ point_common_xyz_m=[0.0, offset_m, 0.0],
+ )
+ for offset_m in (0.0, 0.001, 0.002, 0.003)
+ ]
+
+ assert not _isolated_axis_line_cycle_outliers(
+ measurements, 0.001
+ )
+
+
+def test_fit_retry_preserves_first_cycle_pnp_task_reference() -> None:
+ spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
+
+ assert _preserve_pnp_task_reference_for_sweep(
+ SweepItem(spec, 0, DIRECTION_DECREASING),
+ is_fit_retry=True,
+ has_precheck_anchor=False,
+ )
+ assert _preserve_pnp_task_reference_for_sweep(
+ SweepItem(spec, 0, DIRECTION_DECREASING),
+ is_fit_retry=False,
+ has_precheck_anchor=True,
+ )
+ assert not _preserve_pnp_task_reference_for_sweep(
+ SweepItem(spec, 0, DIRECTION_DECREASING),
+ is_fit_retry=False,
+ has_precheck_anchor=False,
+ )
+ assert _preserve_pnp_task_reference_for_sweep(
+ SweepItem(spec, 1, DIRECTION_DECREASING),
+ is_fit_retry=False,
+ has_precheck_anchor=False,
+ )
+
+
def test_provisional_axis_spread_marks_only_the_isolated_round() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 0)
records = _image_cycle_records([math.radians(47.0)] * 4)
@@ -2660,22 +3624,147 @@ def test_cross_view_roll_axis_disagreement_skips_fusion(tmp_path) -> None:
# bias in session 20260820_105535: it must keep the trusted front axis
# and record a diagnostic instead of failing the joint.
assert measurement.axis_direction_source != "cross_view_weighted_fusion"
+ assert measurement.pose_axis_line_source_joints == (
+ "pinky_mcp_roll_side",
+ )
diagnostics = [
json.loads(line)
for line in node.raw_path.read_text().splitlines()
]
assert diagnostics[-1]["kind"] == "cross_view_roll_axis_diagnostic"
- assert diagnostics[-1]["decision"] == "skip_fusion_use_primary"
+ assert diagnostics[-1]["decision"] == (
+ "diagnostic_only_pose_disagreement_use_primary"
+ )
assert 10.0 < diagnostics[-1]["axis_difference_deg"] < 12.5
+def test_cross_view_axis_group_uses_one_physical_radius(tmp_path) -> None:
+ node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0)
+ measurements = [
+ node._fit_axis_measurement("pinky_mcp_roll", cycle)
+ for cycle in range(node.repetitions)
+ ]
+
+ refined = G20ThreeCameraCalibrationNode._refit_cross_view_axis_line_group(
+ node, "pinky_mcp_roll", measurements
+ )
+
+ assert all(
+ measurement.axis_point_source
+ == "side_circle_shared_radius_cross_view"
+ for measurement in refined
+ )
+ assert all(
+ cross_view_side_line_source(measurement)
+ == "pinky_mcp_roll_side"
+ for measurement in refined
+ )
+ assert axis_line_cycle_rms_m(refined) < 1.0e-6
+
+
+def test_cross_view_side_line_failure_rescans_only_side_alias(
+ tmp_path, monkeypatch
+) -> None:
+ node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0)
+ original_fit_axis = node._fit_axis_measurement
+
+ def displaced_side_line(name, cycle):
+ measurement = original_fit_axis(name, cycle)
+ if name == "pinky_mcp_roll" and cycle == node.repetitions - 1:
+ point = np.asarray(measurement.point_common_xyz_m, dtype=float)
+ point[0] += 0.0024
+ return replace(
+ measurement,
+ point_common_xyz_m=tuple(float(value) for value in point),
+ )
+ return measurement
+
+ node._fit_axis_measurement = displaced_side_line
+ # This test isolates the downstream repeatability gate. The production
+ # shared-radius refit legitimately removes an artificial point-only
+ # displacement that is absent from the underlying trajectory records.
+ monkeypatch.setattr(
+ G20ThreeCameraCalibrationNode,
+ "_refit_cross_view_axis_line_group",
+ lambda _self, _name, measurements: list(measurements),
+ )
+ monkeypatch.setitem(
+ G20ThreeCameraCalibrationNode._provisional_fit_failures.__globals__,
+ "_isolated_axis_line_cycle_outliers",
+ lambda measurements, limit_m: {3},
+ )
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "pinky_roll_multiview"
+ )
+
+ failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
+ node, spec, include_view_validity=False
+ )
+ line_failures = [
+ failure
+ for failure in failures
+ if failure["metric"] == "axis_line_cycle_rms_mm"
+ ]
+
+ assert line_failures
+ assert {
+ failure["joint"] for failure in line_failures
+ } == {"pinky_mcp_roll_side"}
+ assert all(
+ failure["model_joint"] == "pinky_mcp_roll"
+ and failure["quality_source_joints"] == ["pinky_mcp_roll_side"]
+ for failure in line_failures
+ )
+ assert all(failure["cycle"] == 4 for failure in line_failures)
+ assert all(
+ failure["inlier_cycles"] == [1, 2, 3]
+ for failure in line_failures
+ )
+ assert _fit_retry_joint_names(
+ RIGHT_19_HAND_PROFILE, spec, line_failures
+ ) == {"pinky_mcp_roll_side"}
+
+
+def test_cross_view_side_pose_residual_uses_line_repeatability_gate(
+ tmp_path,
+) -> None:
+ node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0)
+ original_fit_axis = node._fit_axis_measurement
+
+ def biased_but_repeatable_side_pose(name, cycle):
+ measurement = original_fit_axis(name, cycle)
+ if name == "pinky_mcp_roll":
+ return replace(measurement, pose_axis_line_rms_m=0.00125)
+ return measurement
+
+ node._fit_axis_measurement = biased_but_repeatable_side_pose
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "pinky_roll_multiview"
+ )
+
+ failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
+ node, spec, include_view_validity=False
+ )
+
+ assert not [
+ failure
+ for failure in failures
+ if failure["metric"]
+ in {"axis_pose_line_rms_mm", "axis_line_cycle_rms_mm"}
+ ]
+
+
def test_cross_view_roll_systematic_line_offset_skips_fusion(tmp_path) -> None:
- # Session 20260820_132727: the front roll-link and side PIP-link axis
- # lines sit ~21 mm apart systematically while the axes differ by ~11.4
- # deg; both are inside the gross bounds, so the joint must keep the
- # front-only axis instead of failing.
+ # Ring session 20260824_155323: the front screw-driven pseudo-line and
+ # side PIP-link physical line sit 36.6-38.2 mm apart while both views are
+ # individually clean. This finger-dependent offset must remain a
+ # diagnostic and must not reject the trusted-front/side-line fallback.
node = _cross_view_roll_node(
- tmp_path, side_tilt_deg=11.0, side_offset_m=[0.021, 0.0, 0.0]
+ tmp_path, side_tilt_deg=11.0, side_offset_m=[0.038, 0.0, 0.0]
)
measurement = node._fit_axis_measurement("pinky_mcp_roll", 0)
@@ -2686,7 +3775,7 @@ def test_cross_view_roll_systematic_line_offset_skips_fusion(tmp_path) -> None:
for line in node.raw_path.read_text().splitlines()
]
assert diagnostics[-1]["kind"] == "cross_view_roll_axis_diagnostic"
- assert 18.0 < diagnostics[-1]["line_distance_mm"] < 25.0
+ assert 34.0 < diagnostics[-1]["line_distance_mm"] < 42.0
assert 10.0 < diagnostics[-1]["axis_difference_deg"] < 12.5
@@ -2705,28 +3794,178 @@ def test_cross_view_roll_diagnostic_uses_real_node_logger(tmp_path) -> None:
assert any("disagree" in message for message in warnings)
-def test_cross_view_roll_axis_gross_disagreement_still_fails(tmp_path) -> None:
+def test_cross_view_roll_axis_gross_disagreement_is_diagnostic_only(
+ tmp_path,
+) -> None:
node = _cross_view_roll_node(tmp_path, side_tilt_deg=20.0)
- with pytest.raises(
- ValueError, match="cross_view_roll_axis_gross_disagreement"
- ):
- node._fit_axis_measurement("pinky_mcp_roll", 0)
+ measurement = node._fit_axis_measurement("pinky_mcp_roll", 0)
+
+ assert measurement.axis_direction_source == "rotation"
+ diagnostics = [
+ json.loads(line)
+ for line in node.raw_path.read_text().splitlines()
+ ]
+ assert diagnostics[-1]["decision"] == (
+ "diagnostic_only_gross_pose_disagreement_use_primary"
+ )
-def test_cross_view_roll_axis_agreement_still_fuses(tmp_path) -> None:
+def test_cross_view_roll_gross_disagreement_does_not_rescan(tmp_path) -> None:
+ node = _cross_view_roll_node(tmp_path, side_tilt_deg=20.0)
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "pinky_roll_multiview"
+ )
+
+ failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
+ node, spec, include_view_validity=False
+ )
+ gross_failures = [
+ failure
+ for failure in failures
+ if str(failure.get("reason", "")).startswith(
+ "cross_view_roll_axis_gross_disagreement:"
+ )
+ ]
+
+ assert not gross_failures
+ diagnostics = [
+ json.loads(line)
+ for line in node.raw_path.read_text().splitlines()
+ ]
+ assert any(
+ row.get("decision")
+ == "diagnostic_only_gross_pose_disagreement_use_primary"
+ for row in diagnostics
+ )
+
+
+def test_cross_view_roll_axis_agreement_keeps_published_primary(tmp_path) -> None:
node = _cross_view_roll_node(tmp_path, side_tilt_deg=0.2)
measurement = node._fit_axis_measurement("pinky_mcp_roll", 0)
- assert measurement.axis_direction_source == "cross_view_weighted_fusion"
+ assert measurement.axis_direction_source == "rotation"
+ assert measurement.pose_axis_line_source_joints == (
+ "pinky_mcp_roll_side",
+ )
-def test_side_alias_branch_gap_range_uses_relaxed_limit(
+def test_cross_view_roll_side_pose_never_replaces_primary_direction(
+ tmp_path, monkeypatch
+) -> None:
+ # Regression for session 20260824_171302. Three side-view cone
+ # residuals were just inside 5 deg and one was 5.037 deg. Selecting the
+ # camera per cycle mixed three side axes with one front axis and created a
+ # false 3.24 deg cycle spread although each camera was internally stable.
+ names = (
+ "middle_mcp_roll",
+ "middle_mcp_roll_side",
+ "middle_mcp_pitch",
+ )
+ node = SimpleNamespace(
+ profile=RIGHT_19_HAND_PROFILE,
+ zero_profile=get_zero_calibration_profile(
+ "right", "g20_right_19"
+ ),
+ repetitions=4,
+ records_by_joint={
+ name: [{"cycle": cycle} for cycle in range(4)]
+ for name in names
+ },
+ source_urdf_path=tmp_path / "unused.urdf",
+ raw_path=tmp_path / "raw_samples.jsonl",
+ zero_maximum_axis_cycle_difference_rad=math.radians(0.75),
+ zero_maximum_axis_cone_mismatch_rad=math.radians(5.0),
+ axis_maximum_pose_line_rms_m=0.001,
+ cross_view_roll_maximum_axis_difference_rad=math.radians(15.0),
+ )
+ primary_degrees = (8.0078, 8.1710, 8.0712, 8.0236)
+ secondary_degrees = (4.9043, 4.9136, 5.0374, 4.9389)
+
+ def axis(degrees: float) -> tuple[float, float, float]:
+ angle = math.radians(degrees)
+ return (math.sin(angle), 0.0, math.cos(angle))
+
+ def measurement(name: str, cycle: int) -> JointAxisMeasurement:
+ if name == "middle_mcp_roll":
+ direction = axis(primary_degrees[cycle])
+ elif name == "middle_mcp_roll_side":
+ direction = axis(secondary_degrees[cycle])
+ else:
+ direction = (1.0, 0.0, 0.0)
+ return JointAxisMeasurement(
+ joint=name,
+ cycle=cycle,
+ axis_common_xyz=direction,
+ point_common_xyz_m=(0.0, 0.0, 0.0),
+ condition_state_u8=(255.0,) * 20,
+ plane_rms_m=0.0001,
+ radial_rms_m=0.0001,
+ rotation_circle_axis_difference_rad=0.0,
+ axis_direction_source="rotation",
+ pose_axis_line_rms_m=0.0001,
+ )
+
+ class Model:
+ def __init__(self, unused_path) -> None:
+ pass
+
+ def axis_line(self, name, *, zero_offsets, joint_angles):
+ direction = (
+ np.asarray([0.0, 0.0, 1.0])
+ if name == "middle_mcp_roll"
+ else np.asarray([1.0, 0.0, 0.0])
+ )
+ return direction, np.zeros(3)
+
+ monkeypatch.setattr(
+ G20ThreeCameraCalibrationNode,
+ "_fit_axis_measurement_raw",
+ lambda unused_self, name, cycle: measurement(name, cycle),
+ )
+ monkeypatch.setitem(
+ G20ThreeCameraCalibrationNode._fit_axis_measurement.__globals__,
+ "UrdfKinematicModel",
+ Model,
+ )
+
+ measured = [
+ G20ThreeCameraCalibrationNode._fit_axis_measurement(
+ node, "middle_mcp_roll", cycle
+ )
+ for cycle in range(4)
+ ]
+
+ assert {
+ item.axis_direction_source for item in measured
+ } == {"rotation"}
+ maximum_spread = max(
+ math.acos(
+ abs(
+ float(
+ np.clip(
+ np.asarray(left.axis_common_xyz)
+ @ np.asarray(right.axis_common_xyz),
+ -1.0,
+ 1.0,
+ )
+ )
+ )
+ )
+ for left in measured
+ for right in measured
+ )
+ assert math.degrees(maximum_spread) < 0.75
+
+
+def test_side_alias_branch_gap_range_is_diagnostic_only(
tmp_path, monkeypatch
) -> None:
records = _image_cycle_records([math.radians(47.0)] * 3)
- gaps_deg = [0.10, 0.46, 0.10] # range 0.36 deg, maximum 0.46 deg
+ gaps_deg = [0.10, 0.81, 0.10] # range 0.71 deg, maximum below 2 deg
monkeypatch.setattr(
"g20_thumb_apriltag_calibration.three_camera_node"
".baseline_hysteresis_by_cycle_rad",
@@ -2760,6 +3999,19 @@ def test_side_alias_branch_gap_range_uses_relaxed_limit(
for failure in alias_failures
if failure["metric"] == "baseline_directional_gap_range_deg"
]
+ alias_diagnostics = [
+ json.loads(line)
+ for line in (tmp_path / "raw_pinky_mcp_roll_side.jsonl")
+ .read_text()
+ .splitlines()
+ ]
+ assert any(
+ row.get("kind") == "validation_only_quality_diagnostic"
+ and row.get("metric") == "baseline_directional_gap_range_deg"
+ and row.get("actual") == 0.71
+ and row.get("reference_limit") == 0.3
+ for row in alias_diagnostics
+ )
canonical_failures = (
G20ThreeCameraCalibrationNode._provisional_fit_failures(
@@ -2775,6 +4027,70 @@ def test_side_alias_branch_gap_range_uses_relaxed_limit(
assert range_failure["limit"] == 0.3
+def test_side_alias_axis_cycle_spread_is_diagnostic_only(tmp_path) -> None:
+ records = _image_cycle_records([math.radians(47.0)] * 4)
+ multiview = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.key == "pinky_roll_multiview"
+ )
+ name = "pinky_mcp_roll_side"
+ node = _fit_check_node({name: records})
+ node.profile = RIGHT_19_HAND_PROFILE
+ node.zero_profile = get_zero_calibration_profile(
+ "right", "g20_right_15"
+ )
+ node.repetitions = 4
+ node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND)
+ node.baseline_records_by_joint = {name: records}
+ node.raw_path = tmp_path / "raw_axis_alias.jsonl"
+ wrapped = node._fit_axis_measurement
+ axes = [
+ np.asarray([0.0, 0.0, 1.0]),
+ np.asarray(
+ [
+ 0.0,
+ math.sin(math.radians(1.1)),
+ math.cos(math.radians(1.1)),
+ ]
+ ),
+ np.asarray([0.0, 0.0, 1.0]),
+ np.asarray([0.0, 0.0, 1.0]),
+ ]
+
+ def spread_axis(joint_name, cycle):
+ return replace(
+ wrapped(joint_name, cycle),
+ axis_common_xyz=axes[cycle],
+ radial_rms_m=0.0001,
+ pose_axis_line_rms_m=0.0001,
+ plane_rms_m=0.0001,
+ rotation_circle_axis_difference_rad=0.0,
+ )
+
+ node._fit_axis_measurement = spread_axis
+ failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
+ node, replace(multiview, joints=(name,)), include_view_validity=False
+ )
+
+ assert not [
+ failure
+ for failure in failures
+ if failure["metric"] == "axis_cycle_difference_deg"
+ ]
+ diagnostics = [
+ json.loads(line)
+ for line in node.raw_path.read_text().splitlines()
+ ]
+ assert any(
+ row.get("kind") == "validation_only_quality_diagnostic"
+ and row.get("metric") == "axis_cycle_difference_deg"
+ and row.get("actual") == 1.1
+ and row.get("reference_limit") == 0.75
+ for row in diagnostics
+ )
+
+
def test_side_alias_skips_pose_line_rms_gate(tmp_path) -> None:
records = _image_cycle_records([math.radians(47.0)] * 3)
multiview = next(
@@ -3043,6 +4359,35 @@ def test_task_validity_gate_fails_on_bad_task_counters() -> None:
assert validity_failure["task_total_frames"] == 1000
+def test_right_19_task_validity_is_diagnostic_after_complete_sweeps() -> None:
+ node, _legacy_spec = _task_validity_node(
+ task_valid=650, task_total=1000, window=0.65
+ )
+ node.profile = RIGHT_19_HAND_PROFILE
+ node.zero_profile = get_zero_calibration_profile(
+ "right", "g20_right_19"
+ )
+ node.records_by_joint["thumb_cmc_pitch"] = _image_cycle_records(
+ [math.radians(47.0)] * 3,
+ command_step=1,
+ )
+ spec = next(
+ item
+ for item in RIGHT_19_HAND_PROFILE.sweep_specs
+ if item.motor_index == 0
+ )
+
+ failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
+ node, spec
+ )
+
+ assert not [
+ failure
+ for failure in failures
+ if failure["metric"] == "tag_valid_rate_percent"
+ ]
+
+
def test_task_validity_gate_falls_back_to_window_without_counters() -> None:
node, spec = _task_validity_node(
task_valid=0, task_total=0, window=0.99
diff --git a/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py b/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py
index 02cf76a..d27c6cc 100644
--- a/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py
+++ b/src/g20_thumb_apriltag_calibration/test/test_urdf_zero.py
@@ -16,13 +16,18 @@ from g20_thumb_apriltag_calibration.urdf_zero import (
DIRECT_ZERO_JOINTS,
INHERITED_ZERO_JOINTS,
JointAxisMeasurement,
+ PalmOrientationMeasurement,
UrdfKinematicModel,
_angles_from_state,
_zero_sensitive_axis_error_rad,
+ anchor_right_19_mechanical_endpoint_curves,
baseline_hysteresis_by_cycle_rad,
- derive_right_19_flexion_endpoint_offsets,
+ derive_right_19_mechanical_endpoint_offsets,
fit_joint_axis_measurement,
+ fit_partial_palm_orientation_measurement,
+ fit_partial_palm_orientation_measurements,
fit_rotation_joint_curve,
+ select_cross_view_roll_direction_source,
solve_urdf_zero_offsets,
get_zero_calibration_profile,
write_zero_corrected_urdf,
@@ -49,6 +54,30 @@ RIGHT_SOURCE_URDF = REPOSITORY / (
)
+def test_cross_view_roll_direction_is_selected_once_by_group_consensus() -> None:
+ # Session 20260824_171302: the side residual in cycle 3 was 5.037 deg,
+ # just outside the 5 deg cone gate, while the other three side cycles
+ # passed and every front cycle was near 8 deg. Per-cycle selection mixed
+ # three side axes with one front axis and fabricated a 3.24 deg spread.
+ source = select_cross_view_roll_direction_source(
+ [math.radians(value) for value in (8.0078, 8.1710, 8.0712, 8.0236)],
+ [math.radians(value) for value in (4.9043, 4.9136, 5.0374, 4.9389)],
+ math.radians(5.0),
+ )
+
+ assert source == "secondary"
+
+
+def test_cross_view_roll_direction_keeps_primary_without_consensus() -> None:
+ source = select_cross_view_roll_direction_source(
+ [math.radians(value) for value in (5.2, 4.8, 5.1, 4.9)],
+ [math.radians(value) for value in (4.7, 5.3, 4.8, 5.2)],
+ math.radians(5.0),
+ )
+
+ assert source == "primary"
+
+
def test_zero_sensitive_axis_error_ignores_fixed_cone_angle_mismatch():
parent = np.asarray([0.0, 0.0, 1.0])
predicted = np.asarray([1.0, 0.0, 0.0])
@@ -588,9 +617,12 @@ def _solve_synthetic_offsets(
inject_secondary_root_axis_bias_degrees: float = 0.0,
inject_secondary_root_point_bias_m: float = 0.0,
inject_observer_cone_bias_degrees: float = 0.0,
+ maximum_systematic_axis_cone_bias_degrees: float | None = None,
pose_axis_line_rms_by_joint_m: dict[str, float] | None = None,
joint_maximum_offset_degrees: dict[str, float] | None = None,
validation_offset_bias_degrees: dict[str, float] | None = None,
+ base_euler_xyz_rad: tuple[float, float, float] = (0.5, -0.4, 0.8),
+ base_translation_xyz_m: tuple[float, float, float] = (0.31, -0.19, 0.72),
):
hand = get_hand_calibration_profile(side, layout_id)
zero = get_zero_calibration_profile(side, layout_id)
@@ -620,12 +652,23 @@ def _solve_synthetic_offsets(
for name, value in zip(zero.direct_zero_joints, offset_degrees)
}
model = UrdfKinematicModel(source)
- base_rotation = Rotation.from_euler("xyz", [0.5, -0.4, 0.8])
- base_translation = np.asarray([0.31, -0.19, 0.72])
+ base_rotation = Rotation.from_euler("xyz", base_euler_xyz_rad)
+ base_translation = np.asarray(base_translation_xyz_m)
measurements: list[JointAxisMeasurement] = []
+ palm_orientation_measurements: list[PalmOrientationMeasurement] = []
validation_cycle = 3 if hand.layout_id == "g20_right_19" else 2
training_cycles = tuple(range(validation_cycle))
for cycle in range(validation_cycle + 1):
+ cycle_offsets = dict(offsets)
+ if cycle == validation_cycle:
+ cycle_offsets.update(
+ {
+ name: cycle_offsets[name] + math.radians(value)
+ for name, value in (
+ validation_offset_bias_degrees or {}
+ ).items()
+ }
+ )
for joint in zero.axis_joints:
state = list(baseline)
if joint == "thumb_cmc_yaw":
@@ -636,16 +679,6 @@ def _solve_synthetic_offsets(
motor_by_joint=motor_by_joint,
inherited_zero_joints=zero.inherited_zero_joints,
)
- cycle_offsets = dict(offsets)
- if cycle == validation_cycle:
- cycle_offsets.update(
- {
- name: cycle_offsets[name] + math.radians(value)
- for name, value in (
- validation_offset_bias_degrees or {}
- ).items()
- }
- )
axis, point = model.axis_line(
joint, zero_offsets=cycle_offsets, joint_angles=angles
)
@@ -734,9 +767,36 @@ def _solve_synthetic_offsets(
).get(joint, 0.0),
)
)
+ for source_joint, model_joint in (
+ hand.palm_orientation_sources or {}
+ ).items():
+ state = list(baseline)
+ angles = _angles_from_state(
+ state,
+ curves=curves,
+ motor_by_joint=motor_by_joint,
+ inherited_zero_joints=zero.inherited_zero_joints,
+ )
+ axis, _ = model.axis_line(
+ model_joint,
+ zero_offsets=cycle_offsets,
+ joint_angles=angles,
+ )
+ palm_orientation_measurements.append(
+ PalmOrientationMeasurement(
+ source_joint=source_joint,
+ model_joint=model_joint,
+ cycle=cycle,
+ axis_common_xyz=tuple(base_rotation.apply(axis)),
+ condition_state_u8=tuple(state),
+ observed_arc_rad=math.radians(45.0),
+ rotation_orthogonal_rms_rad=math.radians(0.1),
+ )
+ )
result = solve_urdf_zero_offsets(
source_urdf=source,
measurements=measurements,
+ palm_orientation_measurements=palm_orientation_measurements,
curves=curves,
motor_by_joint=motor_by_joint,
hand_type=side,
@@ -747,6 +807,13 @@ def _solve_synthetic_offsets(
},
training_cycles=training_cycles,
validation_cycle=validation_cycle,
+ maximum_systematic_axis_cone_bias_rad=(
+ None
+ if maximum_systematic_axis_cone_bias_degrees is None
+ else math.radians(
+ maximum_systematic_axis_cone_bias_degrees
+ )
+ ),
)
return zero, result
@@ -788,9 +855,9 @@ def test_right_19_solver_recovers_visual_targets_when_no_endpoint_anchor_is_supp
expected, abs=0.05
)
assert math.degrees(result.all_active_offsets_rad["thumb_mcp"]) == pytest.approx(
- 0.0, abs=0.05
+ -1.5, abs=0.05
)
- assert zero.fixed_direct_zero_offsets_rad == {"thumb_mcp": 0.0}
+ assert zero.fixed_direct_zero_offsets_rad == {}
assert result.training_cycles == (0, 1, 2)
assert result.validation_cycle == 3
assert all(
@@ -803,7 +870,203 @@ def test_right_19_solver_recovers_visual_targets_when_no_endpoint_anchor_is_supp
assert set(result.offset_covariance_rad2) == set(zero.direct_zero_joints)
-def test_right_19_flexion_zero_is_derived_from_measured_contact_endpoint() -> None:
+def _partial_orientation_records(
+ *,
+ tag_mount: Rotation,
+ common_rotation: Rotation,
+ maximum_angle_deg: float = 30.0,
+ tag_offset_xyz_m: tuple[float, float, float] = (0.01, 0.02, -0.015),
+) -> list[dict[str, object]]:
+ axis_parent = np.asarray([0.0, 1.0, 0.0])
+ parent_pose = {
+ "translation_xyz_m": [0.2, -0.1, 0.7],
+ "quaternion_xyzw": list(common_rotation.as_quat()),
+ }
+ records: list[dict[str, object]] = []
+ commands = tuple(range(255, 174, -4))
+ for direction, ordered in (
+ ("decreasing", commands),
+ ("increasing", tuple(reversed(commands))),
+ ):
+ for command in ordered:
+ fraction = (255 - command) / (255 - commands[-1])
+ angle = math.radians(maximum_angle_deg) * fraction
+ relative = (
+ Rotation.from_rotvec(axis_parent * angle) * tag_mount
+ )
+ relative_translation = Rotation.from_rotvec(
+ axis_parent * angle
+ ).apply(tag_offset_xyz_m)
+ state = [255.0] * 20
+ state[1] = float(command)
+ records.append(
+ {
+ "cycle": 0,
+ "direction": direction,
+ "command_u8": command,
+ "relative_quaternion_xyzw": list(relative.as_quat()),
+ "relative_translation_xyz_m": list(
+ relative_translation
+ ),
+ "parent_pose_common": parent_pose,
+ "state_u8": state,
+ }
+ )
+ return records
+
+
+def test_partial_palm_direction_ignores_fixed_tag_mount_pose() -> None:
+ common_rotation = Rotation.from_euler("xyz", [0.35, -0.2, 0.6])
+ first = fit_partial_palm_orientation_measurement(
+ "index_mcp_pitch_front_axis",
+ "index_mcp_pitch",
+ _partial_orientation_records(
+ tag_mount=Rotation.from_euler("xyz", [0.1, 0.2, -0.4]),
+ common_rotation=common_rotation,
+ ),
+ cycle=0,
+ zero_command_u8=255,
+ )
+ second = fit_partial_palm_orientation_measurement(
+ "index_mcp_pitch_front_axis",
+ "index_mcp_pitch",
+ _partial_orientation_records(
+ tag_mount=Rotation.from_euler("xyz", [-0.7, 0.45, 0.9]),
+ common_rotation=common_rotation,
+ tag_offset_xyz_m=(-0.035, 0.008, 0.041),
+ ),
+ cycle=0,
+ zero_command_u8=255,
+ )
+ expected = common_rotation.apply([0.0, 1.0, 0.0])
+
+ assert abs(np.dot(first.axis_common_xyz, expected)) == pytest.approx(
+ 1.0, abs=1.0e-8
+ )
+ assert abs(
+ np.dot(first.axis_common_xyz, second.axis_common_xyz)
+ ) == pytest.approx(1.0, abs=1.0e-8)
+
+
+def test_partial_palm_direction_is_not_weighted_by_dwell_frame_count() -> None:
+ records = _partial_orientation_records(
+ tag_mount=Rotation.from_euler("xyz", [0.2, -0.1, 0.3]),
+ common_rotation=Rotation.identity(),
+ )
+ baseline = fit_partial_palm_orientation_measurement(
+ "index_mcp_pitch_front_axis",
+ "index_mcp_pitch",
+ records,
+ cycle=0,
+ zero_command_u8=255,
+ )
+ source = next(
+ record
+ for record in records
+ if record["direction"] == "decreasing"
+ and record["command_u8"] == 223
+ )
+ biased = dict(source)
+ biased["relative_quaternion_xyzw"] = list(
+ (
+ Rotation.from_rotvec([math.radians(2.0), 0.0, 0.0])
+ * Rotation.from_quat(source["relative_quaternion_xyzw"])
+ ).as_quat()
+ )
+ fitted = fit_partial_palm_orientation_measurement(
+ "index_mcp_pitch_front_axis",
+ "index_mcp_pitch",
+ [*records, *([biased] * 200)],
+ cycle=0,
+ zero_command_u8=255,
+ )
+
+ difference = math.acos(
+ abs(
+ float(
+ np.clip(
+ np.dot(baseline.axis_common_xyz, fitted.axis_common_xyz),
+ -1.0,
+ 1.0,
+ )
+ )
+ )
+ )
+ assert difference < math.radians(0.25)
+
+
+def test_partial_palm_direction_rejects_too_short_visible_arc() -> None:
+ records = _partial_orientation_records(
+ tag_mount=Rotation.identity(),
+ common_rotation=Rotation.identity(),
+ maximum_angle_deg=5.0,
+ )
+
+ with pytest.raises(ValueError, match="visible rotation arc"):
+ fit_partial_palm_orientation_measurement(
+ "index_mcp_pitch_front_axis",
+ "index_mcp_pitch",
+ records,
+ cycle=0,
+ zero_command_u8=255,
+ )
+
+
+def test_partial_palm_direction_uses_three_of_four_visible_sources() -> None:
+ sources = {
+ f"{finger}_mcp_pitch_front_axis": f"{finger}_mcp_pitch"
+ for finger in ("index", "middle", "ring", "pinky")
+ }
+ records = {
+ source: _partial_orientation_records(
+ tag_mount=Rotation.from_euler(
+ "xyz", [0.1 * index, -0.2, 0.3]
+ ),
+ common_rotation=Rotation.identity(),
+ maximum_angle_deg=(5.0 if index == 3 else 30.0),
+ )
+ for index, source in enumerate(sources)
+ }
+
+ fitted, rejected = fit_partial_palm_orientation_measurements(
+ sources=sources,
+ records_by_joint=records,
+ motor_by_source={source: index + 1 for index, source in enumerate(sources)},
+ baseline_command_u8=(255,) * 20,
+ cycles=(0,),
+ minimum_sources=3,
+ )
+
+ assert len(fitted) == 3
+ assert len(rejected) == 1
+ assert next(iter(rejected)).startswith("pinky_mcp_pitch_front_axis")
+
+
+def test_right_19_offsets_are_invariant_to_rigid_hand_repositioning() -> None:
+ injected = [
+ 2.0, -3.0, 4.0, -1.5,
+ 1.0, -1.0, 0.7,
+ 0.8, -0.7, -0.8,
+ -0.5, 0.6, 0.9,
+ 1.1, -1.0, -0.6,
+ ]
+ _, first = _solve_synthetic_offsets(
+ "right", injected, layout_id="g20_right_19"
+ )
+ _, moved = _solve_synthetic_offsets(
+ "right",
+ injected,
+ layout_id="g20_right_19",
+ base_euler_xyz_rad=(-0.25, 0.55, -0.35),
+ base_translation_xyz_m=(-0.12, 0.28, 0.91),
+ )
+
+ assert moved.direct_offsets_rad == pytest.approx(
+ first.direct_offsets_rad, abs=1.0e-8
+ )
+
+
+def test_right_19_only_verified_contact_zeros_use_mechanical_endpoints() -> None:
hand = get_hand_calibration_profile("right", "g20_right_19")
curves = {
name: _synthetic_curve(255, math.radians(50.0))
@@ -816,16 +1079,35 @@ def test_right_19_flexion_zero_is_derived_from_measured_contact_endpoint() -> No
curves[f"{finger}_pip"] = _synthetic_curve(
255, math.radians(103.0)
)
+ curves["thumb_mcp"] = _synthetic_curve(255, math.radians(70.0))
+ curves["thumb_cmc_roll"] = _synthetic_curve(
+ 255, math.radians(76.0)
+ )
+ curves["thumb_cmc_yaw"] = _synthetic_curve(
+ 255, math.radians(91.0)
+ )
+ curves["thumb_cmc_pitch"] = _synthetic_curve(
+ 255, math.radians(47.0)
+ )
- offsets = derive_right_19_flexion_endpoint_offsets(
+ offsets = derive_right_19_mechanical_endpoint_offsets(
RIGHT_SOURCE_URDF, curves
)
assert set(offsets) == {
- f"{finger}_{suffix}"
- for finger in ("index", "middle", "ring", "pinky")
- for suffix in ("mcp_pitch", "pip")
+ "thumb_mcp",
+ *(
+ f"{finger}_{suffix}"
+ for finger in ("index", "middle", "ring", "pinky")
+ for suffix in ("mcp_pitch", "pip")
+ ),
}
+ assert math.degrees(offsets["thumb_mcp"]) == pytest.approx(
+ math.degrees(1.25) - 70.0
+ )
+ assert not {
+ "thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch"
+ }.intersection(offsets)
assert math.degrees(offsets["index_mcp_pitch"]) == pytest.approx(
math.degrees(1.22) - 71.0
)
@@ -834,6 +1116,100 @@ def test_right_19_flexion_zero_is_derived_from_measured_contact_endpoint() -> No
)
+def _settled_endpoint_records(
+ travel_rad: float,
+ *,
+ increasing_travel_rad: float | None = None,
+) -> list[dict[str, object]]:
+ fixed_mounting = Rotation.from_euler("xyz", [0.7, -0.4, 1.1])
+ mounted_axis = Rotation.from_euler("xyz", [-0.3, 0.8, 0.2]).apply(
+ [0.0, 0.0, 1.0]
+ )
+ result: list[dict[str, object]] = []
+ for direction, branch_travel in (
+ ("decreasing", travel_rad),
+ (
+ "increasing",
+ travel_rad
+ if increasing_travel_rad is None
+ else increasing_travel_rad,
+ ),
+ ):
+ endpoint = fixed_mounting * Rotation.from_rotvec(
+ mounted_axis * branch_travel
+ )
+ for command, rotation in ((0, endpoint), (255, fixed_mounting)):
+ result.append(
+ {
+ "direction": direction,
+ "requested_command_u8": command,
+ "relative_quaternion_xyzw": rotation.as_quat().tolist(),
+ }
+ )
+ return result
+
+
+def test_right_19_endpoint_curve_scale_uses_direct_rigid_rotation() -> None:
+ hand = get_hand_calibration_profile("right", "g20_right_19")
+ names = {
+ "thumb_mcp",
+ *(
+ f"{finger}_{suffix}"
+ for finger in ("index", "middle", "ring", "pinky")
+ for suffix in ("mcp_pitch", "pip")
+ ),
+ }
+ projected_travel = math.radians(105.832)
+ direct_travel = math.radians(103.820)
+ curves = {
+ name: _synthetic_curve(255, projected_travel)
+ for name in hand.measured_joints
+ }
+ records = {
+ name: _settled_endpoint_records(direct_travel) for name in names
+ }
+
+ anchored = anchor_right_19_mechanical_endpoint_curves(curves, records)
+
+ assert curves["middle_pip"].angle_rad[0] == pytest.approx(
+ projected_travel
+ )
+ for name in names:
+ assert anchored[name].angle_rad[0] == pytest.approx(direct_travel)
+ assert anchored[name].angle_rad[255] == pytest.approx(0.0)
+ assert anchored[name].circle[
+ "mechanical_endpoint_direct_travel_rad"
+ ] == pytest.approx(direct_travel)
+ assert anchored["thumb_cmc_pitch"] is curves["thumb_cmc_pitch"]
+
+
+def test_right_19_endpoint_curve_scale_rejects_direction_disagreement() -> None:
+ hand = get_hand_calibration_profile("right", "g20_right_19")
+ names = {
+ "thumb_mcp",
+ *(
+ f"{finger}_{suffix}"
+ for finger in ("index", "middle", "ring", "pinky")
+ for suffix in ("mcp_pitch", "pip")
+ ),
+ }
+ curves = {
+ name: _synthetic_curve(255, math.radians(103.0))
+ for name in hand.measured_joints
+ }
+ records = {
+ name: _settled_endpoint_records(math.radians(103.0))
+ for name in names
+ }
+ records["middle_pip"] = _settled_endpoint_records(
+ math.radians(103.0),
+ increasing_travel_rad=math.radians(104.5),
+ )
+
+ with pytest.raises(ValueError, match="endpoint directions disagree"):
+ anchor_right_19_mechanical_endpoint_curves(curves, records)
+
+
def test_right_15_finger_roll_limit_applies_to_independent_deviation() -> None:
# A shared electrical centre belongs to the four-motor common datum; the
# strict 3 deg assembly guard applies to finger-to-finger deviations.
@@ -919,6 +1295,19 @@ def test_right_19_holdout_never_changes_frozen_training_offsets() -> None:
validation_offset_bias_degrees={"thumb_cmc_roll": 2.0},
)
+ finger_rolls = tuple(
+ name
+ for name in zero.direct_zero_joints
+ if name.endswith("_mcp_roll") and not name.startswith("thumb_")
+ )
+ roll_common = float(
+ np.median(
+ [
+ injected[zero.direct_zero_joints.index(name)]
+ for name in finger_rolls
+ ]
+ )
+ )
assert math.degrees(
result.direct_offsets_rad["thumb_cmc_roll"]
) == pytest.approx(injected[0], abs=0.05)
@@ -1032,12 +1421,14 @@ def test_small_stable_offsets_are_validated_without_rewriting_urdf_zero() -> Non
def test_profiles_do_not_contain_hard_coded_thumb_zero_offsets() -> None:
right = get_zero_calibration_profile("right")
+ right_19 = get_zero_calibration_profile("right", "g20_right_19")
left = get_zero_calibration_profile("left")
assert "thumb_cmc_roll" not in right.fixed_direct_zero_offsets_rad
assert "thumb_cmc_roll" not in right.static_output_zero_offsets_rad
assert "thumb_cmc_roll" not in left.fixed_direct_zero_offsets_rad
assert "thumb_cmc_roll" not in left.static_output_zero_offsets_rad
+ assert "thumb_mcp" not in right_19.fixed_direct_zero_offsets_rad
def test_reference_finger_roll_static_zero_is_fixed_to_upright_cad() -> None:
@@ -1091,18 +1482,24 @@ def test_root_line_depth_bias_does_not_change_thumb_roll_zero() -> None:
assert result.direct_offsets_rad[f"{zero.reference_finger}_mcp_roll"] == 0.0
-def test_thumb_mcp_static_phase_bias_cannot_override_original_cad_zero() -> None:
- offsets = [2.0, -3.0, 4.0, -40.0, 1.0, -1.0, 2.0]
+def test_thumb_mcp_is_not_hard_coded_to_original_cad_zero() -> None:
+ offsets = [2.0, -3.0, 4.0, -40.0, 1.0, -1.0, 2.0, *([0.0] * 9)]
_, result = _solve_synthetic_offsets(
"right",
offsets,
+ layout_id="g20_right_19",
joint_maximum_offset_degrees={"thumb_mcp": 45.0},
)
assert result.passed is True
- assert result.direct_offsets_rad["thumb_mcp"] == pytest.approx(0.0)
- assert result.cycle_offsets_rad["thumb_mcp"] == pytest.approx((0.0, 0.0))
- assert "thumb_ip" not in result.validation_error_by_joint_rad
+ assert math.degrees(result.direct_offsets_rad["thumb_mcp"]) == pytest.approx(
+ -40.0, abs=0.05
+ )
+ assert all(
+ math.degrees(value) == pytest.approx(-40.0, abs=0.05)
+ for value in result.cycle_offsets_rad["thumb_mcp"]
+ )
+ assert result.validation_error_by_joint_rad["thumb_ip"] < math.radians(0.05)
for name in ("thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch"):
assert abs(math.degrees(result.direct_offsets_rad[name])) <= 20.0
for name in ("pinky_mcp_pitch", "pinky_pip"):
@@ -1149,6 +1546,66 @@ def test_zero_solver_rejects_axis_cone_geometry_that_a_zero_cannot_fix() -> None
)
+def test_right_19_audits_stable_cross_view_cone_bias_without_retry() -> None:
+ injected = [0.0] * 16
+ _, result = _solve_synthetic_offsets(
+ "right",
+ injected,
+ layout_id="g20_right_19",
+ inject_observer_cone_bias_degrees=8.0,
+ maximum_systematic_axis_cone_bias_degrees=15.0,
+ )
+
+ assert result.passed is True
+ assert math.degrees(
+ result.axis_cone_mismatch_by_joint_rad["thumb_cmc_yaw"]
+ ) == pytest.approx(8.0)
+ assert result.axis_cone_bias_classification_by_joint[
+ "thumb_cmc_yaw"
+ ] == "stable_cross_view_or_planar_pnp_bias"
+
+
+def test_right_19_still_rejects_gross_cross_view_cone_mismatch() -> None:
+ injected = [0.0] * 16
+ _, result = _solve_synthetic_offsets(
+ "right",
+ injected,
+ layout_id="g20_right_19",
+ inject_observer_cone_bias_degrees=16.0,
+ maximum_systematic_axis_cone_bias_degrees=15.0,
+ )
+
+ assert result.passed is False
+ assert result.failure_reasons["thumb_cmc_yaw"] == (
+ "zero_axis_cone_mismatch_too_large"
+ )
+
+
+def test_right_19_finger_roll_common_gauge_is_removed_before_limits() -> None:
+ zero = get_zero_calibration_profile("right", "g20_right_19")
+ injected = [0.0] * len(zero.direct_zero_joints)
+ deviations = {
+ "index_mcp_roll": -0.4,
+ "middle_mcp_roll": 0.7,
+ "ring_mcp_roll": 0.1,
+ "pinky_mcp_roll": -0.1,
+ }
+ for name, deviation in deviations.items():
+ injected[zero.direct_zero_joints.index(name)] = 18.0 + deviation
+
+ _, result = _solve_synthetic_offsets(
+ "right", injected, layout_id="g20_right_19"
+ )
+
+ assert result.passed is True
+ common = float(np.median(list(deviations.values())))
+ for name, deviation in deviations.items():
+ expected = deviation - common
+ assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx(
+ expected, abs=0.05
+ )
+
+
def test_zero_solver_rejects_unreliable_parallel_axis_line_phase() -> None:
_, result = _solve_synthetic_offsets(
"right",