接入力标定 schema 3 与多点空间增益模型

保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
This commit is contained in:
ykcheungab
2026-10-18 12:56:40 +08:00
parent f7f4682ff9
commit ff7614a6a0
13 changed files with 1422 additions and 19 deletions
+59
View File
@@ -0,0 +1,59 @@
"""Feature identities use effective measurement settings, never display metadata."""
from copy import deepcopy
import numpy as np
import pytest
from orisys.calibration_context import build_context, domain_fingerprint
def config():
return {"camera": {"resolution_raw": [1280, 720], "resolution_stitch": [700, 750]},
"tracker": {"dis_mode": 4}}
@pytest.mark.parametrize("raw,expected", [(None, 1), ("bad", 1), (0, 1), (-3, 1), ("3", 3), (3.7, 3)])
def test_hhd_uses_actual_sensor_normalization(raw, expected):
data = config()
data["tracker"]["hhd_downsample"] = raw
assert build_context(data)["hhd_downsample"] == expected
def test_default_values_equal_explicit_effective_settings():
implicit = build_context(config())
data = config()
data["tracker"]["hhd_downsample"] = 1
explicit = build_context(data, {"flow_ema_alpha": 1.0})
assert implicit["hhd_downsample"] == 1
assert implicit["filters"]["measurement"] == {"flow_ema_alpha": 1.0}
assert implicit["feature_signature_sha256"] == explicit["feature_signature_sha256"]
def test_measurement_filters_change_identity_but_readout_and_paths_do_not():
data = config()
original = build_context(data)
assert build_context(data, {"flow_ema_alpha": 0.5})["feature_signature_sha256"] != original["feature_signature_sha256"]
data.update(name="other", video="D:/other.mp4", cal_path="E:/calibration.json")
assert build_context(data, {"force_median_window": 7, "force_lowpass_cutoff_hz": 2}) == original
def test_explicit_empty_filter_mapping_overrides_config_defaults():
data = config()
data["filters"] = {"flow_ema_alpha": 0.5}
assert build_context(data, {})["filters"]["measurement"] == {"flow_ema_alpha": 1.0}
def test_source_scale_numeric_types_have_identical_identity():
assert build_context(config(), source_scale=1) == build_context(config(), source_scale=1.0)
def test_domain_fingerprint_binds_map_content_not_only_dimensions():
domain = {"map_x": np.zeros((2, 3)), "map_y": np.zeros((2, 3)),
"valid_mask": np.ones((2, 3), dtype=bool), "world_xyz": np.zeros((2, 3, 3))}
original = domain_fingerprint(domain)
for key in domain:
changed = deepcopy(domain)
changed[key].flat[0] = not changed[key].flat[0] if key == "valid_mask" else 1
assert domain_fingerprint(changed) != original
assert domain_fingerprint({key: value.copy() for key, value in domain.items()}) == original
+179
View File
@@ -0,0 +1,179 @@
"""Schema 3 model validation and deterministic inference."""
import hashlib
from copy import deepcopy
import numpy as np
import pytest
from orisys.calibration import CalibrationError
from orisys.calibration_models import parse_model
def document():
return {
"schema_version": 3, "unit": "N",
"identity": {"sensor_type": "finger", "sn": "DD02-GL"},
"pipeline": {
"stitch_resolution": [700, 750], "dis_mode": 4,
"hhd_downsample": 3, "source_scale": 0.5,
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
"feature_signature_sha256": "a" * 64,
"geometry_sha256": "b" * 64,
"coordinates": {"frame": "autocal_flat", "unit": "px",
"origin_xy": [511, 1021], "map_sha256": "c" * 64},
},
"model": {
"type": "spatial_gain/1", "input_profile": "fn_delta_autocal_uv/1",
"output_frame": "sdk_force/1", "outputs": ["fn"],
"params": {"points_uv": [[0, 0], [10, 0], [0, 10]],
"gains_N_per_raw": [0.001, 0.002, 0.003],
"triangles": [[0, 1, 2]], "valid_force_range_N": [0, 3]},
},
}
def features(**overrides):
return dict(delta_raw=1000, u=2.5, v=2.5, contact_found=True,
zero_valid=True, mapping_valid=True, **overrides)
def test_interpolates_gain_not_sensitivity_and_roundtrips():
data = document()
model = parse_model(data)
result = model.predict(1, 0.1, features(), "zero-1")
assert result.values_N == {"fn": pytest.approx(1.75)}
assert result.status == {"fn": "ok"}
assert model.to_dict() == data
assert "fx" not in model.channels
data["model"]["params"]["gains_N_per_raw"][0] = 999
assert model.predict(2, 0.2, features(), "zero-1").values_N["fn"] == 1.75
def test_legacy_scalar_convert_explicitly_rejected():
with pytest.raises(CalibrationError, match="schema 3 requires frame features"):
parse_model(document()).convert(1, 2, 3)
@pytest.mark.parametrize("change,status", [
({"u": 20}, "outside_coverage"), ({"contact_found": False}, "no_contact"),
({"zero_valid": False}, "zero_unavailable"),
({"mapping_valid": False}, "coordinate_unmapped"),
({"delta_raw": float("nan")}, "invalid_input"),
({"delta_raw": "1000"}, "invalid_input"),
({"single_contact": False}, "invalid_input"),
])
def test_frame_invalidity(change, status):
values = features()
values.update(change)
result = parse_model(document()).predict(1, 0.1, values, "zero")
assert result.status == {"fn": status}
assert result.values_N == {"fn": None}
def test_out_of_range_retains_value():
values = features()
values["delta_raw"] = 3000
result = parse_model(document()).predict(1, 0, values, "zero")
assert result.values_N["fn"] == 5.25
assert result.status["fn"] == "out_of_range"
@pytest.mark.parametrize("mutate", [
lambda d: d.update(schema_version=True),
lambda d: d.update(extra="ignored"),
lambda d: d["identity"].update(sn=""),
lambda d: d["model"].update(outputs=["fn", "fx"]),
lambda d: d["model"]["params"].update(gains_N_per_raw=[0.001, 0, 0.003]),
lambda d: d["model"]["params"].update(triangles=[[0, 1, 1]]),
lambda d: d["model"]["params"].update(triangles=[[0, 1, 2], [2, 1, 0]]),
lambda d: d["pipeline"].pop("geometry_sha256"),
lambda d: d["pipeline"]["coordinates"].update(map_sha256="bad"),
])
def test_rejects_bad_documents(mutate):
data = document()
mutate(data)
with pytest.raises(ValueError):
parse_model(data)
def test_rejects_gap_and_overlapping_equal_area_meshes():
data = document()
params = data["model"]["params"]
params.update(points_uv=[[0, 0], [10, 0], [10, 10], [0, 10]],
gains_N_per_raw=[0.001] * 4, triangles=[[0, 1, 2]])
with pytest.raises(ValueError, match="triang|mesh|hull"):
parse_model(data)
params["triangles"] = [[0, 1, 2], [0, 1, 3]]
with pytest.raises(ValueError, match="overlap"):
parse_model(data)
params["triangles"] = [[0, 1, 2], [0, 2, 3]]
assert parse_model(data).predict(1, 0, features(), "zero").status["fn"] == "ok"
def test_global_gain_and_context():
data = document()
data["pipeline"].pop("coordinates")
data["pipeline"].pop("geometry_sha256")
data["model"].update(type="global_gain/1", input_profile="force_delta/1",
outputs=["fn", "fx"], params={
"gains_N_per_raw": {"fn": 0.002, "fx": 0.004},
"valid_force_range_N": [-3, 3]})
model = parse_model(data)
result = model.predict(1, 0, {"delta_raw_by_axis": {"fn": 100, "fx": -100},
"zero_valid": True, "contact_found": True}, "z")
assert result.values_N == {"fn": 0.2, "fx": -0.4}
model.validate_for_context(data["pipeline"])
wrong = deepcopy(data["pipeline"])
wrong["source_scale"] = 0.42
with pytest.raises(ValueError, match="source_scale"):
model.validate_for_context(wrong)
model.reset("new-zero")
data["pipeline"].pop("source_scale")
assert parse_model(data).input_profile == "force_delta/1"
assert model.predict(2, None, {"force_delta_raw": {"fn": 100, "fx": -100},
"zero_valid": True}, "z").values_N == {"fn": 0.2, "fx": -0.4}
assert model.predict(3, None, {"force_delta_raw": {"fn": 100, "fx": -100},
"zero_valid": True, "contact_found": False}, "z").status == {
"fn": "no_contact", "fx": "no_contact"}
def test_unknown_backend_fails_explicitly():
data = document()
data["model"]["type"] = "onnx/1"
with pytest.raises(ValueError, match="unsupported.*onnx/1"):
parse_model(data)
def test_coordinate_artifact_hash_and_paths(tmp_path):
path = tmp_path / "map.npz"
np.savez(path, uv_by_output_px=np.zeros((2, 3, 2)), valid_mask=np.ones((2, 3), dtype=bool),
origin_xy=np.array([511, 1021]))
digest = hashlib.sha256(path.read_bytes()).hexdigest()
data = document()
data["pipeline"]["coordinates"]["map_sha256"] = digest
data["model"]["artifacts"] = [{"role": "coordinate_map", "path": "map.npz", "sha256": digest}]
model = parse_model(data, tmp_path / "calibration.json")
assert model.coordinate_map.shape == (2, 3, 2)
assert len(model.fingerprint) == 64
assert not model.coordinate_map.flags.writeable
for unsafe in ("../map.npz", "https://example.org/x.npz", "C:\\map.npz", "/map.npz"):
data["model"]["artifacts"][0]["path"] = unsafe
with pytest.raises(ValueError, match="relative"):
parse_model(data, tmp_path / "calibration.json")
data["model"]["artifacts"][0]["path"] = "map.npz"
path.write_bytes(b"corrupt")
with pytest.raises(ValueError, match="SHA-256"):
parse_model(data, tmp_path / "calibration.json")
def test_many_points_complete_mesh():
from scipy.spatial import Delaunay
data = document()
points = np.array([[x, y] for x in range(5) for y in range(5)])
data["model"]["params"].update(points_uv=points.tolist(), gains_N_per_raw=[0.001] * 25,
triangles=Delaunay(points).simplices.tolist())
model = parse_model(data)
values = features()
values.update(u=2, v=2)
assert model.predict(1, 0, values, "z").values_N["fn"] == 1.0
+120
View File
@@ -0,0 +1,120 @@
"""File selection, SN isolation and legacy compatibility at the public loader boundary."""
import json
import time
from types import SimpleNamespace
import numpy as np
import pytest
from orisys import calibration
from orisys.calibration_context import build_context
from orisys.sensors.calibration import resolve_calibration
def model_doc():
config = {"type": "single_camera", "camera": {"resolution_stitch": [64, 64]},
"tracker": {"dis_mode": 1, "hhd_downsample": 3}}
return {"schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "A1"},
"pipeline": 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": .001}, "valid_force_range_N": [0, 3]}}}
def test_schema3_public_load_write_and_fn_only_sn(tmp_path):
path = tmp_path / "finger" / "A1.json"
path.parent.mkdir()
path.write_text(json.dumps(model_doc()))
model = calibration.load_for_device(tmp_path, "finger", "A1")
assert tuple(model.channels) == ("fn",)
assert calibration.check(model).ok
target = tmp_path / "copy.json"
calibration.write(model, target)
assert json.loads(target.read_text())["schema_version"] == 3
assert calibration.load(target).fingerprint == model.fingerprint
with pytest.raises(calibration.CalibrationError, match="identity mismatch"):
calibration.validate_for_device(model, "finger", "another-SN")
def test_missing_sn_does_not_pick_any_neighbor_or_default(tmp_path):
(tmp_path / "finger").mkdir()
(tmp_path / "finger" / "A1.json").write_text(json.dumps(model_doc()))
(tmp_path / "force_calibration.json").write_text(json.dumps(model_doc()))
assert calibration.load_for_device(tmp_path, "finger", "B2") is None
with pytest.raises(calibration.CalibrationError, match="directory"):
calibration.load_for_device(tmp_path / "missing", "finger", "B2")
@pytest.mark.parametrize("change", ["unknown_type", "unknown_schema", "malformed"])
def test_selected_bad_model_never_falls_back(tmp_path, change):
data = model_doc()
if change == "unknown_type":
data["model"]["type"] = "onnx/1"
elif change == "unknown_schema":
data["schema_version"] = 99
else:
data["model"]["params"]["gains_N_per_raw"] = {}
path = tmp_path / "selected.json"
path.write_text(json.dumps(data))
with pytest.raises(calibration.CalibrationError):
calibration.load(path, sensor_type="box")
def test_invalid_default_schema3_does_not_silently_become_raw(monkeypatch, tmp_path):
from orisys import configs
data = model_doc()
data["model"]["type"] = "onnx/1"
path = tmp_path / "force_calibration.json"
path.write_text(json.dumps(data))
monkeypatch.setattr(configs, "resolve_sensor_bundle", lambda name: SimpleNamespace(force_calibration_json=path))
with pytest.raises(calibration.CalibrationError, match="unsupported calibration model"):
calibration.load(sensor_type="finger")
def test_path_selection_keeps_explicit_and_sn_modes_separate(tmp_path):
with pytest.raises(calibration.CalibrationError, match="mutually exclusive"):
calibration.select_calibration_path(cal_path="file.json", calibration_dir=tmp_path)
with pytest.raises(calibration.CalibrationError, match="requires"):
calibration.select_calibration_path(device_id="A1")
assert calibration.select_calibration_path(cal_path=tmp_path / "A1.json") == tmp_path / "A1.json"
def test_profile_declares_only_available_axes_and_checks_family(tmp_path):
path = tmp_path / "selected.json"
path.write_text(json.dumps(model_doc()))
options = dict(bundle_root=tmp_path, declared_default=None, profile_id="test", selection=path)
resolved = resolve_calibration(product_family="finger", **options)
assert resolved.axes == ("fn",) and resolved.document is not None
assert resolved.fingerprint == resolved.document.fingerprint
wrong = resolve_calibration(product_family="box", **options)
assert wrong.document is None and wrong.issues[0].code == "calibration.model-family-mismatch"
def test_processor_applies_resolved_selection_including_none(monkeypatch):
from orisys.sensors import backends
monkeypatch.setattr(backends, "validate_profile", lambda profile: SimpleNamespace(usable=True))
selected = calibration.parse(model_doc())
calls = []
sensor = SimpleNamespace(set_calibration=calls.append)
for model in (selected, None):
profile = SimpleNamespace(processing_backend="single_camera", config={"camera": {}},
calibration=SimpleNamespace(document=model), profile_id="test")
session = backends.create_processor(profile, sensor=sensor)
assert session.sensor is sensor
assert calls == [selected, None]
def test_snapshot_keeps_per_frame_units_and_validity_copy():
from orisys.pipeline import StreamPipeline
sensor = SimpleNamespace(info=object(), force_unit="N", force_units=("N", "raw", "raw"),
calibration_status={"fn": "outside_coverage"}, fnormal=float("nan"),
fshearx=2., fsheary=3., _force_raw=(1., 2., 3.))
pipeline = StreamPipeline(sensor, lambda: None, include=())
snapshot = pipeline._build(np.zeros((2, 2)), None, 0., 0., 0., time.perf_counter())
assert snapshot.force_units == ("N", "raw", "raw")
assert snapshot.calibration_status == {"fn": "outside_coverage"}
sensor.calibration_status["fn"] = "ok"
assert snapshot.calibration_status["fn"] == "outside_coverage"
+308
View File
@@ -0,0 +1,308 @@
"""Schema 3 lifecycle and readout tests without camera or hardware."""
from types import SimpleNamespace
import numpy as np
import pytest
from orisys.calibration import CalibrationError
from orisys.force_readout import ForceReadout
from orisys.sensor import Sensor
class RecordingModel:
schema_version = 3
channels = {"fn": None}
input_profile = "fn_delta_autocal_uv/1"
def __init__(self):
self.calls = []
self.resets = []
self.coordinate_map = np.array([[[0., 0.], [1., 0.]], [[0., 1.], [1., 1.]]])
self.coordinate_valid_mask = np.array([[True, False], [True, True]])
def reset(self, reference_id):
self.resets.append(reference_id)
def validate_for_context(self, context):
if context != {"signature": "actual"}:
raise CalibrationError("pipeline mismatch")
def predict(self, **kwargs):
self.calls.append(kwargs)
features = kwargs["features"]
status = "ok"
if not features["zero_valid"]:
status = "zero_unavailable"
elif not features["contact_found"]:
status = "no_contact"
elif not features["mapping_valid"]:
status = "coordinate_unmapped"
value = features["delta_raw"] * .1 if status == "ok" else None
return SimpleNamespace(values_N={"fn": value}, status={"fn": status})
def make_sensor(*, median=1):
sensor = Sensor.__new__(Sensor)
sensor.calibration = RecordingModel()
sensor._config = {"filters": {"force_median_window": median}}
sensor._force_readout = ForceReadout(sensor._config["filters"])
sensor._force_raw = (10., 20., 30.)
sensor._model_readouts = {}
sensor._calibration_context = None
sensor._reset_calibration_session()
sensor.bind_calibration_context({"signature": "actual"})
return sensor
def predict(sensor, frame, fn, *, contact=True, mapped=True):
return sensor.apply_calibration_frame(frame_id=frame, timestamp_s=frame / 30.,
raw=(fn, 20., 30.), uv=(0., 0.),
contact_found=contact, mapping_valid=mapped)
def test_once_per_frame_and_explicit_zero_subtracted_once():
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="zero-a")
first = predict(sensor, 1, 25.)
assert first.values_N["fn"] == pytest.approx(1.5)
assert predict(sensor, 1, 99.) is first
assert len(sensor.calibration.calls) == 1
assert sensor.force_units == ("N", "raw", "raw")
assert (sensor.fnormal, sensor.fshearx, sensor.fsheary) == (1.5, 20., 30.)
assert sensor._force_raw_unfiltered == (25., 20., 30.)
def test_unknown_zero_and_invalid_contact_never_become_zero_newtons():
sensor = make_sensor()
assert predict(sensor, 1, 20.).status["fn"] == "zero_unavailable"
assert np.isnan(sensor.fnormal)
sensor.set_calibration_reference({"fn": 10.}, reference_id="zero-b")
assert predict(sensor, 2, 20., contact=False).status["fn"] == "no_contact"
assert np.isnan(sensor.fnormal)
assert predict(sensor, 3, 20., mapped=False).status["fn"] == "coordinate_unmapped"
assert np.isnan(sensor.fnormal)
def test_failed_frame_does_not_enter_newton_median_history():
sensor = make_sensor(median=3)
sensor.set_calibration_reference({"fn": 0.}, reference_id="zero")
predict(sensor, 1, 10.)
predict(sensor, 2, 1000., contact=False)
predict(sensor, 3, 20.)
predict(sensor, 4, 30.)
assert sensor.fnormal == pytest.approx(2.)
assert sensor._force_unfiltered[0] == 3.
def test_new_reference_clears_cached_prediction_and_filter():
sensor = make_sensor(median=3)
sensor.set_calibration_reference({"fn": 0.}, reference_id="first")
predict(sensor, 1, 10.)
sensor.set_calibration_reference({"fn": 20.}, reference_id="second")
assert sensor.calibration_result is None
assert np.isnan(sensor.fnormal)
predict(sensor, 1, 30.)
assert sensor.fnormal == pytest.approx(1.)
assert sensor.calibration.resets[-1] == "second"
def test_requires_actual_pipeline_context():
sensor = make_sensor()
with pytest.raises(CalibrationError, match="pipeline mismatch"):
sensor.bind_calibration_context({"signature": "different"})
assert sensor._calibration_context == {"signature": "actual"}
sensor._calibration_context = None
with pytest.raises(CalibrationError, match="actual calibration context"):
predict(sensor, 1, 10.)
def test_contact_map_never_reuses_previous_coordinate():
sensor = make_sensor()
sensor.centroid_found = True
sensor.centroid = (0, 1)
assert sensor._calibration_contact_uv() == (0., 1.)
sensor.centroid = (1, 0)
assert sensor._calibration_contact_uv() is None
sensor.centroid = (10, 10)
assert sensor._calibration_contact_uv() is None
sensor.centroid_found = False
sensor.centroid = (0, 1)
assert sensor._calibration_contact_uv() is None
def test_hhd_infers_after_current_contact_only_once_and_clears_old_contact():
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="zero")
field = np.zeros((2, 2, 2), dtype=np.float32)
sensor._Sensor__hhd = SimpleNamespace(
get_force=lambda *args, **kwargs: (None, (20., 0., 0., 0.)),
get_normal=lambda: field, get_shear=lambda: field,
)
contact = {"centroid": (0, 1), "centroid_found": True,
"divergence_map": np.zeros((2, 2))}
sensor._Sensor__contact3d = SimpleNamespace(compute_foe=lambda *args, **kwargs: contact)
sensor.cuda = sensor.verbose = False
sensor._hhd_downsample = 1
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
sensor.valid_mask = sensor.valid_mask_hhd = None
sensor._model_frame_id = 0
sensor.compute_hhd(field)
assert sensor.calibration.calls[-1]["features"]["v"] == 1.
assert sensor.fnormal == pytest.approx(1.)
sensor.compute_contact()
assert len(sensor.calibration.calls) == 1
contact["centroid_found"] = False
sensor.compute_hhd(field)
assert len(sensor.calibration.calls) == 2
assert sensor.calibration_status == {"fn": "no_contact"}
assert sensor.calibration.calls[-1]["features"]["u"] is None
assert np.isnan(sensor.fnormal)
def test_real_model_adapter_uses_same_lifecycle_as_stateful_model():
from orisys.calibration import parse
data = {
"schema_version": 3, "unit": "N",
"identity": {"sensor_type": "finger", "sn": "test"},
"pipeline": {"stitch_resolution": [2, 2], "dis_mode": 4, "hhd_downsample": 1,
"filters": {"measurement": {"flow_ema_alpha": 1.}},
"feature_signature_sha256": "a" * 64},
"model": {"type": "global_gain/1", "input_profile": "force_delta/1",
"output_frame": "sdk_force/1", "outputs": ["fn"],
"params": {"gains_N_per_raw": {"fn": .1}, "valid_force_range_N": [0., 3.]}},
}
sensor = make_sensor()
sensor.calibration = parse(data)
sensor.bind_calibration_context(data["pipeline"])
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
result = sensor.apply_calibration_frame(frame_id=1, raw=(20., 30., 40.))
assert result.values_N == {"fn": 1.}
assert sensor.force_units == ("N", "raw", "raw")
assert sensor.calibration_status == {"fn": "ok"}
result = sensor.apply_calibration_frame(frame_id=2, raw=(20., 30., 40.), contact_found=False)
assert result.status == {"fn": "no_contact"}
def test_reversed_frame_requires_a_new_zero_reference():
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
predict(sensor, 10, 20.)
assert predict(sensor, 2, 20.).status == {"fn": "zero_unavailable"}
assert sensor.calibration.resets[-1] is None
def test_replacement_is_atomic_when_pipeline_cannot_be_validated():
sensor = make_sensor()
sensor.device_id = None
original = sensor.calibration
replacement = RecordingModel()
with pytest.raises(CalibrationError, match="pipeline mismatch"):
sensor.set_calibration(replacement, context={"signature": "different"})
assert sensor.calibration is original
sensor._calibration_context = None
with pytest.raises(CalibrationError, match="actual pipeline context"):
sensor.set_calibration(replacement)
assert sensor.calibration is original
sensor.set_calibration(replacement, context={"signature": "actual"})
assert sensor.calibration is replacement
assert sensor._calibration_reference == {}
@pytest.mark.parametrize("use_legacy", [False, True])
def test_schema3_swap_cannot_relabel_cached_newtons_as_raw_or_legacy(use_legacy):
from orisys.calibration import parse
sensor = make_sensor()
sensor.device_id = None
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
predict(sensor, 1, 25.)
assert sensor.fnormal == 1.5
legacy = parse({"schema_version": 2, "unit": "N", "channels": {
"normal": {"method": "piecewise_linear", "points": [[0., 0.], [100., 2.]],
"extrapolate": True},
}}) if use_legacy else None
sensor.set_calibration(legacy)
expected = .5 if use_legacy else 25.
assert sensor.fnormal == pytest.approx(expected)
assert sensor.force_triplets()["unfiltered"]["newton"][0] == pytest.approx(expected)
assert sensor.force_triplets()["filtered"]["raw"][0] == 25.
assert sensor.calibration_status == {}
def test_reset_invalidates_contact_cache_and_coordinate():
sensor = make_sensor()
sensor._Sensor__tracker = SimpleNamespace(reset=lambda: None)
sensor.img = None
sensor.compute_contact_valid = sensor.centroid_found = True
sensor.centroid = (1, 1)
sensor._model_contact_result = {"centroid": (1, 1)}
sensor.compute_deformation_valid = True
sensor.reset()
assert sensor.compute_contact_valid is False
assert sensor.compute_deformation_valid is False
assert sensor.centroid_found is False
assert sensor._model_contact_result is None
assert sensor._calibration_contact_uv() is None
with pytest.raises(CalibrationError, match="current deformation"):
sensor.compute_contact()
def test_schema3_rejects_measurement_filter_change_before_mutation():
sensor = make_sensor()
sensor.compute_deformation_valid = False
with pytest.raises(CalibrationError, match="measurement identity"):
sensor.configure(flow_ema_alpha=.5)
assert sensor._force_readout.flow_ema_alpha == 1.
assert sensor._calibration_context == {"signature": "actual"}
def test_readout_configuration_applies_before_and_after_first_model_frame():
sensor = make_sensor()
sensor.compute_deformation_valid = False
sensor.configure(force_median_window=3, lowpass_sample_rate_hz=20.)
sensor.set_calibration_reference({"fn": 0.}, reference_id="reference")
predict(sensor, 1, 10.)
readout = sensor._model_readouts["fn"]
assert readout.force_median_window == 3
assert readout.lowpass_sample_rate_hz == 20.
predict(sensor, 2, 20.)
predict(sensor, 3, 30.)
assert sensor.fnormal == 2.
sensor.configure(force_median_window=1)
assert readout.force_median_window == 1
predict(sensor, 4, 40.)
assert sensor.fnormal == 4.
def test_generic_global_fallback_binds_actual_mirror_identity():
from orisys.calibration import parse
from orisys.calibration_context import build_context
sensor = make_sensor()
sensor.device_id = None
sensor.mirror = True
sensor._config.update(camera={"resolution_stitch": [2, 2]}, tracker={"dis_mode": 1})
context = build_context(sensor._config, sensor.filters_state(), preprocessing={"mirror": True})
data = {"schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "test"},
"pipeline": context,
"model": {"type": "global_gain/1", "input_profile": "force_delta/1",
"output_frame": "sdk_force/1", "outputs": ["fn"],
"params": {"gains_N_per_raw": {"fn": .1}, "valid_force_range_N": [0., 3.]}}}
sensor._calibration_context = None
sensor.set_calibration(parse(data))
assert sensor._calibration_context == context
sensor.mirror = False
sensor._calibration_context = None
with pytest.raises(CalibrationError, match="feature_signature"):
sensor.set_calibration(parse(data))
@pytest.mark.parametrize("reference", [{"fn": float("nan")}, {"other": 0.}])
def test_bad_zero_reference_is_rejected_without_reset(reference):
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="valid")
with pytest.raises(ValueError):
sensor.set_calibration_reference(reference, reference_id="bad")
assert sensor._calibration_reference == {"fn": 10.}