新增统一力读数会话 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:
@@ -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
|
||||
Reference in New Issue
Block a user