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

回放按声明的 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
@@ -43,6 +43,7 @@ from orisys_app_shared.acquisition.sdk_worker import (
_capture_sequence,
calibration_digest,
capture_status_of,
live_field_model_rejection,
loaded_calibration_version,
loaded_path,
process_chain,
@@ -408,6 +409,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
) -> FixedCameraMode:
# Non-blocking: a previous session that is stuck opening must never hold
# the caller (the GUI thread) hostage.
rejection = live_field_model_rejection(cal_path)
if rejection:
raise RuntimeError(rejection)
self.stop()
previous = self._thread
ok, detail = orisys_importable()
@@ -643,6 +647,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
session = self._session_id
if path is None or sensor is None:
return
rejection = live_field_model_rejection(path)
if rejection:
raise RuntimeError(rejection)
if not hasattr(sensor, "set_calibration"):
raise RuntimeError("sensor does not support set_calibration")
if device_id:
@@ -95,7 +95,7 @@ def _high_resolution_timer():
if winmm is not None:
try:
winmm.timeEndPeriod(1)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001, S110 - a failed timer restore is not fatal
pass
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
@@ -109,6 +109,7 @@ __all__ = [
"load_fixed_mode",
"loaded_calibration_version",
"loaded_path",
"live_field_model_rejection",
"orisys_importable",
"process_chain",
"read_depth",
@@ -394,6 +395,8 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
if arr.size == 0:
depth = None
calibrated = _sensor_force_calibrated(sensor, config_ref=config_ref)
result = getattr(sensor, "calibration_result", None)
diagnostics = getattr(result, "diagnostics", None)
return {
"normal_force": float(fn),
"shear_x": float(fx),
@@ -410,6 +413,13 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
"invalid_reason": error,
"raw_available": read_raw,
"calibration_status": dict(getattr(sensor, "calibration_status", {}) or {}),
# Evidence from the calibration model itself (the field model's
# extrapolated/coverage fractions, baseline and fingerprint). It is carried,
# never interpreted here: whether a value is displayed is the model status's
# decision, and quality is not validity.
"calibration_diagnostics": dict(diagnostics or {})
if isinstance(diagnostics, dict)
else {},
}
@@ -692,6 +702,41 @@ def loaded_calibration_version(sensor) -> str:
return calibration_digest(calibration)
#: The field-weighted model's input profile. Kept as a literal here because the
#: acquisition package must not import SDK modules unless a run actually opens one;
#: ``analysis.model`` owns the app's copy of the same name for the replay path.
_FIELD_INPUT_PROFILE = "hhd_normal_l1_field/1"
def live_field_model_rejection(path: str | Path | None) -> str:
"""Why a chosen calibration file must not be used for a **live** run, or ``""``.
The field-weighted model (``field_gain/1`` / ``hhd_normal_l1_field/1``) is a
replay-only experiment: it needs a declared 30-frame unloaded window and the
same-frame HHD field, neither of which the live acquisition loop supplies. The
live entry therefore refuses it by name, before the device is opened, instead of
loading it and failing per frame. An unreadable file yields ``""`` — the open
path already reports that with its own reason, and this helper must not replace
one failure with another.
"""
if path is None or not str(path).strip():
return ""
try:
import orisys.calibration as sdk_calibration
model = sdk_calibration.load(str(path), strict=True)
except Exception: # noqa: BLE001 - the open path reports an unloadable file itself
return ""
if str(getattr(model, "input_profile", "") or "") != _FIELD_INPUT_PROFILE:
return ""
return (
"the field-weighted calibration model (hhd_normal_l1_field/1) is replay-only: "
"it needs a declared 30-frame unloaded window and the same-frame HHD field, "
"which the live chain does not provide. Choose a scalar calibration for live "
f"operation ({Path(str(path)).name})."
)
class SdkAcquisitionWorker(RecordSinkMixin):
"""Background loop: Sensor.get_img + deformation + force readout."""
@@ -838,6 +883,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# Non-blocking: a previous session that is stuck opening the device must
# never hold the caller (the GUI thread) hostage.
del device_id # box/mini open path has no Finger-style serial check
rejection = live_field_model_rejection(cal_path)
if rejection:
raise RuntimeError(rejection)
self.stop()
previous = self._thread
ok, detail = orisys_importable()
@@ -1023,6 +1071,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
session = self._session_id
if path is None or sensor is None:
return
rejection = live_field_model_rejection(path)
if rejection:
raise RuntimeError(rejection)
if not hasattr(sensor, "set_calibration"):
raise RuntimeError("sensor does not support set_calibration")
sensor.set_calibration(path)
@@ -1050,7 +1101,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
return
try:
sensor.disconnect()
except Exception:
except Exception: # noqa: BLE001, S110 - teardown must not mask the pass result
pass
def _close_all_sensors(self) -> None:
+122 -2
View File
@@ -57,6 +57,17 @@ differently now. This is the one channel whose ``Provenance.sources`` entry is
``recomputed`` on the *synthetic* path and ``recorded`` beside a real ``forces.csv``:
the two must never be confused, which is what the field is for.
**The field model's quality is stored beside the force, not inside it.** The
field-weighted model is an experiment: its estimate is an area-weighted integral of the
HHD curl-free field over a mapped gain, and part of that support may be extrapolated
from the convex hull boundary. The SDK reports that as an extrapolated fraction; the
pass stores it per frame behind :data:`FRAME_FLAG_FIELD` with an 8-byte
:data:`FIELD_PAYLOAD` (extrapolated, coverage). It is *evidence about the estimate*,
never a validity statement: a frame with a non-zero extrapolated fraction still has a
newton value. A frame without the flag has **unknown** quality (a scalar model, an
older result) and must be displayed that way, not as 0. The total per-frame cost is
8 B against the 4,048 B a frame already costs.
Sparse storage was measured and rejected: 4-byte indices per non-zero pixel cost
910 B for the worst non-empty frame against 486 B for the whole quantised map, because
a mostly-zero byte plane is nearly free for zlib while pixel indices are not. Island
@@ -92,9 +103,14 @@ __all__ = [
"CODEC_ZLIB",
"DEPTH_BITS",
"DEPTH_HEADER",
"FIELD_KEY_SUFFIX",
"FIELD_PAYLOAD",
"FIELD_PROFILE",
"FIELD_STRATEGY",
"FORCE_PAYLOAD",
"FRAME_FLAG_DEPTH",
"FRAME_FLAG_DROPPED",
"FRAME_FLAG_FIELD",
"FRAME_FLAG_FORCE",
"FRAME_FLAG_VALIDITY",
"FRAME_HEADER",
@@ -145,6 +161,20 @@ ALGORITHM_VERSION = "flow-hhd-contact-2"
#: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents.
UNIT = "uv_px"
#: The field-weighted model's input profile (SDK calibration contract). The app only
#: names it: the model is loaded and run by the SDK, and this string is what tells the
#: pass that the unloaded reference is a 30-frame weighted window rather than a scalar
#: raw zero. It also keys the result, because that window is a property of this video.
FIELD_PROFILE = "hhd_normal_l1_field/1"
#: Cache-key component appended to ``sensor_config`` when the chosen calibration is the
#: field-weighted model. A spatial result and a field result must never share a key:
#: the force on the same footage is a different experiment with a different baseline.
#: It is added only for an explicitly chosen file whose ``model.input_profile`` is
#: :data:`FIELD_PROFILE`; the scalar models' keys are unchanged.
FIELD_KEY_SUFFIX = ";field-weighted=1"
#: Version of the app's field strategy, written to the provenance for display/audit.
FIELD_STRATEGY = "field-weighted-baseline-1"
#: ``frame_id``, ``t_rel_s``, grid keyframe id, flags, candidate count, depth
#: non-zero count, depth minimum, depth maximum. The three trailing pad bytes keep
#: the value arrays 8-byte aligned inside a record.
@@ -160,6 +190,12 @@ FRAME_FLAG_DEPTH = 0x04
#: pass was not asked to collect it (a recording, whose forces live in its own
#: ``forces.csv``) or the chain exposed none.
FRAME_FLAG_FORCE = 0x08
#: The frame carries the field model's quality payload (:data:`FIELD_PAYLOAD`),
#: appended after the force payload. This is *evidence about the estimate* — how much
#: of its support was extrapolated — not a validity statement: the newton value is
#: still governed by the calibration status and must not be dropped because the
#: extrapolated fraction is non-zero.
FRAME_FLAG_FIELD = 0x10
#: The force payload: FN, FX, FY in newtons, then the raw triplet, then one byte of
#: validity and one of "a calibration curve was applied". Appended **last** in the
@@ -168,6 +204,13 @@ FRAME_FLAG_FORCE = 0x08
#: always ignored trailing bytes.
FORCE_PAYLOAD = struct.Struct("<ffffffBB2x")
#: Field quality: extrapolated fraction, coverage fraction (both 0..1). Appended after
#: the force payload like the force payload is appended after depth: a reader that
#: does not know :data:`FRAME_FLAG_FIELD` still decodes every earlier field. A frame
#: without the flag has *unknown* quality (an older result, or a scalar model) — not
#: zero extrapolation.
FIELD_PAYLOAD = struct.Struct("<ff")
#: magic, kind, codec, count, height, width, flags, reserved, raw bytes, digest
KEYFRAME_HEADER = struct.Struct("<4sBBIIIBBQ8s")
KEYFRAME_MAGIC = b"ORAK"
@@ -515,6 +558,11 @@ class ForceSample:
#: The calibration model's per-axis status for this frame, as ``(axis, status)``
#: pairs. Kept in memory for the pass's counters; not part of the stored payload.
status: tuple[tuple[str, str], ...] = field(default=(), compare=False)
#: The calibration model's own evidence for this frame (the field model's
#: extrapolated/coverage fractions, baseline and fingerprint). Kept in memory for
#: the pass to store as quality; not part of the force payload, and never a reason
#: to drop a finite newton value.
diagnostics: dict[str, Any] = field(default_factory=dict, compare=False)
@property
def any_finite(self) -> bool:
@@ -537,6 +585,23 @@ class ForceSample:
def raw(self) -> tuple[float, float, float]:
return (self.normal_raw, self.shear_x_raw, self.shear_y_raw)
def field_quality(self) -> tuple[float, float] | None:
"""This frame's ``(extrapolated_fraction, coverage_fraction)``, or ``None``.
Only the field model reports these. ``None`` means *unknown* (an older result,
a scalar model, or a frame whose model gave no field evidence) and must never
be displayed as 0.
"""
extrapolated = self.diagnostics.get("extrapolated_fraction")
coverage = self.diagnostics.get("coverage_fraction")
try:
values = (float(extrapolated), float(coverage))
except (TypeError, ValueError):
return None
if not all(value == value and abs(value) != float("inf") for value in values):
return None
return tuple(min(1.0, max(0.0, value)) for value in values) # type: ignore[return-value]
def values(self, unit: str = "raw") -> tuple[float, float, float]:
"""The triplet in ``unit``: newtons when a calibration was applied, else raw.
@@ -565,6 +630,7 @@ class ForceSample:
return _NAN
return float(reading.get(key, _NAN))
diagnostics = reading.get("calibration_diagnostics")
return cls(
normal=newton("fn", "normal_force"),
shear_x=newton("fx", "shear_x"),
@@ -575,6 +641,7 @@ class ForceSample:
valid=bool(reading.get("valid", True)),
calibrated=bool(reading.get("force_calibrated", False)),
status=tuple(sorted(statuses.items())),
diagnostics=dict(diagnostics or {}),
)
def encode(self) -> bytes:
@@ -628,8 +695,9 @@ def encode_frame(
depth_nonzero: int = 0,
dropped: bool = False,
force: ForceSample | None = None,
field_quality: Sequence[float] | None = None,
) -> bytes:
"""One frame record: a fixed header, then the value arrays, depth and force.
"""One frame record: a fixed header, then the value arrays, depth, force and quality.
Arrays are little-endian regardless of host order, so a result written on one
machine reads back identically on another. ``dx``/``dy``/``magnitude`` are stored
@@ -645,6 +713,12 @@ def encode_frame(
``force`` is the chain's own sample for this frame, or ``None`` when the pass did not
collect one (``FRAME_FLAG_FORCE``). It is written last, after every field an older
reader knows, so the record stays readable by a build without this flag.
``field_quality`` is the field model's ``(extrapolated_fraction, coverage_fraction)``
for this frame, or ``None`` when the model reported none (a scalar model, an older
result, or a frame without field evidence). It is appended *after* the force payload
for the same reason the force payload follows depth: an older reader never parses
past a field it knows, and a chunk's offset table delimits the record.
"""
dx = np.ascontiguousarray(dx, dtype="<f4").reshape(-1)
dy = np.ascontiguousarray(dy, dtype="<f4").reshape(-1)
@@ -660,6 +734,16 @@ def encode_frame(
raise ValueError(f"grid id {grid_id} does not fit the frame header")
if count > 0xFFFF:
raise ValueError(f"{count} candidates do not fit the frame header")
quality_payload = b""
if field_quality is not None:
values = tuple(float(value) for value in field_quality)
if len(values) != 2 or not all(value == value for value in values):
raise ValueError(
"field quality must be two finite fractions (extrapolated, coverage)"
)
quality_payload = FIELD_PAYLOAD.pack(
min(1.0, max(0.0, values[0])), min(1.0, max(0.0, values[1]))
)
flags = 0
if validity_digest is not None:
flags |= FRAME_FLAG_VALIDITY
@@ -667,6 +751,8 @@ def encode_frame(
flags |= FRAME_FLAG_DROPPED
if force is not None:
flags |= FRAME_FLAG_FORCE
if quality_payload:
flags |= FRAME_FLAG_FIELD
depth_payload = b""
depth_low = depth_high = 0.0
if depth is not None:
@@ -696,6 +782,7 @@ def encode_frame(
parts.append(depth_payload)
if force is not None:
parts.append(force.encode())
parts.append(quality_payload)
return b"".join(parts)
@@ -720,6 +807,9 @@ class DecodedFrame:
#: ``None`` when the frame carried no force (an older result, a recording whose forces
#: live in its own ``forces.csv``, or a chain that produced none).
force: ForceSample | None = None
#: ``None`` when the frame carried no field quality (an older result, a scalar model,
#: or no field evidence). Not the same fact as "the extrapolated fraction was 0".
field_quality: tuple[float, float] | None = None
def depth_map(self) -> np.ndarray | None:
"""This frame's depth map, or ``None`` when it had no depth channel."""
@@ -771,6 +861,14 @@ def decode_frame(
if len(view) < cursor + FORCE_PAYLOAD.size:
raise ValueError("a frame says it carries force but is shorter than one sample")
force = ForceSample.decode(view, cursor)
cursor += FORCE_PAYLOAD.size
field_quality = None
if flags & FRAME_FLAG_FIELD:
if len(view) < cursor + FIELD_PAYLOAD.size:
raise ValueError("a frame says it carries field quality but is shorter than it")
field_quality = tuple(
float(value) for value in FIELD_PAYLOAD.unpack_from(view, cursor)
)
return DecodedFrame(
frame_id=int(frame_id),
t_rel_s=float(t_rel),
@@ -785,6 +883,7 @@ def decode_frame(
validity_digest=validity,
depth=depth,
force=force,
field_quality=field_quality,
)
@@ -795,18 +894,22 @@ def frame_payload_bytes(
with_depth: bool = False,
depth_stored: int = 0,
with_force: bool = False,
with_field_quality: bool = False,
) -> int:
"""Stored bytes of one frame record at ``candidates`` sample locations.
``with_depth`` adds the 6-byte depth header every frame that carries a channel pays;
``depth_stored`` adds a payload of that many bytes on top, for a caller sizing the
memory bound rather than the common case. ``with_force`` adds the fixed force sample.
memory bound rather than the common case. ``with_force`` adds the fixed force sample;
``with_field_quality`` adds the fixed field quality payload.
"""
total = FRAME_HEADER.size + int(candidates) * 12 + (int(candidates) + 7) // 8
if with_validity:
total += 8
if with_force:
total += FORCE_PAYLOAD.size
if with_field_quality:
total += FIELD_PAYLOAD.size
if with_depth:
total += DEPTH_HEADER.size + max(0, int(depth_stored))
return total
@@ -872,6 +975,11 @@ class FramePayload:
#: which is why the provenance — not this field — is what a chart must label a trace
#: from.
force: ForceSample | None = None
#: The field model's ``(extrapolated_fraction, coverage_fraction)`` for this frame,
#: or ``None`` when the result carries none (a scalar model, an older result). This
#: is quality evidence, not validity: a finite newton value with a non-zero
#: extrapolated fraction is still a value.
field_quality: tuple[float, float] | None = None
@property
def has_depth(self) -> bool:
@@ -928,6 +1036,7 @@ class FramePayload:
depth_nonzero=self.depth_nonzero,
dropped=self.dropped,
force=self.force,
field_quality=self.field_quality,
)
@@ -1035,9 +1144,20 @@ class Provenance:
#: that time is not throughput.
open_seconds: float = 0.0
warnings: list[str] = field(default_factory=list)
#: True when the pass reached the end of every declared segment and committed.
complete: bool = False
stopped_reason: str = ""
counts: dict[str, Any] = field(default_factory=dict)
#: The field-weighted experiment behind this result, when it has one. Keys:
#: ``profile``/``strategy`` (what ran), ``model_fingerprint`` (content identity of
#: the model), ``reference_id``/``baseline_N`` (the declared unloaded window), and
#: ``frames``/``extrapolated_mean``/``extrapolated_max``/``coverage_mean`` (per-frame
#: quality statistics) plus ``hhd`` (effective grid shape and downsample the
#: weighted sum ran on). Empty for a scalar model or an older result; a consumer
#: must display that as *unknown*, never as zero extrapolation.
#: Declared last on purpose: an attribute named ``field`` would shadow the
#: ``dataclasses.field`` helper for every attribute after it in this class body.
field: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return dict(self.__dict__)
@@ -533,12 +533,22 @@ class AnalysisReader:
depth_max=depth_max,
depth_stored_bytes=depth_stored,
force=decoded.force,
field_quality=decoded.field_quality,
)
def force(self, frame_id: int) -> ForceSample | None:
"""One frame's stored force sample, or ``None`` when the frame carries none."""
return self.frame(int(frame_id)).force
def field_quality(self, frame_id: int) -> tuple[float, float] | None:
"""One frame's ``(extrapolated_fraction, coverage_fraction)``, or ``None``.
``None`` is the truthful answer for a result that predates the field-model
diagnostics (or for a scalar model): the extrapolated fraction was not
recorded, which is not the same as zero. Display it as unknown.
"""
return self.frame(int(frame_id)).field_quality
@property
def has_force(self) -> bool:
"""Whether *any* frame in this result carries a force sample.
+307 -14
View File
@@ -61,6 +61,9 @@ from orisys_app_shared.analysis.cache import (
)
from orisys_app_shared.analysis.model import (
ALGORITHM_VERSION,
FIELD_KEY_SUFFIX,
FIELD_PROFILE,
FIELD_STRATEGY,
KEYFRAME_MASK,
ForceSample,
Provenance,
@@ -86,6 +89,7 @@ __all__ = [
"ChainSpec",
"LiveFrame",
"Progress",
"calibration_profile",
"chain_spec_for",
"recording_config_sha256",
"resolve_finger_export_dir",
@@ -136,6 +140,116 @@ def recording_config_sha256(config_ref: str | Path | None) -> str:
return ""
def calibration_profile(path: str | Path | None) -> tuple[str, str]:
"""``(model.type, model.input_profile)`` of a calibration file, or ``("", "")``.
A plain JSON lookup on purpose: the result key has to be computed *before* anything
opens the SDK (a cache hit is a file read, and a pass that turns out to be cached
must not pay for a chain open). Schema 1/2 documents have no ``model`` block and
answer ``("", "")``, which is the same answer an unreadable file gives — the open
path reports that failure with its own reason.
"""
if path is None:
return ("", "")
try:
data = json.loads(Path(str(path)).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return ("", "")
if not isinstance(data, dict):
return ("", "")
model = data.get("model")
if not isinstance(model, dict):
return ("", "")
return (
str(model.get("type") or ""),
str(model.get("input_profile") or ""),
)
def _chain_raw_sensor(chain: Any) -> Any:
"""The SDK ``Sensor`` behind a chain (a finger chain wraps it in a tactile one)."""
return getattr(chain.sensor, "raw_sensor", chain.sensor)
def _chain_field_model(chain: Any) -> Any:
"""The loaded calibration model when it is the field-weighted one, else ``None``."""
model = getattr(_chain_raw_sensor(chain), "calibration", None)
if str(getattr(model, "input_profile", "") or "") != FIELD_PROFILE:
return None
return model
def _field_diagnostics(sensor: Any) -> dict[str, Any]:
"""The calibration model's diagnostics for the frame it just answered.
The SDK exposes them as ``Sensor.calibration_result.diagnostics``; a sensor that
has no model (or a model that reports none) answers ``{}``. Missing diagnostics
mean *unknown quality*, never zero extrapolation.
"""
result = getattr(sensor, "calibration_result", None)
diagnostics = getattr(result, "diagnostics", None)
return dict(diagnostics) if isinstance(diagnostics, dict) else {}
def _hhd_identity(sensor: Any) -> dict[str, Any]:
"""The HHD grid the weighted sum actually ran on, for the result's provenance.
Read from the open sensor (the effective values), not from the configuration
document: the SDK may accept a calibration whose declared ``hhd_downsample``
differs from the runtime one, and the area factor ``A`` depends on the grid that
actually ran.
"""
identity: dict[str, Any] = {}
downsample = getattr(sensor, "_hhd_downsample", None)
if downsample is not None:
try:
identity["downsample"] = int(downsample)
except (TypeError, ValueError):
pass
for key, attribute in (("grid_shape", "_hhd_grid_shape"), ("reference_shape", "_hhd_ref_shape")):
value = getattr(sensor, attribute, None)
if value is None:
continue
try:
identity[key] = [int(size) for size in value]
except (TypeError, ValueError):
continue
return identity
def _accept_field_diagnostics(
state: _PassState, sensor: Any, *, zero_frames: int
) -> None:
"""Keep this frame's weighted-window status and quality statistics in the state."""
diagnostics = _field_diagnostics(sensor)
if zero_frames and state.field_reference_id and state.field_baseline is None:
baseline = diagnostics.get("baseline_N")
if baseline is not None and np.isfinite(float(baseline)):
state.field_baseline = float(baseline)
pending = diagnostics.get("pending_reference_frames")
if pending is not None:
state.provenance.counts["field_pending_reference_frames"] = int(pending)
extrapolated = diagnostics.get("extrapolated_fraction")
if extrapolated is not None:
try:
value = min(1.0, max(0.0, float(extrapolated)))
except (TypeError, ValueError):
value = None
if value is not None:
state.field_extrapolated_sum += value
state.field_extrapolated_max = max(state.field_extrapolated_max, value)
state.field_extrapolated_count += 1
coverage = diagnostics.get("coverage_fraction")
if coverage is not None:
try:
value = min(1.0, max(0.0, float(coverage)))
except (TypeError, ValueError):
value = None
if value is not None:
state.field_coverage_sum += value
state.field_coverage_count += 1
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
"""``assets/30fps-0703`` beside a finger ``sensor.json``, when it is complete.
@@ -509,6 +623,18 @@ class _PassState:
frames_expected: int = 0
frames_written: int = 0
started: float = 0.0
#: The field-weighted experiment this pass ran, when the loaded model is
#: ``field_gain/1``. Empty/false for every scalar model.
field_profile: str = ""
field_fingerprint: str = ""
field_reference_id: str = ""
field_baseline: float | None = None
field_hhd: dict[str, Any] = field(default_factory=dict)
field_extrapolated_sum: float = 0.0
field_extrapolated_max: float = 0.0
field_extrapolated_count: int = 0
field_coverage_sum: float = 0.0
field_coverage_count: int = 0
class AnalysisService:
@@ -631,6 +757,14 @@ class AnalysisService:
from orisys_app_shared.analysis.bare_video import calibration_identity
calibration = calibration_identity(self.chain.calibration)
# The chosen file's model, read as JSON (never by opening the SDK): the field
# model's unloaded baseline is measured from *this* video's frames, so a field
# result and a scalar result on the same footage must not share a cache entry.
model_type, model_profile = (
("", "")
if self.chain.calibration_disabled
else calibration_profile(self.chain.calibration)
)
zero_frames = self._zero_reference_frames()
key = result_key(
recording=identity,
@@ -638,6 +772,7 @@ class AnalysisService:
self.chain.identity()
+ (";unloaded-reference=30" if zero_frames else "")
+ (";force=recomputed" if self.store_force else "")
+ (FIELD_KEY_SUFFIX if model_profile == FIELD_PROFILE else "")
),
calibration=calibration,
reference_frame=self.reference_frame,
@@ -650,6 +785,8 @@ class AnalysisService:
"segment_sizes": sizes,
"recording_identity": identity,
"calibration_id": calibration,
"model_type": model_type,
"model_profile": model_profile,
"key": key,
}
@@ -680,6 +817,63 @@ class AnalysisService:
f"frames ({span}); a reset that cannot be applied is not a result"
)
def _require_field_single_segment(self, facts: dict[str, Any]) -> None:
"""Refuse a field-model pass over more than one segment.
The field model's baseline is one declared, consecutive 30-frame window of the
*same* stream. A segment boundary reopens the chain (and the tracker), so a
window that crossed it would be a different measurement wearing the same
reference id. Refusing is the only truthful answer; silently re-declaring would
discard the first segment's baseline and silently rebuilding it would invent
one.
"""
if facts.get("model_profile") != FIELD_PROFILE:
return
segments = list(facts.get("segments") or ())
if len(segments) <= 1:
return
raise ValueError(
"field_gain/1 requires a single-segment recording: its weighted unloaded "
"baseline is one declared window of the same stream and cannot be rebuilt "
f"across a segment boundary ({len(segments)} segments in {self.recording})"
)
@staticmethod
def _field_provenance(state: _PassState) -> dict[str, Any]:
"""The field-weighted experiment behind this result, as stored evidence.
Kept even for an abandoned pass: a cancelled analysis still has to say which
model it was running and whether its unloaded window completed.
"""
extrapolated_mean = (
state.field_extrapolated_sum / state.field_extrapolated_count
if state.field_extrapolated_count
else None
)
coverage_mean = (
state.field_coverage_sum / state.field_coverage_count
if state.field_coverage_count
else None
)
return {
"profile": state.field_profile,
"strategy": FIELD_STRATEGY,
"model_fingerprint": state.field_fingerprint,
"reference_id": state.field_reference_id,
"baseline_N": state.field_baseline,
"frames": state.field_extrapolated_count,
"extrapolated_mean": (
None if extrapolated_mean is None else round(extrapolated_mean, 6)
),
"extrapolated_max": (
round(state.field_extrapolated_max, 6)
if state.field_extrapolated_count
else None
),
"coverage_mean": None if coverage_mean is None else round(coverage_mean, 6),
"hhd": dict(state.field_hhd),
}
# ------------------------------------------------------------------------ pass
def recompute(self) -> Iterator[LiveFrame]:
"""Re-run the chain over every segment and yield what it computes now.
@@ -692,6 +886,7 @@ class AnalysisService:
key: ResultKey = facts["key"]
grouped = self.rows_by_segment(facts["info"].frames)
self._require_reference(grouped)
self._require_field_single_segment(facts)
working = self._make_working_directory(key)
state = _PassState(
provenance=self._provenance(key, facts),
@@ -724,6 +919,7 @@ class AnalysisService:
# Checked before anything is written, so a caller error does not leave a
# directory behind at all.
self._require_reference(grouped)
self._require_field_single_segment(facts)
working = self._make_working_directory(key)
writer = AnalysisWriter(
self.cache.directory(key), key=key, chunk_frames=self.chunk_frames
@@ -762,6 +958,10 @@ class AnalysisService:
state.reasons.append(f"{type(exc).__name__}: {exc}")
finally:
self._discard_working_directory(working)
if state.field_profile:
# Written for complete *and* abandoned results: a cancelled field pass still
# has to name its model and whether the unloaded window had completed.
provenance.field = self._field_provenance(state)
if (
not state.reasons
and self.reference_frame is not None
@@ -784,6 +984,9 @@ class AnalysisService:
writer.bytes_written / state.frames_written, 2
)
counts["open_seconds"] = provenance.open_seconds
if state.field_profile:
counts["field_frames"] = state.field_extrapolated_count
counts["field_baseline_ready"] = state.field_baseline is not None
provenance.decode_order_offsets = dict(state.offsets)
distinct = sorted(set(state.offsets.values()))
provenance.decode_order_offset = distinct[0] if len(distinct) == 1 else None
@@ -843,6 +1046,25 @@ class AnalysisService:
provenance.warnings.append(f"segment_frame_gap:{number}")
with _OpenChain(self.chain, path, state.working) as chain:
zero_samples = []
raw_sensor = _chain_raw_sensor(chain)
field_model = _chain_field_model(chain)
field_weighted = field_model is not None
if field_weighted:
# The baseline is one window of the same stream; a second segment
# would reopen the tracker and could only re-declare it. Refuse
# rather than silently keep the first window or rebuild a new one.
if len(state.segments) > 1:
raise ValueError(
"field_gain/1 requires a single-segment recording: its "
"weighted unloaded baseline is one declared window of the "
"same stream and cannot be rebuilt across a segment boundary"
)
if not state.field_profile:
state.field_profile = FIELD_PROFILE
state.field_fingerprint = str(
getattr(field_model, "fingerprint", "") or ""
)
state.field_hhd = _hhd_identity(raw_sensor)
offset, measured = self._segment_offset(chain, number, state.offsets)
state.offsets[str(number)] = offset
state.sources.add("measured" if measured else "declared")
@@ -888,25 +1110,79 @@ class AnalysisService:
provenance.counts["reset_count"] = (
provenance.counts.get("reset_count", 0) + 1
)
if (
field_weighted
and zero_frames
and not state.field_reference_id
and (
self.reference_frame is None
or int(row.frame_id) >= int(self.reference_frame)
)
):
# The tracker reset cleared the model's session too, so the
# declaration must follow it and *precede* this frame's chain
# call: otherwise the first frame of the window is consumed
# before the model knows a window started.
declare = getattr(raw_sensor, "declare_unloaded_reference", None)
if not callable(declare):
raise ValueError(
"this SDK cannot declare the field model's weighted "
"unloaded window"
)
state.field_reference_id = (
f"video-unloaded-{int(row.frame_id)}"
)
declare(reference_id=state.field_reference_id)
provenance.counts["field_reference_frames"] = zero_frames
depth, error = process_chain(chain.sensor)
if error:
provenance.warnings.append(f"chain:{error}")
state.reasons.append(f"chain_failed:{row.frame_id}")
return
if zero_frames and (self.reference_frame is None or row.frame_id >= self.reference_frame) and len(zero_samples) < zero_frames:
sensor = getattr(chain.sensor, "raw_sensor", chain.sensor)
model = getattr(sensor, "calibration", None)
if getattr(model, "schema_version", None) != 3:
raise ValueError("unloaded reference requires a schema 3 calibration")
keys = (sensor.info.FNORMAL_RAW, sensor.info.FSHEARX_RAW, sensor.info.FSHEARY_RAW)
values = sensor.read_info(*keys)
if getattr(sensor, "centroid_found", False) or not np.all(np.isfinite(values)):
raise ValueError("unloaded reference contains contact or invalid readings")
if field_weighted:
_accept_field_diagnostics(
state, raw_sensor, zero_frames=zero_frames
)
if (
not field_weighted
and zero_frames
and (
self.reference_frame is None
or row.frame_id >= self.reference_frame
)
and len(zero_samples) < zero_frames
):
if getattr(raw_sensor, "calibration", None) is None or getattr(
raw_sensor.calibration, "schema_version", None
) != 3:
raise ValueError(
"unloaded reference requires a schema 3 calibration"
)
keys = (
raw_sensor.info.FNORMAL_RAW,
raw_sensor.info.FSHEARX_RAW,
raw_sensor.info.FSHEARY_RAW,
)
values = raw_sensor.read_info(*keys)
if getattr(raw_sensor, "centroid_found", False) or not np.all(
np.isfinite(values)
):
raise ValueError(
"unloaded reference contains contact or invalid readings"
)
zero_samples.append(values)
if len(zero_samples) == zero_frames:
raw = {axis: float(np.median([value[index] for value in zero_samples]))
for index, axis in enumerate(("fn", "fx", "fy")) if axis in model.channels}
sensor.set_calibration_reference(raw, reference_id=f"video-unloaded-{row.frame_id}")
raw = {
axis: float(
np.median([value[index] for value in zero_samples])
)
for index, axis in enumerate(("fn", "fx", "fy"))
if axis in raw_sensor.calibration.channels
}
raw_sensor.set_calibration_reference(
raw,
reference_id=f"video-unloaded-{row.frame_id}",
)
provenance.counts["unloaded_reference_frames"] = zero_frames
provenance.counts["unloaded_reference_fn_raw"] = raw.get("fn")
force = self._read_force(chain)
@@ -945,8 +1221,16 @@ class AnalysisService:
depth=depth_array,
force=force,
)
if zero_frames and len(zero_samples) != zero_frames:
raise ValueError("video ended before 30 unloaded reference frames were available")
if field_weighted:
if zero_frames and state.field_baseline is None:
raise ValueError(
"video ended before the 30-frame weighted unloaded baseline "
"was available"
)
elif zero_frames and len(zero_samples) != zero_frames:
raise ValueError(
"video ended before 30 unloaded reference frames were available"
)
# ------------------------------------------------------------------- internals
def _zero_reference_frames(self) -> int:
@@ -1055,6 +1339,7 @@ class AnalysisService:
self._accumulate(
provenance.counts, samples, live.depth_nonzero, live.depth_abs_sum
)
field_quality = None if live.force is None else live.force.field_quality()
record = encode_frame(
frame_id=row.frame_id,
t_rel_s=row.t_rel_s,
@@ -1068,6 +1353,7 @@ class AnalysisService:
depth_nonzero=live.depth_nonzero,
dropped=row.dropped,
force=live.force,
field_quality=field_quality,
)
counts = provenance.counts
counts["frame_record_bytes"] = counts.get("frame_record_bytes", 0) + len(record)
@@ -1153,6 +1439,13 @@ class AnalysisService:
counts["force_zero_unavailable_frames"] = counts.get(
"force_zero_unavailable_frames", 0
) + (1 if zero_missing else 0)
quality = force.field_quality()
if quality is not None:
counts["field_quality_frames"] = counts.get("field_quality_frames", 0) + 1
counts["field_extrapolated_last"] = quality[0]
counts["field_extrapolated_max"] = max(
float(counts.get("field_extrapolated_max", 0.0)), quality[0]
)
def _provenance(self, key: ResultKey, facts: dict[str, Any]) -> Provenance:
session = facts["session"]
+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))