将场加权标定模型接入回放分析
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。 新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题, 已归因并另立 studio-gate-hang。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user