新增统一力读数会话 ForceSession

把同帧测量、空载归零、模型推断与共用后处理收进一个 SDK 会话,Studio 显示与记录使用同一样本;旧 Sensor 读数接口保持兼容。以 force session 与受影响 SDK 套件、lint 与文档门禁验证,硬件与真实载荷精度未验证。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CpCfJF6SvonMKmPZhzKZHb
This commit is contained in:
ykcheungab
2026-10-20 07:07:22 +00:00
parent dc8a630df3
commit ee830d8505
17 changed files with 4175 additions and 29 deletions
+82
View File
@@ -628,3 +628,85 @@ def test_every_preset_reports_what_it_does(
assert fp["measurement"]["flow_ema_alpha"] == pytest.approx(alpha)
assert fp["readout"]["force_median_window"] == median
assert fp["readout"]["force_lowpass_cutoff_hz"] == pytest.approx(cutoff)
# ------------------------------------------------- session reference adapter
def _nonlinear_scalar_calibration(schema_version: int = 2):
return parse(
{
"schema_version": schema_version,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"points": [[0.0, 0.0], [100.0, 1.0], [200.0, 3.0]],
"extrapolate": True,
"clamp_output_min": None,
},
"shear_x": {
"method": "piecewise_linear",
"signed": True,
"input": "components",
"points": [[0.0, 0.0], [100.0, 2.0]],
"extrapolate": True,
"clamp_output_min": None,
},
},
}
)
@pytest.mark.parametrize("schema_version", [1, 2])
def test_convert_referenced_subtracts_the_session_baseline_once(schema_version: int):
calibration = _nonlinear_scalar_calibration(schema_version)
curve = calibration.get("fn")
assert curve is not None
reference = {"fn": 150.0}
converted = calibration.convert_referenced((250.0, 0.0, 0.0), reference)
# curve(250) - curve(150) = 4.0 - 2.0: one subtraction, and the curve's own
# offset/zero point is not removed a second time.
assert converted[0] == pytest.approx(4.0 - 2.0)
assert converted[0] != pytest.approx(4.0 - 2.0 * 2.0)
# A raw-domain delta would convert 250-150 = 100 (1.0) instead of 4.0-2.0.
assert converted[0] != pytest.approx(1.0)
# The historical entry point is unchanged.
assert calibration.convert(250.0, 0.0, 0.0)[0] == pytest.approx(4.0)
def test_convert_referenced_keeps_signed_shear_and_missing_axes_unavailable():
calibration = _nonlinear_scalar_calibration()
converted = calibration.convert_referenced(
(250.0, -50.0, 12.0), {"fn": 150.0, "fx": 10.0}
)
assert converted[0] == pytest.approx(2.0)
# signed curve: convert_component(-50) - convert_component(10) = -1.0 - 0.2.
assert converted[1] == pytest.approx(-1.2)
# FY has no curve, no reference: unavailable, never a zero.
assert converted[2] is None
missing_reference = calibration.convert_referenced((250.0, 0.0, 0.0), None)
assert missing_reference == (None, None, None)
invalid_raw = calibration.convert_referenced(
(float("nan"), 0.0, 0.0), {"fn": 150.0}
)
assert invalid_raw[0] is None
def test_legacy_fingerprint_is_content_identity():
calibration = _nonlinear_scalar_calibration()
assert len(calibration.fingerprint) == 64
assert calibration.fingerprint == _nonlinear_scalar_calibration().fingerprint
changed = parse(
{
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"points": [[0.0, 0.0], [100.0, 1.0], [200.0, 4.0]],
"extrapolate": True,
"clamp_output_min": None,
}
},
}
)
assert changed.fingerprint != calibration.fingerprint
+219
View File
@@ -0,0 +1,219 @@
"""HHD downsample compatibility diagnostics and non-divisible grid integration.
The pipeline contract allows a model calibrated at one ``hhd_downsample`` to run at
another, but the difference is an accuracy risk that must be visible instead of a
silent pass. These tests pin the report, the unchanged refusal of every other
identity mismatch, and the analytic block integration on grids that do not divide
the output image evenly.
"""
from __future__ import annotations
import numpy as np
import pytest
from orisys.calibration import CalibrationError, parse
from orisys.calibration_context import build_context
from orisys.calibration_models import ModelCalibration
from orisys.field_calibration import FieldFrame, _block_starts, build_field_gain_map, reduce_field
from orisys.force_readout import OutputSettings
from orisys.force_session import ForceSession, FrameStamp
from orisys.sensor import FrameMeasurement
FRAME = np.zeros((2, 2, 3), dtype=np.uint8)
def _config(downsample=1, *, dis_mode=4):
return {
"camera": {"resolution_raw": [1280, 720], "resolution_stitch": [700, 750]},
"tracker": {"dis_mode": dis_mode, "hhd_downsample": downsample},
}
def _document(config) -> dict:
return {
"schema_version": 3,
"unit": "N",
"identity": {"sensor_type": "box", "sn": "A1"},
"pipeline": dict(build_context(config)),
"model": {
"type": "global_gain/1",
"input_profile": "force_delta/1",
"output_frame": "sdk_force/1",
"outputs": ["fn"],
"params": {
"gains_N_per_raw": {"fn": 0.001},
"valid_force_range_N": [0, 3],
},
},
}
def _model(config) -> ModelCalibration:
loaded = parse(_document(config))
assert isinstance(loaded, ModelCalibration)
return loaded
# ------------------------------------------------------------------ the report
def test_downsample_only_difference_runs_and_is_reported():
model = _model(_config(2))
runtime = build_context(_config(3))
model.validate_for_context(runtime) # allowed: no exception
report = model.pipeline_compatibility(runtime)
assert report["ok"] is True
downsample = report["downsample"]
assert downsample["declared"] == 2
assert downsample["actual"] == 3
assert downsample["match"] is False
assert downsample["allowed"] is True
assert downsample["accuracy_risk"]
assert model.downsample_report(runtime).known is True
def test_a_matching_downsample_has_no_accuracy_risk():
model = _model(_config(3))
report = model.pipeline_compatibility(build_context(_config(3)))
assert report["downsample"]["match"] is True
assert report["downsample"]["accuracy_risk"] == ""
def test_an_unknown_runtime_downsample_is_unknown_not_a_match():
model = _model(_config(2))
report = model.downsample_report({})
assert report.actual is None
assert report.match is False
assert report.allowed is True
assert "unknown" in report.accuracy_risk
def test_every_other_identity_mismatch_is_still_refused():
model = _model(_config(2))
runtime = build_context(_config(2, dis_mode=5))
report = model.pipeline_compatibility(runtime)
assert report["ok"] is False
assert "calibration pipeline mismatch: dis_mode" in report["mismatches"].values()
with pytest.raises(CalibrationError, match="calibration pipeline mismatch: dis_mode"):
model.validate_for_context(runtime)
@pytest.mark.parametrize("raw, expected", [(None, 1), ("bad", 1), (0, 1), (-3, 1), ("3", 3), (3.7, 3)])
def test_context_reports_its_actual_downsample(raw, expected):
config = _config(1)
config["tracker"]["hhd_downsample"] = raw
assert build_context(config).actual_downsample == expected
# ------------------------------------------------- non-divisible grid integration
def _identity_coordinates(rows: int, cols: int) -> np.ndarray:
uv = np.empty((rows, cols, 2), dtype=np.float64)
uv[..., 0] = np.arange(cols)[None, :]
uv[..., 1] = np.arange(rows)[:, None]
return uv
@pytest.mark.parametrize("grid_shape", [(1, 1), (2, 3), (3, 4), (4, 4)])
def test_a_constant_field_integrates_exactly_on_a_non_divisible_grid(grid_shape):
rows, cols = 37, 53
uv = _identity_coordinates(rows, cols)
mask = np.ones((rows, cols), dtype=bool)
points = np.array(
[
[0.0, 0.0],
[cols - 1.0, 0.0],
[cols - 1.0, rows - 1.0],
[0.0, rows - 1.0],
]
)
gains = np.ones(4, dtype=np.float64)
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=int)
# The block layout is an exact partition of the output image, for every grid
# size that does not divide it: no lost pixels and no double counting.
for divisions, size in ((grid_shape[0], rows), (grid_shape[1], cols)):
starts, ends = _block_starts(divisions, size)
assert starts[0] == 0
assert ends[-1] == size
assert np.array_equal(starts[1:], ends[:-1])
assert np.all(ends > starts)
gain_map = build_field_gain_map(points, gains, triangles, uv, mask, grid_shape, mask)
row_starts, row_ends = _block_starts(grid_shape[0], rows)
col_starts, col_ends = _block_starts(grid_shape[1], cols)
block_area = np.outer(row_ends - row_starts, col_ends - col_starts)
# A constant unit gain and a fully mapped mask: the area-weighted coverage is
# the whole image, exactly (analytic area, not a sampled sum).
assert float(np.sum(gain_map.coverage * block_area)) == pytest.approx(rows * cols)
assert np.allclose(gain_map.gain, 1.0)
assert np.allclose(gain_map.inside, 1.0)
field = FieldFrame(
d=np.ones((grid_shape[0], grid_shape[1], 2), dtype=np.float32),
valid_mask=np.ones(grid_shape, dtype=bool),
area_scale=1.0,
grid_shape=grid_shape,
reference_shape=None,
)
reduction = reduce_field(field, gain_map)
# q = |d_x| + |d_y| = 2 per active HHD cell; the gain map's block sums are exact
# whatever the block boundaries are, so the integral is exactly 2 * grid cells.
cells = grid_shape[0] * grid_shape[1]
assert reduction.weighted_N == pytest.approx(2.0 * cells)
assert reduction.extrapolated_fraction == 0.0
assert reduction.coverage_fraction == pytest.approx(1.0)
# ------------------------------------------------------- session output diagnostics
class _Source:
"""The measurement-source surface ForceSession needs; no camera involved."""
def __init__(self) -> None:
self.session_enabled = False
self.reset_count = 0
def enable_measurement_session(self) -> None:
self.session_enabled = True
def disable_measurement_session(self) -> None:
self.session_enabled = False
def reset_tracker(self) -> None:
self.reset_count += 1
def read_measurement_frame(
self, source_frame, *, frame_id, timestamp_s=None, want_field=False, want_contact=False
) -> FrameMeasurement:
del source_frame, timestamp_s, want_field, want_contact
return FrameMeasurement(
raw=(10.0, 20.0, 30.0),
raw_valid=(True, True, True),
)
def test_the_session_output_states_the_downsample_risk():
model = _model(_config(2))
session = ForceSession(
source=_Source(),
sensor_family="box",
context=build_context(_config(3)),
calibration=model,
settings=OutputSettings(sample_rate_hz=30.0),
)
output = session.process_frame(
FRAME,
FrameStamp(
session_id="s1",
frame_id=0,
source_frame_id=0,
signal_time_s=0.0,
time_basis="recorded_signal",
),
)
diagnostics = dict(output.measurement.diagnostics)
assert diagnostics["hhd_downsample_calibrated"] == 2.0
assert diagnostics["hhd_downsample_actual"] == 3.0
assert diagnostics["hhd_downsample_mismatch"] == 1.0
assert diagnostics["downsample_accuracy_risk"]
assert session.calibration_compatibility["downsample"]["actual"] == 3
File diff suppressed because it is too large Load Diff
+163
View File
@@ -455,3 +455,166 @@ def test_finger_runtime_binds_field_identity_and_forwards_the_window(monkeypatch
runtime.declare_unloaded_reference("runtime-window")
assert sensor.calibration.begin_calls == ["runtime-window"]
# ------------------------------------------------- session measurement seam
def test_measurement_session_skips_old_inference_and_readout():
"""The new entry publishes same-frame raw only: no model call, no readout."""
sensor = make_sensor()
sensor.cuda = sensor.verbose = False
sensor.valid_mask = sensor.valid_mask_hhd = None
sensor._hhd_downsample = 1
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
sensor._model_frame_id = 0
field = np.zeros((2, 2, 2), dtype=np.float32)
sensor._Sensor__hhd = SimpleNamespace(
get_force=lambda *args, **kwargs: (None, (10., 0., 0., 0.)),
get_normal=lambda: field,
get_shear=lambda: field,
)
sensor.enable_measurement_session()
assert sensor.measurement_session is True
sensor.compute_hhd(field)
assert sensor.calibration.calls == [], "the session owns the one model call"
assert sensor._force_readout.filtered_raw is None
assert sensor._force_raw == (10., 0., 0.)
assert np.isnan(sensor.fnormal)
assert sensor.calibration_status == {}
# Contact statistics stay available, still without a model inference.
sensor._Sensor__contact3d = SimpleNamespace(
compute_foe=lambda *args, **kwargs: {
"centroid": (0, 1), "centroid_found": True,
"divergence_map": np.zeros((2, 2)),
}
)
sensor.compute_contact()
assert sensor.calibration.calls == []
assert sensor.centroid_found is True
sensor.disable_measurement_session()
assert sensor.measurement_session is False
def test_reset_tracker_confirms_without_touching_the_calibration_reference():
sensor = make_sensor()
resets = []
sensor._Sensor__tracker = SimpleNamespace(reset=lambda: resets.append("tracker"))
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
sensor._force_unfiltered = (1., 2., 3.)
sensor.compute_deformation_valid = True
sensor.reset_tracker()
assert resets == ["tracker"]
assert sensor._force_unfiltered is None
assert sensor._calibration_reference == {"fn": 10.}
assert sensor._calibration_reference_id == "reference"
assert sensor.compute_deformation_valid is False
sensor.enable_measurement_session()
sensor.reset_tracker()
assert sensor.measurement_session is True, "the seam survives a frame-boundary reset"
def test_measure_current_frame_returns_this_frames_features_once():
sensor = make_sensor()
sensor.cuda = sensor.verbose = False
sensor.valid_mask = np.ones((2, 2), dtype=bool)
sensor.valid_mask_hhd = np.ones((2, 2), dtype=bool)
sensor._hhd_mask_f32 = None
sensor._hhd_downsample = 1
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
sensor._model_frame_id = 0
field = np.zeros((2, 2, 2), dtype=np.float32)
sensor._Sensor__hhd = SimpleNamespace(
get_force=lambda *args, **kwargs: (None, (10., 20., 30., 0.)),
get_normal=lambda: field,
get_shear=lambda: field,
get_field=lambda valid_mask=None: {
"d": field, "valid_mask": np.ones((2, 2), dtype=bool),
"area_scale": 4.0, "grid_shape": (2, 2), "reference_shape": (2, 2),
},
)
sensor._Sensor__contact3d = SimpleNamespace(
compute_foe=lambda *args, **kwargs: {
"centroid": (0.4, 1.2), "centroid_found": True,
"divergence_map": np.zeros((2, 2)),
}
)
flow_calls = []
sensor.compute_flow = lambda image=None: (
flow_calls.append(image), setattr(sensor, "flow", field)
)
sensor.enable_measurement_session()
sensor.img = np.zeros((2, 2), dtype=np.uint8)
measurement = sensor.measure_current_frame(
frame_id=5, want_field=True, want_contact=True
)
assert flow_calls == [sensor.img]
assert measurement.raw == (10., 20., 30.)
assert measurement.raw_valid == (True, True, True)
assert measurement.field["grid_shape"] == (2, 2)
assert measurement.output_valid_mask.shape == (2, 2)
assert measurement.contact_found is True
assert measurement.centroid == (0.4, 1.2)
assert measurement.single_contact is True
def test_read_measurement_frame_prepares_the_source_frame_once():
sensor = make_sensor()
sensor.cuda = sensor.verbose = False
sensor.valid_mask = sensor.valid_mask_hhd = None
sensor._hhd_downsample = 1
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
sensor._model_frame_id = 0
field = np.zeros((2, 2, 2), dtype=np.float32)
sensor._Sensor__hhd = SimpleNamespace(
get_force=lambda *args, **kwargs: (None, (1., 2., 3., 0.)),
get_normal=lambda: field,
get_shear=lambda: field,
)
prepared = []
sensor.get_img = lambda frame=None: (prepared.append(frame), setattr(sensor, "img", frame))
sensor.compute_flow = lambda image=None: setattr(sensor, "flow", field)
sensor.enable_measurement_session()
frame = np.ones((2, 2, 3), dtype=np.uint8)
measurement = sensor.read_measurement_frame(frame, frame_id=7)
assert prepared == [frame]
assert measurement.raw == (1., 2., 3.)
assert measurement.field is None, "no field unless the model asked for it"
assert measurement.contact_found is None, "no contact unless it was requested"
def test_finger_runtime_session_seam_delegates_to_its_sensor():
from orisys.finger.runtime import FingerRuntime
events = []
class FakeSensor:
def enable_measurement_session(self):
events.append("enable")
def disable_measurement_session(self):
events.append("disable")
def reset_tracker(self):
events.append("reset")
def measure_current_frame(self, *, frame_id, want_field=False, want_contact=False):
events.append(("measure", frame_id, want_field, want_contact))
return "measurement"
runtime = object.__new__(FingerRuntime)
runtime.tactile = SimpleNamespace(raw_sensor=FakeSensor())
processed = []
runtime.process_frame = lambda frame, color=False: processed.append((frame, color))
frame = np.zeros((2, 2, 3), dtype=np.uint8)
result = runtime.read_measurement_frame(
frame, frame_id=9, want_field=True, want_contact=False
)
assert processed == [(frame, False)]
assert result == "measurement"
assert events[-1] == ("measure", 9, True, False)
runtime.enable_measurement_session()
runtime.reset_tracker()
runtime.disable_measurement_session()
assert events[1:] == ["enable", "reset", "disable"]
+74
View File
@@ -14,6 +14,7 @@ from orisys.signal_processing import (
NORMAL_FORCE_CAL_OFFSET,
NORMAL_FORCE_CAL_SLOPE,
LowpassFilter,
MultiAxisCausalFilter,
calibrate_normal_force,
calibrate_shear_components,
calibrate_shear_force,
@@ -494,3 +495,76 @@ def test_the_calibration_note_names_the_bundle_that_was_found(tmp_path, monkeypa
# A dict always keys ``found_root``, so guard its value: an unguarded copy of
# this branch prints "bundle found at None".
assert "None" not in note
# --------------------------------------------- multi-axis causal force chain
class _ReferenceMedian:
"""Independent median window, so the operator test has its own expectation."""
def __init__(self, window):
self.window = window
self.history = []
def process(self, value):
self.history.append(float(value))
self.history = self.history[-self.window :]
return float(np.median(self.history))
def test_multi_axis_median_matches_a_plain_window_per_axis():
chain = MultiAxisCausalFilter(median_window=3)
reference = [_ReferenceMedian(3) for _ in range(3)]
samples = [(10.0, 20.0, 30.0), (90.0, 20.0, 30.0), (10.0, 20.0, 30.0),
(10.0, 20.0, 30.0)]
for sample in samples:
actual = chain.process(sample)
expected = [reference[index].process(value) for index, value in enumerate(sample)]
np.testing.assert_allclose(actual, expected)
def test_multi_axis_nan_axis_does_not_enter_history_or_pull_the_others():
chain = MultiAxisCausalFilter(median_window=3)
chain.process((10.0, 20.0, 30.0))
skipped = chain.process(
(float("nan"), 20.0, 30.0), valid=(False, True, True)
)
assert np.isnan(skipped[0])
assert skipped[1] == pytest.approx(20.0)
# Valid axes keep advancing while the missing axis is skipped.
resumed = chain.process((10.0, 20.0, 30.0))
assert resumed[0] == pytest.approx(10.0)
# A NaN arriving without an explicit validity flag is treated as unavailable.
assert np.isnan(chain.process((float("nan"), 20.0, 30.0))[0])
def test_multi_axis_all_stages_off_is_exact_passthrough_including_nan():
chain = MultiAxisCausalFilter(median_window=1, lowpass_cutoff_hz=0.0)
assert chain.enabled is False
sample = (1.5, -2.0, float("nan"))
first = chain.process(sample)
second = chain.process(sample)
assert first[0] == 1.5 and first[1] == -2.0 and np.isnan(first[2])
assert second[0] == 1.5 and second[1] == -2.0 and np.isnan(second[2])
def test_multi_axis_lowpass_ignores_missing_axes_and_resets():
chain = MultiAxisCausalFilter(
median_window=1, lowpass_cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3
)
values = []
for index in range(20):
values.append(
chain.process(
(float(index), float(index), float(index)),
valid=(True, True, index % 2 == 0),
)
)
for index, value in enumerate(values):
assert np.isfinite(value[0])
assert np.isfinite(value[1])
# The third axis is only fed on even frames, so it is missing on odd ones.
assert np.isnan(value[2]) == bool(index % 2)
chain.reset()
after = chain.process((1.0, 2.0, 3.0))
# A reset IIR starts from its first sample's steady state.
np.testing.assert_allclose(after, (1.0, 2.0, 3.0))