将场加权标定模型接入回放分析

回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。
新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题,
已归因并另立 studio-gate-hang。
This commit is contained in:
ykcheungab
2026-10-19 19:49:36 +08:00
parent 81063213f9
commit c1e6d21030
14 changed files with 1462 additions and 23 deletions
+292
View File
@@ -0,0 +1,292 @@
"""Fake SDK surface for the S4 field-model tests (shared by two test modules).
The real SDK field model is exercised by ``orisys-sdk/tests/test_field_calibration.py``;
the app tests describe the *calls and data* the app owns. Keeping the fakes in one
place means the pass test and the integration test cannot drift apart.
"""
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
from types import SimpleNamespace
import numpy as np
from orisys_app_shared.acquisition import sdk_worker
from orisys_app_shared.analysis import service as service_module
from orisys_app_shared.analysis.model import Provenance
from orisys_app_shared.record.playback import RecordingFrame
FIELD_PROFILE = "hhd_normal_l1_field/1"
# ------------------------------------------------------------------ calibration
def write_model_file(tmp_path: Path, model_type: str) -> Path:
"""A schema 3 calibration document the SDK accepts, with its coordinate map.
``field_gain/1`` and ``spatial_gain/1`` differ only in the model block, so a test
can compare two documents that really load through the SDK's parser.
"""
from orisys.field_calibration import BASELINE_POLICY, FIELD_IDENTITY, MAPPING_POLICY
rows, cols = np.mgrid[0:4, 0:4]
uv = np.stack((cols, rows), axis=-1).astype(np.float64)
attachment = tmp_path / "map.npz"
np.savez(
attachment,
uv_by_output_px=uv,
valid_mask=np.ones((4, 4), dtype=bool),
origin_xy=np.array([0, 0]),
)
digest = hashlib.sha256(attachment.read_bytes()).hexdigest()
pipeline = {
"stitch_resolution": [4, 4],
"dis_mode": 1,
"hhd_downsample": 1,
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
"feature_signature_sha256": "a" * 64,
"geometry_sha256": "b" * 64,
"source_scale": 0.42,
"coordinates": {
"frame": "autocal_flat",
"unit": "px",
"origin_xy": [0, 0],
"map_sha256": digest,
},
}
params = {
"points_uv": [[0, 0], [3, 0], [0, 3], [3, 3]],
"gains_N_per_raw": [1.0, 1.0, 1.0, 1.0],
"triangles": [[0, 1, 2], [1, 3, 2]],
"valid_force_range_N": [0.0, 3.0],
}
if model_type == "field_gain/1":
pipeline["field"] = dict(FIELD_IDENTITY)
profile = FIELD_PROFILE
params["mapping"] = dict(MAPPING_POLICY)
params["baseline"] = dict(BASELINE_POLICY)
else:
profile = "fn_delta_autocal_uv/1"
document = {
"schema_version": 3,
"unit": "N",
"identity": {"sensor_type": "finger", "sn": "dd02-gl"},
"pipeline": pipeline,
"model": {
"type": model_type,
"input_profile": profile,
"output_frame": "sdk_force/1",
"outputs": ["fn"],
"params": params,
"artifacts": [
{"role": "coordinate_map", "path": attachment.name, "sha256": digest}
],
},
}
path = tmp_path / ("field.json" if model_type == "field_gain/1" else "spatial.json")
path.write_text(json.dumps(document), encoding="utf-8")
return path
# --------------------------------------------------------------------- recording
def minimal_recording(root: Path, *, segments: int = 1) -> Path:
"""The two text files and the segment bytes a reader needs, and nothing else."""
recording = root / "recording"
recording.mkdir(parents=True, exist_ok=True)
(recording / "frames.csv").write_text(
"frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped\n"
"1,0.0,,1,1,1,0\n2,0.033,,1,2,1,0\n",
encoding="utf-8",
)
for number in range(1, segments + 1):
(recording / f"raw_{number:04d}.mp4").write_bytes(b"")
(recording / "session.json").write_text(
json.dumps(
{
"session_id": 1,
"started_at": "2026-01-01T00:00:00+0000",
"ended_at": "",
"device": {"sensor_type": "finger", "config_ref": ""},
"stats": {"frames": 2, "video_frames": 2, "segments": segments},
}
),
encoding="utf-8",
)
return recording
def field_rows(count: int, *, segment: int = 1) -> list[RecordingFrame]:
return [
RecordingFrame(i + 1, i / 30.0, "", segment, i + 1, True)
for i in range(count)
]
# ------------------------------------------------------------------------- SDK
class FakeFieldModel:
schema_version = 3
input_profile = FIELD_PROFILE
model_type = "field_gain/1"
channels = {"fn": "N"}
fingerprint = "abcdef0123456789" * 4
def __init__(self) -> None:
self.field_baseline_N: float | None = None
class FakeFieldSensor:
"""The slice of ``Sensor`` the pass touches, simulating the SDK's window."""
def __init__(self, *, zero_frames: int = 30) -> None:
self.info = SimpleNamespace(
FNORMAL=0,
FSHEARX=1,
FSHEARY=2,
FNORMAL_RAW=3,
FSHEARX_RAW=4,
FSHEARY_RAW=5,
)
self.calibration = FakeFieldModel()
self.force_calibrated = True
self.centroid_found = True # the field model must not gate on it
self.flow = np.zeros((4, 4, 2), dtype=np.float32)
self.valid_mask = None
self.calibration_status = {"fn": "zero_unavailable"}
self.calibration_result = None
self.declared: list[str] = []
self.set_reference_calls: list[tuple] = []
self.pending = 0
self.frame_index = 0
self.fn_value = float("nan")
self.zero_frames = zero_frames
self._hhd_downsample = 2
self._hhd_grid_shape = (2, 2)
self._hhd_ref_shape = (4, 4)
def declare_unloaded_reference(self, *, reference_id: str) -> None:
self.declared.append(str(reference_id))
self.pending = 0
self.calibration.field_baseline_N = None
def set_calibration_reference(self, raw, *, reference_id) -> None: # pragma: no cover
self.set_reference_calls.append((raw, reference_id))
def read_info(self, *keys):
values = {
0: self.fn_value,
1: float("nan"),
2: float("nan"),
3: 100.0,
4: 2.0,
5: 3.0,
}
return tuple(values[key] for key in keys)
def step(self):
"""One frame of the SDK's field model: 30-frame window, then ok frames."""
self.frame_index += 1
diagnostics: dict = {}
if self.declared:
if self.pending < self.zero_frames:
self.pending += 1
diagnostics = {
"pending_reference_frames": self.pending,
"extrapolated_fraction": 0.4,
"coverage_fraction": 0.9,
"model_fingerprint": self.calibration.fingerprint,
"frame_id": self.frame_index,
}
if self.pending == self.zero_frames:
self.calibration.field_baseline_N = 0.25
diagnostics["baseline_N"] = 0.25
diagnostics["pending_reference_frames"] = 0
diagnostics["extrapolated_fraction"] = 0.3
self.calibration_status = {"fn": "zero_unavailable"}
self.fn_value = float("nan")
else:
diagnostics = {
"baseline_N": 0.25,
"extrapolated_fraction": 0.2,
"coverage_fraction": 0.97,
"model_fingerprint": self.calibration.fingerprint,
}
self.calibration_status = {"fn": "ok"}
self.fn_value = 1.5
else:
diagnostics = {"model_fingerprint": self.calibration.fingerprint}
self.calibration_result = SimpleNamespace(
diagnostics=diagnostics,
status=dict(self.calibration_status),
values_N={"fn": None if math.isnan(self.fn_value) else self.fn_value},
)
return None
class FakeChain:
def __init__(self, sensor, *, frames: int = 35) -> None:
self.sensor = sensor
self.frames = int(frames)
self.index = 0
self.open_seconds = 0.0
self.debug_files: list[str] = []
self.decode_position = 0
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
if self.index >= self.frames:
return False, None
self.index += 1
return True, np.zeros((4, 4), dtype=np.uint8)
def feed(self, frame):
return frame
class FakeReader:
def __init__(self, rows, *, segments: int = 1, base: Path) -> None:
self._rows = list(rows)
self._segments = [
base / f"raw_{number:04d}.mp4" for number in range(1, segments + 1)
]
def session(self):
return {
"session_id": 1,
"started_at": "2026-01-01T00:00:00+0000",
"ended_at": "",
"device": {"sensor_type": "finger", "config_ref": ""},
"stats": {"frames": len(self._rows), "segments": len(self._segments)},
}
def info(self):
return SimpleNamespace(frames=self._rows, video_frames=len(self._rows))
def segments(self):
return list(self._segments)
def segment_path(self, number: int):
index = int(number) - 1
return self._segments[index] if 0 <= index < len(self._segments) else None
def install_pass(monkeypatch, processor, chain: FakeChain, reader: FakeReader) -> None:
"""Make the service walk the fake chain instead of opening the SDK."""
monkeypatch.setattr(processor, "reader", lambda: reader)
monkeypatch.setattr(processor, "_segment_offset", lambda *args: (0, True))
monkeypatch.setattr(service_module, "_OpenChain", lambda *args: chain)
monkeypatch.setattr(sdk_worker, "process_chain", lambda sensor: (sensor.step(), ""))
def new_pass_state(processor, tmp_path: Path, *, count: int = 35):
rows = field_rows(count)
return service_module._PassState(
Provenance(), [1], {1: list(rows)}, tmp_path
)
@@ -10,6 +10,8 @@ from __future__ import annotations
import math
import pytest
from orisys_app_shared.analysis.model import ALGORITHM_VERSION, ForceSample
from orisys_app_shared.analysis.service import AnalysisService
@@ -97,3 +99,45 @@ def test_results_stored_under_the_old_validity_rule_are_not_served():
# Version 1 marked every frame invalid when the model lacked a zero; the cache key
# carries this string, so those results must miss the cache and be recomputed.
assert ALGORITHM_VERSION != "flow-hhd-contact-1"
def test_extrapolated_fraction_is_quality_not_validity():
"""A field estimate's extrapolation is evidence about its support, not validity.
The SDK reports the field model's extrapolated/coverage fraction in
``calibration_diagnostics`` and keeps the per-axis status ``ok``; the app stores
the fractions as quality and must keep the newton value. Reading them as
invalidity would silently drop every experimental value.
"""
reading = _reading({"fn": "ok"}, normal=3.5)
reading["calibration_diagnostics"] = {
"extrapolated_fraction": 0.42,
"coverage_fraction": 0.9,
"baseline_N": 0.25,
"model_fingerprint": "f" * 64,
}
sample = ForceSample.from_reading(reading)
assert sample.normal == pytest.approx(3.5)
assert sample.valid
assert sample.diagnostics["extrapolated_fraction"] == pytest.approx(0.42)
assert sample.field_quality() == pytest.approx((0.42, 0.9))
# The force payload is unchanged by the new evidence: quality is stored in its
# own frame payload, so an older result and this one read the same force fields.
decoded = ForceSample.decode(sample.encode())
assert decoded.normal == pytest.approx(3.5)
assert decoded.diagnostics == {}
def test_a_baseline_wait_still_keeps_the_raw_measurement():
"""The first 30 declared frames are ``zero_unavailable``: raw, not newton."""
reading = _reading({"fn": "zero_unavailable"}, normal=float("nan"))
reading["calibration_diagnostics"] = {
"pending_reference_frames": 7,
"extrapolated_fraction": 0.35,
}
sample = ForceSample.from_reading(reading)
assert sample.valid
assert sample.raw() == (1234.0, -5.0, 7.0)
assert math.isnan(sample.normal)
assert sample.diagnostics["pending_reference_frames"] == 7
+339
View File
@@ -0,0 +1,339 @@
"""S4: the field-weighted model in the replay pass, result payload and live entries.
The field model is an *experiment*: the SDK computes ``P = A * sum(g * q)`` on the
HHD grid and subtracts a baseline measured from a declared 30-frame unloaded
window. The app's part is to declare that window exactly once, keep the numeric
quality (extrapolated fraction) out of the validity decision, store it per frame,
and refuse the model at the live entries it was not wired into.
The SDK is faked in ``_field_fakes``: these tests describe the app-side contract
(which calls it makes, in which order, and what it stores), not the SDK's own
arithmetic, which ``orisys-sdk/tests/test_field_calibration.py`` owns.
"""
from __future__ import annotations
import math
from types import SimpleNamespace
import numpy as np
import pytest
from _field_fakes import (
FakeChain,
FakeFieldSensor,
FakeReader,
field_rows,
install_pass,
minimal_recording,
new_pass_state,
write_model_file,
)
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import (
SdkAcquisitionWorker,
live_field_model_rejection,
read_forces_from_sensor,
)
from orisys_app_shared.analysis import model as model_module
from orisys_app_shared.analysis.cache import AnalysisWriter, ResultCache
from orisys_app_shared.analysis.model import (
FIELD_KEY_SUFFIX,
FIELD_PAYLOAD,
FIELD_PROFILE,
FRAME_FLAG_FIELD,
ForceSample,
Provenance,
decode_frame,
encode_frame,
frame_payload_bytes,
result_key,
)
from orisys_app_shared.analysis.reader import AnalysisReader
from orisys_app_shared.analysis.service import (
AnalysisService,
ChainSpec,
calibration_profile,
)
from orisys_app_shared.record.playback import RecordingFrame
# ------------------------------------------------------------------- payload
def test_field_quality_round_trips_in_the_frame_record():
payload = {
"frame_id": 7,
"t_rel_s": 0.25,
"grid_id": 0,
"dx": np.zeros(0, dtype=np.float32),
"dy": np.zeros(0, dtype=np.float32),
"magnitude": np.zeros(0, dtype=np.float32),
"active": np.zeros(0, dtype=bool),
"field_quality": (0.25, 0.9),
}
decoded = decode_frame(encode_frame(**payload))
assert decoded.field_quality == pytest.approx((0.25, 0.9))
assert decoded.frame_id == 7 and decoded.t_rel_s == pytest.approx(0.25)
legacy = decode_frame(encode_frame(**{**payload, "field_quality": None}))
assert legacy.field_quality is None, "a frame without the flag must read as unknown"
assert frame_payload_bytes(0) == frame_payload_bytes(0, with_field_quality=False)
assert (
frame_payload_bytes(0, with_field_quality=True)
== frame_payload_bytes(0) + FIELD_PAYLOAD.size
)
assert FRAME_FLAG_FIELD != 0 and FIELD_PROFILE == "hhd_normal_l1_field/1"
def test_a_frame_with_force_and_field_quality_decodes_both():
sample = ForceSample.from_reading(
{
"normal_force": 1.5,
"normal_force_raw": 100.0,
"valid": True,
"force_calibrated": True,
"calibrated_axes": ("fn",),
"calibration_status": {"fn": "ok"},
"calibration_diagnostics": {
"extrapolated_fraction": 0.3,
"coverage_fraction": 0.9,
},
}
)
record = encode_frame(
frame_id=3,
t_rel_s=0.0,
grid_id=0,
dx=np.zeros(0, dtype=np.float32),
dy=np.zeros(0, dtype=np.float32),
magnitude=np.zeros(0, dtype=np.float32),
active=np.zeros(0, dtype=bool),
force=sample,
field_quality=sample.field_quality(),
)
decoded = decode_frame(record)
assert decoded.force is not None and decoded.force.normal == pytest.approx(1.5)
assert decoded.field_quality == pytest.approx((0.3, 0.9))
# Quality is evidence about the estimate, not part of the force payload.
assert decoded.force.diagnostics == {}
def test_the_reader_serves_field_quality_and_none_for_old_frames(tmp_path):
key = result_key(recording="r", sensor_config="s", calibration="c")
directory = tmp_path / "result"
writer = AnalysisWriter(directory, key=key)
writer.begin()
grid = writer.grid_keyframe(np.zeros(0, np.int32), np.zeros(0, np.int32))
for frame_id, quality in ((1, (0.4, 0.8)), (2, None)):
writer.add_frame(
encode_frame(
frame_id=frame_id,
t_rel_s=frame_id / 30.0,
grid_id=grid,
dx=np.zeros(0, dtype=np.float32),
dy=np.zeros(0, dtype=np.float32),
magnitude=np.zeros(0, dtype=np.float32),
active=np.zeros(0, dtype=bool),
field_quality=quality,
),
frame_id=frame_id,
t_rel_s=frame_id / 30.0,
)
writer.commit(provenance=Provenance(), counts={})
reader = AnalysisReader(directory)
try:
first = reader.frame(1)
second = reader.frame(2)
assert first.field_quality == pytest.approx((0.4, 0.8))
assert second.field_quality is None
finally:
reader.close()
# ----------------------------------------------------------------------- key
def test_field_calibration_adds_its_own_cache_component(tmp_path):
recording = minimal_recording(tmp_path)
field_file = write_model_file(tmp_path, "field_gain/1")
spatial_file = write_model_file(tmp_path, "spatial_gain/1")
assert calibration_profile(field_file) == ("field_gain/1", FIELD_PROFILE)
assert calibration_profile(spatial_file)[1] == "fn_delta_autocal_uv/1"
assert calibration_profile(None) == ("", "")
field_service = AnalysisService(
recording, chain=ChainSpec("finger", calibration=field_file)
)
spatial_service = AnalysisService(
recording, chain=ChainSpec("finger", calibration=spatial_file)
)
field_key = field_service.dependency_report()["key"]
spatial_key = spatial_service.dependency_report()["key"]
assert field_key.components["recording"] == spatial_key.components["recording"]
assert field_key.components["sensor_config"].endswith(FIELD_KEY_SUFFIX)
assert FIELD_KEY_SUFFIX not in spatial_key.components["sensor_config"]
assert field_service.dependency_report()["model_profile"] == FIELD_PROFILE
assert field_key.digest != spatial_key.digest
# ---------------------------------------------------------------------- pass
def test_field_pass_declares_one_window_and_does_not_use_contact_or_scalar_zero(
monkeypatch, tmp_path
):
sensor = FakeFieldSensor()
chain = FakeChain(sensor, frames=35)
recording = minimal_recording(tmp_path)
reader = FakeReader(field_rows(35), base=recording)
processor = AnalysisService(
recording,
chain=ChainSpec("finger"),
store_force=True,
unloaded_reference_frames=30,
)
install_pass(monkeypatch, processor, chain, reader)
state = new_pass_state(processor, tmp_path)
frames = list(processor._pass_frames(state))
assert len(frames) == 35
assert sensor.declared == ["video-unloaded-1"], "the window is declared exactly once"
assert sensor.set_reference_calls == [], "field models must not get a scalar raw zero"
# 30 pending frames are zero_unavailable; their raw counts stay the measurement.
for live in frames[:30]:
assert dict(live.force.status)["fn"] == "zero_unavailable"
assert live.force.valid and math.isnan(live.force.normal)
assert live.force.normal_raw == pytest.approx(100.0)
assert dict(frames[30].force.status)["fn"] == "ok"
assert frames[30].force.normal == pytest.approx(1.5)
# The SDK's weighted baseline, not a median of raw counts.
assert state.field_baseline == pytest.approx(0.25)
assert state.field_reference_id == "video-unloaded-1"
assert state.field_extrapolated_count == 35
# centroid_found was True throughout: the field model has no contact gate.
def test_field_pass_records_provenance_and_per_frame_quality(monkeypatch, tmp_path):
sensor = FakeFieldSensor()
chain = FakeChain(sensor, frames=35)
recording = minimal_recording(tmp_path)
reader = FakeReader(field_rows(35), base=recording)
field_file = write_model_file(tmp_path, "field_gain/1")
cache = ResultCache(tmp_path / "cache")
processor = AnalysisService(
recording,
chain=ChainSpec("finger", calibration=field_file),
cache=cache,
store_force=True,
unloaded_reference_frames=30,
)
install_pass(monkeypatch, processor, chain, reader)
outcome = processor.run()
assert outcome.complete, outcome.reason
reader_out = AnalysisReader(outcome.directory)
try:
provenance = reader_out.provenance
field = dict(provenance.field)
assert field["profile"] == FIELD_PROFILE
assert field["strategy"] == model_module.FIELD_STRATEGY
assert field["baseline_N"] == pytest.approx(0.25)
assert field["reference_id"] == "video-unloaded-1"
assert field["frames"] == 35
expected_mean = (29 * 0.4 + 0.3 + 5 * 0.2) / 35
assert field["extrapolated_mean"] == pytest.approx(expected_mean, abs=1e-6)
assert field["extrapolated_max"] == pytest.approx(0.4)
assert field["hhd"]["downsample"] == 2
assert field["hhd"]["grid_shape"] == [2, 2]
assert reader_out.counts["field_quality_frames"] == 35
# The same window's frames carry quality in the record; frame ids are the
# recording's own, not the SDK's diagnostic id.
first = reader_out.frame(1)
last = reader_out.frame(35)
assert first.field_quality == pytest.approx((0.4, 0.9))
assert last.field_quality == pytest.approx((0.2, 0.97))
assert reader_out.key_components["sensor_config"].endswith(FIELD_KEY_SUFFIX)
finally:
reader_out.close()
def test_field_model_rejects_a_multi_segment_recording(monkeypatch, tmp_path):
field_file = write_model_file(tmp_path, "field_gain/1")
recording = minimal_recording(tmp_path, segments=2)
rows = field_rows(2) + [
RecordingFrame(i + 1, (i + 2) / 30.0, "", 2, i + 1, True) for i in range(2)
]
sensor = FakeFieldSensor()
chain = FakeChain(sensor, frames=4)
reader = FakeReader(rows, segments=2, base=recording)
cache = ResultCache(tmp_path / "cache")
processor = AnalysisService(
recording,
chain=ChainSpec("finger", calibration=field_file),
cache=cache,
store_force=True,
unloaded_reference_frames=30,
)
install_pass(monkeypatch, processor, chain, reader)
with pytest.raises(ValueError, match="single-segment"):
processor.run()
assert not list((tmp_path / "cache").glob("*/meta.json"))
# --------------------------------------------------------------------- live
def test_live_entries_refuse_a_field_model_file(tmp_path):
field_file = write_model_file(tmp_path, "field_gain/1")
rejection = live_field_model_rejection(field_file)
assert FIELD_PROFILE in rejection and "replay" in rejection.lower()
box = SdkAcquisitionWorker()
with pytest.raises(RuntimeError, match="hhd_normal_l1_field/1"):
box.start(0, config_ref=tmp_path / "unused.json", cal_path=field_file)
finger = FingerAcquisitionWorker()
with pytest.raises(RuntimeError, match="hhd_normal_l1_field/1"):
finger.start(0, config_ref=tmp_path / "unused.json", cal_path=field_file)
# A hot swap is refused before the device is touched too (the sensor double only
# has to exist: the refusal comes before any SDK call on it).
box._pending_calibration = str(field_file)
with pytest.raises(RuntimeError, match="hhd_normal_l1_field/1"):
box._consume_pending_calibration(sensor=SimpleNamespace())
finger._pending_calibration = str(field_file)
with pytest.raises(RuntimeError, match="hhd_normal_l1_field/1"):
finger._consume_pending_calibration(sensor=SimpleNamespace())
def test_a_scalar_calibration_is_still_accepted_by_the_live_entry(tmp_path):
spatial_file = write_model_file(tmp_path, "spatial_gain/1")
assert live_field_model_rejection(spatial_file) == ""
assert live_field_model_rejection(None) == ""
assert live_field_model_rejection(tmp_path / "missing.json") == ""
def test_the_field_reading_carries_quality_without_touching_validity():
sensor = FakeFieldSensor()
sensor.declare_unloaded_reference(reference_id="unloaded-1")
sensor.step()
reading = read_forces_from_sensor(sensor)
assert reading["calibration_status"] == {"fn": "zero_unavailable"}
assert reading["calibration_diagnostics"]["pending_reference_frames"] == 1
sample = ForceSample.from_reading(reading)
assert sample.valid, "a missing baseline is not an invalid raw measurement"
assert math.isnan(sample.normal) and sample.normal_raw == pytest.approx(100.0)
# Quality is available while the baseline is pending: it describes the support of
# this frame's integral, not whether the baseline is ready.
assert sample.field_quality() == pytest.approx((0.4, 0.9))
for _ in range(30):
sensor.step()
ready = read_forces_from_sensor(sensor)
assert ready["calibration_status"] == {"fn": "ok"}
ready_sample = ForceSample.from_reading(ready)
assert ready_sample.normal == pytest.approx(1.5)
assert ready_sample.field_quality() == pytest.approx((0.2, 0.97))
# A reading with no diagnostics at all is *unknown*, never 0 extrapolation.
reading.pop("calibration_diagnostics")
assert ForceSample.from_reading(reading).field_quality() is None
+36
View File
@@ -182,3 +182,39 @@ def test_video_unloaded_reference_is_explicit_and_requires_the_whole_interval(mo
assert len(list(processor._pass_frames(state))) == frame_count
assert applied == [{"fn": 100.}]
assert state.provenance.counts["unloaded_reference_frames"] == 30
def test_a_field_model_reading_keeps_raw_and_reports_weighted_quality(tmp_path):
"""The field model is a different zeroing protocol, not a different validity rule.
Its reading carries the SDK's weighted diagnostics; the app must keep the raw
counts while the baseline is pending and report the extrapolated fraction as
quality (0.0 is a real value, "no diagnostics" is unknown).
"""
from orisys_app_shared.acquisition.sdk_worker import read_forces_from_sensor
from orisys_app_shared.analysis.model import ForceSample
from _field_fakes import FakeFieldSensor # tests' own SDK double
sensor = FakeFieldSensor()
sensor.declare_unloaded_reference(reference_id="unloaded-1")
sensor.step()
reading = read_forces_from_sensor(sensor)
assert reading["calibration_status"] == {"fn": "zero_unavailable"}
assert reading["calibration_diagnostics"]["pending_reference_frames"] == 1
sample = ForceSample.from_reading(reading)
assert sample.valid and sample.normal_raw == pytest.approx(100.0)
assert math.isnan(sample.normal)
# Quality is a statement about the frame's support, available while the baseline
# is still pending; only absent diagnostics mean "unknown".
assert sample.field_quality() == pytest.approx((0.4, 0.9))
reading.pop("calibration_diagnostics")
assert ForceSample.from_reading(reading).field_quality() is None
for _ in range(30):
sensor.step()
ready = read_forces_from_sensor(sensor)
assert ready["calibration_status"] == {"fn": "ok"}
ready_sample = ForceSample.from_reading(ready)
assert ready_sample.normal == pytest.approx(1.5)
assert ready_sample.field_quality() == pytest.approx((0.2, 0.97))