feat: ForceStreamSession 覆盖 field 与 FN-only 标定(T06b)
新增 measurement_requirements,告知调用方 field 模型需要同帧场数据。补充标量、field、FN-only 三类标定与旧 ForceSession 的数值等价测试,以及标定与帧不匹配的情况:field 缺 context 时构造失败,基线帧缺 field 时零点失败,ready 后缺 field 的帧单独标为 invalid_input。与改动前的旧会话基线逐位一致。回归 381 项、lint 通过。
This commit is contained in:
@@ -1,14 +1,18 @@
|
|||||||
# 新力侧会话(T06a)
|
# 新力侧会话(T06a/T06b)
|
||||||
|
|
||||||
本文件是单路 SensorStream 计划 T06a 的 SDK 合同:新的力侧会话直接消费 T05 的
|
本文件是单路 SensorStream 计划 T06a/T06b 的 SDK 合同:新的力侧会话直接消费 T05 的
|
||||||
`ImageMeasurement`(T03 `MeasurementFrame` 头 + 同帧 raw/field),参考版本取帧上的
|
`ImageMeasurement`(T03 `MeasurementFrame` 头 + 同帧 raw/field),参考版本取帧上的
|
||||||
`reference_generation`。旧 `ForceSession` 仍为 App 保留到 T09/T10 迁移,本会话不提供
|
`reference_generation`。旧 `ForceSession` 仍为 App 保留到 T09/T10 迁移,本会话不提供
|
||||||
`process_measurement`、`request_zero_external_reset`、`tracker_reset_seq` 等旧外部复位接口。
|
`process_measurement`、`request_zero_external_reset`、`tracker_reset_seq` 等旧外部复位接口。
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
- `ForceStreamSession(sensor_family=..., calibration=None, settings=None)`:构造;T06a 支持
|
- `ForceStreamSession(sensor_family=..., calibration=None, settings=None)`:构造。T06a 支持
|
||||||
无标定与 schema2 标量曲线,`settings` 为 `OutputSettings`(None 用旧默认)。
|
无标定与 schema2 标量曲线;T06b 补充 schema3 field 模型(`hhd_normal_l1_field/1`,需要
|
||||||
|
`context` 与同帧 field)和 FN-only 全局增益模型(`outputs=["fn"]`);`settings` 为
|
||||||
|
`OutputSettings`(None 用旧默认)。
|
||||||
|
- `measurement_requirements`:`(want_field, want_contact)`,告诉 T05 调用方是否需要把
|
||||||
|
`field` include 进 `ImageMeasurement`(field 模型为 `(True, False)`,标量/FN-only 为 `(False, False)`)。
|
||||||
- `request_zero(reference_id=..., declared_unloaded=True, expected_reference_generation=版本, max_gap_s=None, max_window_s=None)`:
|
- `request_zero(reference_id=..., declared_unloaded=True, expected_reference_generation=版本, max_gap_s=None, max_window_s=None)`:
|
||||||
为指定参考版本确认无载归零;可选窗口上限。
|
为指定参考版本确认无载归零;可选窗口上限。
|
||||||
- `process(measurement) -> ForceStreamOutput`:消费一个 T05 `ImageMeasurement`,返回逐帧结果
|
- `process(measurement) -> ForceStreamOutput`:消费一个 T05 `ImageMeasurement`,返回逐帧结果
|
||||||
@@ -34,11 +38,15 @@
|
|||||||
`reference_generation`;`quality` 为 `ok` / `uncalibrated` / `zero_pending` / `duplicate` /
|
`reference_generation`;`quality` 为 `ok` / `uncalibrated` / `zero_pending` / `duplicate` /
|
||||||
`late` / `session_changed` / `version_changed` / `window_failed` / `invalid`。
|
`late` / `session_changed` / `version_changed` / `window_failed` / `invalid`。
|
||||||
迟到、无效和带质量标记的结果不会被丢弃;拒绝帧仍保留 raw 特征。
|
迟到、无效和带质量标记的结果不会被丢弃;拒绝帧仍保留 raw 特征。
|
||||||
- **数值等价与滤波。** T06a 的零点/标定换算/读数滤波复用已发布算法;同一输入、无复位、
|
- **数值等价与滤波。** 零点/标定换算/读数滤波复用已发布算法;同一输入、无复位、同一标定时
|
||||||
同一标定时与旧 `ForceSession` 逐帧相等(测试覆盖标量曲线、无标定、median+lowpass 滤波器)。
|
与旧 `ForceSession` 逐帧相等(T06a 覆盖标量曲线、无标定、median+lowpass 滤波器;T06b 覆盖
|
||||||
|
`context` 与同帧 field)和 FN-only 全局增益模型(`outputs=["fn"]`);`settings` 为
|
||||||
固定设计帧率与显式滤波切换属于 T07,不在本会话实现。
|
固定设计帧率与显式滤波切换属于 T07,不在本会话实现。
|
||||||
|
- **三类标定与不匹配。** 标量曲线、field、FN-only 各有有效测试;无标定会话保持 raw 与
|
||||||
|
`uncalibrated` 状态;field 模型缺 `context` 时构造报 `CalibrationError`,基线帧缺 field 或
|
||||||
|
模型拒绝帧时零点失败并给出 `invalid_input`/原因;FN-only 的 FX/FY 永远保持 NaN +
|
||||||
|
`uncalibrated`,不冒充三轴标定。
|
||||||
- **线程与只读。** 一个实例只属于一个力侧线程;输入数组只读,输出只含标量/状态/不可变进度。
|
- **线程与只读。** 一个实例只属于一个力侧线程;输入数组只读,输出只含标量/状态/不可变进度。
|
||||||
T06b 在本合同上补充 field 模型、FN-only 全局增益模型与三类标定的完整等价测试。
|
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
|
|
||||||
@@ -64,8 +72,8 @@ assert row.zero.phase in ("resetting", "collecting")
|
|||||||
|
|
||||||
## 后续
|
## 后续
|
||||||
|
|
||||||
T06b:field(`hhd_normal_l1_field/1`)与 FN-only(schema3 `outputs=["fn"]`)标定、标定不匹配、
|
T06b 已交付标量/field/FN-only 三类标定与旧 ForceSession 的完整数值等价;spatial_gain/接触
|
||||||
field 加权基线与标量力域基线的分离,以及对照旧 ForceSession 的完整五场景数值等价。
|
映射模型不在 T06b 验收范围,需要时另立任务。
|
||||||
T07 才改变输出滤波的帧率与切换语义;T09/T10 迁移 App 后删除旧 `ForceSession`。
|
T07 才改变输出滤波的帧率与切换语义;T09/T10 迁移 App 后删除旧 `ForceSession`。
|
||||||
另一项后续任务:删除旧 `ForceSession` 之前,先把零点、标定、读数引擎从旧类中抽出来,
|
另一项后续任务:删除旧 `ForceSession` 之前,先把零点、标定、读数引擎从旧类中抽出来,
|
||||||
避免新会话依赖待删除的类型(T06b 或 T09 之前)。
|
避免新会话依赖待删除的类型(T06b 或 T09 之前)。
|
||||||
|
|||||||
@@ -194,6 +194,17 @@ class ForceStreamSession:
|
|||||||
def last_output(self) -> ForceStreamOutput | None:
|
def last_output(self) -> ForceStreamOutput | None:
|
||||||
return self._last_output
|
return self._last_output
|
||||||
|
|
||||||
|
@property
|
||||||
|
def measurement_requirements(self) -> tuple[bool, bool]:
|
||||||
|
"""``(want_field, want_contact)`` the installed calibration model asks for.
|
||||||
|
|
||||||
|
A field model (``hhd_normal_l1_field/1``) asks for the same-frame field, so
|
||||||
|
a T05 caller must include ``field`` in the ``ImageMeasurement``; a
|
||||||
|
scalar/FN-only model asks for neither. The engine's answer is reused
|
||||||
|
instead of a second interpretation of the model.
|
||||||
|
"""
|
||||||
|
return self._engine.measurement_requirements
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reference_generation(self) -> int | None:
|
def reference_generation(self) -> int | None:
|
||||||
"""The reference generation this session currently accepts frames for."""
|
"""The reference generation this session currently accepts frames for."""
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import importlib.util
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
from orisys.force_stream_session import ForceStreamOutput, ForceStreamSession
|
from orisys.force_stream_session import ForceStreamOutput, ForceStreamSession
|
||||||
from orisys.image_pipeline import ImageMeasurement
|
from orisys.image_pipeline import ImageMeasurement
|
||||||
@@ -44,6 +45,8 @@ def _measurement(
|
|||||||
sensor_id: str = "TEST-1",
|
sensor_id: str = "TEST-1",
|
||||||
capture_time_ns: int | None = None,
|
capture_time_ns: int | None = None,
|
||||||
source_frame_id: int | None = None,
|
source_frame_id: int | None = None,
|
||||||
|
field: dict | None = None,
|
||||||
|
valid_mask: np.ndarray | None = None,
|
||||||
) -> ImageMeasurement:
|
) -> ImageMeasurement:
|
||||||
stamp = FrameStamp(
|
stamp = FrameStamp(
|
||||||
sensor_id=sensor_id,
|
sensor_id=sensor_id,
|
||||||
@@ -61,7 +64,14 @@ def _measurement(
|
|||||||
source_dt_s=None if frame_id == 1 else FRAME_NS / 1e9,
|
source_dt_s=None if frame_id == 1 else FRAME_NS / 1e9,
|
||||||
reference_generation=version,
|
reference_generation=version,
|
||||||
)
|
)
|
||||||
return ImageMeasurement(frame=frame, force_raw=raw, torque=0.0, flow=None)
|
return ImageMeasurement(
|
||||||
|
frame=frame,
|
||||||
|
force_raw=raw,
|
||||||
|
torque=0.0,
|
||||||
|
flow=None,
|
||||||
|
field=field,
|
||||||
|
valid_mask=valid_mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _raw_for(frame_id: int) -> tuple[float, float, float]:
|
def _raw_for(frame_id: int) -> tuple[float, float, float]:
|
||||||
@@ -419,3 +429,243 @@ def test_version_change_branch_frame_is_registered_for_duplicate_detection() ->
|
|||||||
assert repeat.duplicate is True
|
assert repeat.duplicate is True
|
||||||
assert repeat.quality == "duplicate"
|
assert repeat.quality == "duplicate"
|
||||||
assert new.zero == after_change, "duplicate must not be processed again"
|
assert new.zero == after_change, "duplicate must not be processed again"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- T06b: field and FN-only calibration
|
||||||
|
def _field_payload_for(frame_id: int):
|
||||||
|
return helpers._field_payload(0.5 + 0.05 * frame_id, 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_models(tmp_path):
|
||||||
|
document, context = helpers._field_document(tmp_path)
|
||||||
|
old_model = helpers.parse_model(document, tmp_path / "field-calibration-old.json")
|
||||||
|
new_model = helpers.parse_model(document, tmp_path / "field-calibration-new.json")
|
||||||
|
return old_model, new_model, context
|
||||||
|
|
||||||
|
|
||||||
|
def _field_measurement(frame_id: int, *, include_field: bool = True, mask=None):
|
||||||
|
return _measurement(
|
||||||
|
frame_id,
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
field=_field_payload_for(frame_id) if include_field else None,
|
||||||
|
valid_mask=np.ones((4, 4), dtype=bool) if mask is None else mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field_old(tmp_path):
|
||||||
|
old_model, new_model, context = _field_models(tmp_path)
|
||||||
|
source = helpers.FakeMeasurementSource(
|
||||||
|
lambda frame_id: (0.0, 0.0, 0.0),
|
||||||
|
field_for=_field_payload_for,
|
||||||
|
output_mask=np.ones((4, 4), dtype=bool),
|
||||||
|
)
|
||||||
|
session = helpers.ForceSession(
|
||||||
|
source=source,
|
||||||
|
sensor_family="finger",
|
||||||
|
context=context,
|
||||||
|
calibration=old_model,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
return session, source, new_model, context
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_field_with_old(tmp_path) -> None:
|
||||||
|
old, source, new_model, context = _field_old(tmp_path)
|
||||||
|
new = ForceStreamSession(
|
||||||
|
sensor_family="finger",
|
||||||
|
context=context,
|
||||||
|
calibration=new_model,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
assert new.measurement_requirements == (True, False)
|
||||||
|
old.request_zero(reference_id="field-ref", declared_unloaded=True)
|
||||||
|
new.request_zero(
|
||||||
|
reference_id="field-ref",
|
||||||
|
declared_unloaded=True,
|
||||||
|
expected_reference_generation=0,
|
||||||
|
)
|
||||||
|
old_raw, old_newton, old_status = [], [], []
|
||||||
|
new_raw, new_newton, new_status = [], [], []
|
||||||
|
for frame_id in range(1, 41):
|
||||||
|
old_out = helpers.process(old, source, frame_id)
|
||||||
|
new_out = new.process(_field_measurement(frame_id))
|
||||||
|
old_raw.append(np.asarray(old_out.raw))
|
||||||
|
old_newton.append(np.asarray(old_out.newton))
|
||||||
|
old_status.append(list(old_out.measurement.newton_status))
|
||||||
|
new_raw.append(np.asarray(new_out.raw))
|
||||||
|
new_newton.append(np.asarray(new_out.newton))
|
||||||
|
new_status.append(list(new_out.newton_status))
|
||||||
|
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
|
||||||
|
assert np.array_equal(np.stack(old_newton), np.stack(new_newton), equal_nan=True)
|
||||||
|
assert old_status == new_status
|
||||||
|
assert new.zero.phase == "ready"
|
||||||
|
# The field model outputs FN only; shear axes stay explicitly unavailable.
|
||||||
|
assert np.isfinite(new_newton[-1][0])
|
||||||
|
assert np.isnan(new_newton[-1][1]) and np.isnan(new_newton[-1][2])
|
||||||
|
assert new_status[-1][1:] == ["uncalibrated", "uncalibrated"]
|
||||||
|
|
||||||
|
|
||||||
|
def _fn_only_models():
|
||||||
|
context = helpers.build_context(
|
||||||
|
{
|
||||||
|
"camera": {"resolution_stitch": [4, 4]},
|
||||||
|
"tracker": {"dis_mode": 1, "hhd_downsample": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
document = helpers._global_gain_document(context, gains={"fn": 0.01})
|
||||||
|
old_model = helpers.parse_model(document)
|
||||||
|
new_model = helpers.parse_model(document)
|
||||||
|
return old_model, new_model, context
|
||||||
|
|
||||||
|
|
||||||
|
def _fn_raw(frame_id: int) -> tuple[float, float, float]:
|
||||||
|
return (100.0 + frame_id, 7.0, -7.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_fn_only_with_old() -> None:
|
||||||
|
old_model, new_model, context = _fn_only_models()
|
||||||
|
source = helpers.FakeMeasurementSource(_fn_raw)
|
||||||
|
old = helpers.ForceSession(
|
||||||
|
source=source,
|
||||||
|
sensor_family="box",
|
||||||
|
context=context,
|
||||||
|
calibration=old_model,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
new = ForceStreamSession(
|
||||||
|
sensor_family="box",
|
||||||
|
context=context,
|
||||||
|
calibration=new_model,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
assert new.measurement_requirements == (False, False)
|
||||||
|
old.request_zero(reference_id="fn-ref", declared_unloaded=True)
|
||||||
|
new.request_zero(
|
||||||
|
reference_id="fn-ref",
|
||||||
|
declared_unloaded=True,
|
||||||
|
expected_reference_generation=0,
|
||||||
|
)
|
||||||
|
old_raw, old_newton, old_status = [], [], []
|
||||||
|
new_raw, new_newton, new_status = [], [], []
|
||||||
|
for frame_id in range(1, 41):
|
||||||
|
old_out = helpers.process(old, source, frame_id)
|
||||||
|
new_out = new.process(_measurement(frame_id, _fn_raw(frame_id)))
|
||||||
|
old_raw.append(np.asarray(old_out.raw))
|
||||||
|
old_newton.append(np.asarray(old_out.newton))
|
||||||
|
old_status.append(list(old_out.measurement.newton_status))
|
||||||
|
new_raw.append(np.asarray(new_out.raw))
|
||||||
|
new_newton.append(np.asarray(new_out.newton))
|
||||||
|
new_status.append(list(new_out.newton_status))
|
||||||
|
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
|
||||||
|
assert np.array_equal(np.stack(old_newton), np.stack(new_newton), equal_nan=True)
|
||||||
|
assert old_status == new_status
|
||||||
|
assert new.zero.phase == "ready"
|
||||||
|
assert np.isfinite(new_newton[-1][0])
|
||||||
|
assert np.isnan(new_newton[-1][1]) and np.isnan(new_newton[-1][2])
|
||||||
|
assert new_status[-1][1:] == ["uncalibrated", "uncalibrated"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_model_matches_old_session_on_this_platform(tmp_path) -> None:
|
||||||
|
_compare_field_with_old(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fn_only_model_matches_old_session_on_this_platform() -> None:
|
||||||
|
_compare_fn_only_with_old()
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_calibration_types_match_the_old_session_on_this_platform(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
# Same interpreter/platform for both chains: no cross-OS golden comparison.
|
||||||
|
_compare_with_old(helpers.box_cal()) # schema2 scalar curves
|
||||||
|
_compare_field_with_old(tmp_path) # schema3 field model (FN-only)
|
||||||
|
_compare_fn_only_with_old() # schema3 global gain (FN-only)
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_model_requires_context_and_field_frame(tmp_path) -> None:
|
||||||
|
from orisys.calibration import CalibrationError
|
||||||
|
|
||||||
|
old_model, new_model, context = _field_models(tmp_path)
|
||||||
|
with pytest.raises(CalibrationError, match="pipeline context"):
|
||||||
|
ForceStreamSession(sensor_family="finger", calibration=old_model)
|
||||||
|
|
||||||
|
session = ForceStreamSession(
|
||||||
|
sensor_family="finger",
|
||||||
|
context=context,
|
||||||
|
calibration=new_model,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
session.request_zero(
|
||||||
|
reference_id="field-ref",
|
||||||
|
declared_unloaded=True,
|
||||||
|
expected_reference_generation=0,
|
||||||
|
)
|
||||||
|
missing = session.process(_field_measurement(1, include_field=False))
|
||||||
|
assert missing.zero.phase == "failed"
|
||||||
|
assert "invalid_input" in missing.zero.failure_reason
|
||||||
|
assert all(np.isnan(value) for value in missing.newton)
|
||||||
|
assert missing.newton_status[0] == "invalid_input"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_model_uncalibrated_session_stays_explicit(tmp_path) -> None:
|
||||||
|
_, _, context = _field_models(tmp_path)
|
||||||
|
new = ForceStreamSession(
|
||||||
|
sensor_family="finger",
|
||||||
|
context=context,
|
||||||
|
calibration=None,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
new.request_zero(
|
||||||
|
reference_id="field-raw",
|
||||||
|
declared_unloaded=True,
|
||||||
|
expected_reference_generation=0,
|
||||||
|
)
|
||||||
|
rows = [_field_measurement(frame_id) for frame_id in range(1, 32)]
|
||||||
|
results = [new.process(row) for row in rows]
|
||||||
|
assert results[-1].zero.phase == "ready"
|
||||||
|
assert all(np.isnan(value) for value in results[-1].newton)
|
||||||
|
assert set(results[-1].newton_status) == {"uncalibrated"}
|
||||||
|
assert results[-1].quality == "uncalibrated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fn_only_model_requires_context_and_rejects_invalid_frames() -> None:
|
||||||
|
from orisys.calibration import CalibrationError
|
||||||
|
|
||||||
|
old_model, new_model, context = _fn_only_models()
|
||||||
|
with pytest.raises(CalibrationError, match="pipeline context"):
|
||||||
|
ForceStreamSession(sensor_family="box", calibration=old_model)
|
||||||
|
|
||||||
|
new = ForceStreamSession(
|
||||||
|
sensor_family="box",
|
||||||
|
context=context,
|
||||||
|
calibration=new_model,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
new.request_zero(
|
||||||
|
reference_id="fn-ref", declared_unloaded=True, expected_reference_generation=0
|
||||||
|
)
|
||||||
|
invalid = new.process(_measurement(1, (float("nan"), 7.0, -7.0)))
|
||||||
|
assert invalid.zero.phase == "failed"
|
||||||
|
assert "invalid input frame" in invalid.zero.failure_reason
|
||||||
|
assert all(np.isnan(value) for value in invalid.newton)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fn_only_model_uncalibrated_session_stays_explicit() -> None:
|
||||||
|
_, _, context = _fn_only_models()
|
||||||
|
new = ForceStreamSession(
|
||||||
|
sensor_family="box",
|
||||||
|
context=context,
|
||||||
|
calibration=None,
|
||||||
|
settings=helpers.legacy_settings(),
|
||||||
|
)
|
||||||
|
new.request_zero(
|
||||||
|
reference_id="fn-raw", declared_unloaded=True, expected_reference_generation=0
|
||||||
|
)
|
||||||
|
results = [
|
||||||
|
new.process(_measurement(frame_id, _fn_raw(frame_id)))
|
||||||
|
for frame_id in range(1, 32)
|
||||||
|
]
|
||||||
|
assert results[-1].zero.phase == "ready"
|
||||||
|
assert all(np.isnan(value) for value in results[-1].newton)
|
||||||
|
assert set(results[-1].newton_status) == {"uncalibrated"}
|
||||||
|
assert results[-1].quality == "uncalibrated"
|
||||||
|
|||||||
Reference in New Issue
Block a user