将场加权标定模型接入回放分析
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。 新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题, 已归因并另立 studio-gate-hang。
This commit is contained in:
@@ -38,10 +38,9 @@ zlib level 6。
|
|||||||
`(max − min) / 510`,实测 6.6e-5(该帧峰值 0.034)。这不到深度视图所用 256 级色标的一步,所以
|
`(max − min) / 510`,实测 6.6e-5(该帧峰值 0.034)。这不到深度视图所用 256 级色标的一步,所以
|
||||||
重建出来的帧**不可能被染成与原帧不同的颜色**。
|
重建出来的帧**不可能被染成与原帧不同的颜色**。
|
||||||
|
|
||||||
稀疏存储实测过并否决:每像素 4 B 索引在最坏帧上要 910 B,而整幅量化平面只要 486 B —— 一片几乎
|
稀疏存储实测后否决:最坏帧每像素 4 B 索引要 910 B,整幅量化平面只要 486 B —— 几乎全零的
|
||||||
全零的字节平面在 zlib 下几乎免费,像素索引不是。限制在岛内也实测过并否决:418 B 对 486 B,
|
字节平面在 zlib 下几乎免费。限制在岛内也否决:418 B 对 486 B,每帧省 5.2 B,却要依赖有效性
|
||||||
在 4,048 B 的记录里省 5.2 B/帧,代价是重建要依赖有效性掩码 keyframe(box 链路根本没有),
|
掩码 keyframe(box 链路没有),且岛外深度值会静默丢测量。
|
||||||
而且一旦有深度值落在岛外就会**静默丢掉一个测量**。
|
|
||||||
|
|
||||||
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
|
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
|
||||||
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算;`forces` 见第 1.1 节)、
|
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算;`forces` 见第 1.1 节)、
|
||||||
@@ -90,7 +89,8 @@ box 链路没有 `valid_mask`,`max_arrows=400` 的上限因此按幅值排序
|
|||||||
`magnitude` 重新推导 `active`,不需要重建结果;只有采样**网格**(`flow_grid_spacing`、
|
`magnitude` 重新推导 `active`,不需要重建结果;只有采样**网格**(`flow_grid_spacing`、
|
||||||
`flow_edge_margin`、`max_arrows`)变化才会移动候选位置,那份策略记在 provenance 里供比对而
|
`flow_edge_margin`、`max_arrows`)变化才会移动候选位置,那份策略记在 provenance 里供比对而
|
||||||
不塞进键里。SDK 版本同样不在键里(补丁版本不该让键变化),它记在 provenance 的 `versions`,
|
不塞进键里。SDK 版本同样不在键里(补丁版本不该让键变化),它记在 provenance 的 `versions`,
|
||||||
由 `AnalysisReader.stale_reasons()` 报出。
|
由 `AnalysisReader.stale_reasons()` 报出。场加权实验模型的键分量与逐帧质量见
|
||||||
|
[replay-field-experiment.zh.md](replay-field-experiment.zh.md)。
|
||||||
|
|
||||||
## 4. 参考帧规则
|
## 4. 参考帧规则
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Studio 回放:场加权实验模型
|
||||||
|
|
||||||
|
用途:reference。范围:Studio 回放分析对 SDK `field_gain/1`(输入 profile
|
||||||
|
`hhd_normal_l1_field/1`)的接线、缓存身份、逐帧质量诊断与实时拒绝边界。
|
||||||
|
格式、完整性、失效键与播放语义分别由[replay-analysis.md](replay-analysis.md)与
|
||||||
|
[replay-analysis-part-b.md](replay-analysis-part-b.md)负责;模型语义见
|
||||||
|
[场模型契约](../../../orisys-sdk/docs/field-gain-model.zh.md),任务范围见
|
||||||
|
[实施计划](../../../docs/workflow/field-weighted-calibration-plan.zh.md)。
|
||||||
|
|
||||||
|
## 缓存身份
|
||||||
|
|
||||||
|
场加权模型是新的分析行为,但不是新的二进制格式。`result_key` 的 `sensor_config` 分量在
|
||||||
|
标定文件实际为 `field_gain/1` 时追加 `;field-weighted=1`;spatial/global 模型与旧结果的键
|
||||||
|
不变,因此旧回放结果仍能命中。标定内容身份仍来自 schema 3 指纹(含 `pipeline.field`、
|
||||||
|
几何与坐标附件),算法版本不因本功能整体升级。
|
||||||
|
|
||||||
|
provenance 另存 `field` 块:`profile`、`strategy`、实验来源、`baseline_N`、
|
||||||
|
`reference_id`、参与统计帧数、`extrapolated_mean/max` 与**运行时实际**的
|
||||||
|
`hhd.downsample`、`hhd.grid_shape`。缓存键只用标定指纹与配置内容哈希,不用声明值假装实际值;
|
||||||
|
实际网格只进 provenance。
|
||||||
|
|
||||||
|
## 回放 pass
|
||||||
|
|
||||||
|
- 打开链后读 `sensor.calibration.input_profile` 判定模型;field 模型只在显式 `cal_path`
|
||||||
|
下使用,profile 默认文件不假定为实验模型。
|
||||||
|
- 新模型要求**单 segment 录像**:加权基线无法在段边界重建,多 segment 直接 `ValueError`
|
||||||
|
并留下原因,不静默丢基线。
|
||||||
|
- 空载窗口仍需操作员在分析设置里声明(30 帧);每个 pass 在参考帧 `reset` 之后、该帧
|
||||||
|
`process_chain` 之前调用一次 `declare_unloaded_reference(reference_id="video-unloaded-<id>")`。
|
||||||
|
SDK 累计连续 30 个有效加权 `P` 并取中位数;前 30 帧状态 `zero_unavailable`,
|
||||||
|
没有牛顿值但保留 raw 读数。不得调用 `set_calibration_reference` 传旧 raw 标量零点,
|
||||||
|
也不得把 `centroid_found` 当门槛。
|
||||||
|
- 第 30 个有效帧后确认 `baseline_N` 已就绪;视频结束仍未就绪沿用现有 `ValueError` 语义。
|
||||||
|
- 逐帧质量取自 `sensor.calibration_result.diagnostics`:`extrapolated_fraction`、
|
||||||
|
`coverage_fraction`、`pending_reference_frames`、`baseline_N`、`reference_id`、
|
||||||
|
`model_fingerprint`。质量是估计证据,不参与 `calibration_status`,不得因此丢弃牛顿值。
|
||||||
|
|
||||||
|
## 逐帧存储与读取
|
||||||
|
|
||||||
|
场加权质量追加在帧记录最后:`FRAME_FLAG_FIELD` 后接 `extrapolated_fraction` 与
|
||||||
|
`coverage_fraction` 两个 float32,沿用 `FRAME_FLAG_FORCE` 的追加先例,格式版本不变。
|
||||||
|
`AnalysisReader.frame().field_quality` 返回二元组;旧结果没有该 flag 时返回 `None`,
|
||||||
|
上层显示“未知”,不得补零或拒绝结果。`counts.field_quality_frames` 记录带质量帧数。
|
||||||
|
|
||||||
|
`ForceSample.from_reading` 继续只凭 raw 有限性判断 `valid`;质量缺失或外推比例高低都不改变
|
||||||
|
有效性。`out_of_range` 与负的基线差保留数值。
|
||||||
|
|
||||||
|
## 显示与实时拒绝
|
||||||
|
|
||||||
|
- `replay_controller` 在曲线来源与提示中使用 provenance 的 `field` 块:实验模型标签、
|
||||||
|
加权基线、平均外推比例;缺字段显示未知。中英 locales 成对新增键。
|
||||||
|
- 实时入口未接入该模型:`SdkAcquisitionWorker.start()`/`FingerAcquisitionWorker.start()`
|
||||||
|
与热切换 `_consume_pending_calibration()` 在打开设备前检查显式 `cal_path`,遇
|
||||||
|
schema 3 `field_gain/1` 抛可诊断的 `RuntimeError`;由现有运行错误处理显示。
|
||||||
|
`calibration_service`/实时 UI 若继续重构,保留同一检查函数供调用。
|
||||||
|
|
||||||
|
## 验收与限制
|
||||||
|
|
||||||
|
回归见 `shared/tests/test_field_calibration_app.py`、`apps/studio/tests/test_field_experiment_display.py`
|
||||||
|
及扩展现有四件套:窗口只声明一次、前 30 帧无牛顿值、第 30 帧后就绪、无接触仍有效、
|
||||||
|
多 segment 拒绝、质量 round-trip 与旧结果未知、field 键与 spatial 键不同、实时入口拒绝。
|
||||||
|
本次不宣称物理精度或在先模型的精度改善;基线窗口是否平稳、外推比例大小都必须随结果如实报告。
|
||||||
@@ -42,6 +42,7 @@ from orisys_app_shared.acquisition.image_backend import (
|
|||||||
)
|
)
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.force.source import AnalysisCurveSource
|
from orisys_app_shared.force.source import AnalysisCurveSource
|
||||||
|
from orisys_app_shared.analysis.model import FIELD_PROFILE
|
||||||
from orisys_app_shared.paths import recordings_dir
|
from orisys_app_shared.paths import recordings_dir
|
||||||
from PySide6.QtCore import QObject, QTimer, Signal
|
from PySide6.QtCore import QObject, QTimer, Signal
|
||||||
from PySide6.QtWidgets import QFileDialog, QWidget
|
from PySide6.QtWidgets import QFileDialog, QWidget
|
||||||
@@ -66,6 +67,59 @@ PLAYBACK_TICK_MS = 33
|
|||||||
ReplayHost = ReplayShell
|
ReplayHost = ReplayShell
|
||||||
|
|
||||||
|
|
||||||
|
def field_experiment_note(session: Any) -> tuple[str, str]:
|
||||||
|
"""What a field-weighted result is, as ``(status line, tooltip)``.
|
||||||
|
|
||||||
|
A field result is an **experiment**: its force is an area-weighted integral of the
|
||||||
|
HHD curl-free field with a baseline measured from a declared unloaded window, and
|
||||||
|
part of its support may be extrapolated. The replay has to say that where the
|
||||||
|
operator looks — the status line and the chart's tooltip — using the result's own
|
||||||
|
provenance. A result that carries the field profile but no diagnostics (an older
|
||||||
|
cache, or a pass that could not collect them) says *unknown*: a missing
|
||||||
|
extrapolated fraction is not zero extrapolation.
|
||||||
|
|
||||||
|
Returns ``("", "")`` for anything that is not a field result, so a scalar model's
|
||||||
|
replay is unchanged.
|
||||||
|
"""
|
||||||
|
reader = getattr(session, "reader", session)
|
||||||
|
if reader is None:
|
||||||
|
return "", ""
|
||||||
|
provenance = getattr(reader, "provenance", None)
|
||||||
|
field = getattr(provenance, "field", None)
|
||||||
|
if not isinstance(field, dict) or not field:
|
||||||
|
return "", ""
|
||||||
|
if str(field.get("profile") or "") != FIELD_PROFILE:
|
||||||
|
return "", ""
|
||||||
|
frames = int(field.get("frames") or 0)
|
||||||
|
baseline = field.get("baseline_N")
|
||||||
|
extrapolated = field.get("extrapolated_mean")
|
||||||
|
if frames <= 0 or baseline is None or extrapolated is None:
|
||||||
|
return t("chart.field_unknown"), t("chart.field_unknown_tip")
|
||||||
|
hhd = dict(field.get("hhd") or {})
|
||||||
|
grid_shape = hhd.get("grid_shape")
|
||||||
|
grid = (
|
||||||
|
f"{int(grid_shape[0])}x{int(grid_shape[1])}"
|
||||||
|
if isinstance(grid_shape, (list, tuple)) and len(grid_shape) == 2
|
||||||
|
else "?"
|
||||||
|
)
|
||||||
|
downsample = hhd.get("downsample")
|
||||||
|
note = t(
|
||||||
|
"chart.field_experiment",
|
||||||
|
baseline=f"{float(baseline):.3f}",
|
||||||
|
frames=frames,
|
||||||
|
extrapolated=f"{100.0 * float(extrapolated):.1f}",
|
||||||
|
)
|
||||||
|
tip = t(
|
||||||
|
"chart.field_experiment_tip",
|
||||||
|
fingerprint=str(field.get("model_fingerprint") or "?")[:16],
|
||||||
|
strategy=str(field.get("strategy") or "?"),
|
||||||
|
reference=str(field.get("reference_id") or "?"),
|
||||||
|
grid=grid,
|
||||||
|
downsample="?" if downsample is None else str(downsample),
|
||||||
|
)
|
||||||
|
return note, tip
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReplayActions:
|
class ReplayActions:
|
||||||
"""The five menu/shortcut entries whose meaning depends on the replay state."""
|
"""The five menu/shortcut entries whose meaning depends on the replay state."""
|
||||||
@@ -1018,6 +1072,18 @@ class ReplayController(QObject):
|
|||||||
# A result just became readable, so it is what the operator is shown — unless a
|
# A result just became readable, so it is what the operator is shown — unless a
|
||||||
# derived view was already on screen (a depth view chosen while the pass ran).
|
# derived view was already on screen (a depth view chosen while the pass ran).
|
||||||
self.serve_stored_view()
|
self.serve_stored_view()
|
||||||
|
# A field-weighted result is an experiment; name its source and quality where
|
||||||
|
# the operator reads the measurement. A scalar result answers ("", "") and
|
||||||
|
# nothing is added.
|
||||||
|
note, tip = field_experiment_note(session)
|
||||||
|
if note:
|
||||||
|
self._host.notify(note)
|
||||||
|
try:
|
||||||
|
self._charts.setToolTip(tip or note)
|
||||||
|
except Exception: # noqa: BLE001 - a tooltip must not break adoption
|
||||||
|
logger.debug(
|
||||||
|
"could not attach the field experiment tooltip", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
def serve_stored_view(self) -> bool:
|
def serve_stored_view(self) -> bool:
|
||||||
"""Put the cached view on screen when a result becomes readable.
|
"""Put the cached view on screen when a result becomes readable.
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""S4: the replay names the field-weighted experiment a result came from.
|
||||||
|
|
||||||
|
The field model is an experiment, and its numbers are estimates: the result records
|
||||||
|
its model fingerprint, the declared unloaded reference and the mean extrapolated
|
||||||
|
fraction. The replay has to say so — and say "unknown" for a result that predates
|
||||||
|
the diagnostics, never 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from orisys_app_shared.analysis.model import FIELD_PROFILE
|
||||||
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
|
|
||||||
|
from orisys_studio.replay_controller import field_experiment_note
|
||||||
|
|
||||||
|
|
||||||
|
def _session(field: dict) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
reader=SimpleNamespace(provenance=SimpleNamespace(field=field))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FIELD = {
|
||||||
|
"profile": FIELD_PROFILE,
|
||||||
|
"strategy": "field-weighted-baseline-1",
|
||||||
|
"model_fingerprint": "abcdef0123456789" * 4,
|
||||||
|
"reference_id": "video-unloaded-36",
|
||||||
|
"baseline_N": 0.25,
|
||||||
|
"frames": 35,
|
||||||
|
"extrapolated_mean": 0.3,
|
||||||
|
"extrapolated_max": 0.4,
|
||||||
|
"coverage_mean": 0.95,
|
||||||
|
"hhd": {"downsample": 2, "grid_shape": [150, 140]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
set_locale("zh_CN")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_field_result_names_its_baseline_and_extrapolation():
|
||||||
|
set_locale("zh_CN")
|
||||||
|
note, tip = field_experiment_note(_session(dict(FIELD)))
|
||||||
|
assert note == t(
|
||||||
|
"chart.field_experiment",
|
||||||
|
baseline="0.250",
|
||||||
|
frames=35,
|
||||||
|
extrapolated="30.0",
|
||||||
|
)
|
||||||
|
assert "field-weighted-baseline-1" in tip
|
||||||
|
assert "abcdef0123456789" in tip
|
||||||
|
assert "150x140" in tip or "150 × 140" in tip
|
||||||
|
|
||||||
|
set_locale("en_US")
|
||||||
|
english, _ = field_experiment_note(_session(dict(FIELD)))
|
||||||
|
assert english and english != note
|
||||||
|
assert "30.0" in english
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_field_result_without_diagnostics_says_unknown():
|
||||||
|
set_locale("zh_CN")
|
||||||
|
note, tip = field_experiment_note(_session({"profile": FIELD_PROFILE}))
|
||||||
|
assert note == t("chart.field_unknown")
|
||||||
|
assert tip == t("chart.field_unknown_tip")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scalar_or_missing_result_has_no_field_note():
|
||||||
|
assert field_experiment_note(_session({})) == ("", "")
|
||||||
|
assert field_experiment_note(None) == ("", "")
|
||||||
|
assert field_experiment_note(SimpleNamespace(reader=None)) == ("", "")
|
||||||
@@ -171,3 +171,50 @@ def test_explicit_raw_choice_disables_default_calibration_before_processing(monk
|
|||||||
chain = ChainSpec(sensor_type=family, calibration_disabled=True)
|
chain = ChainSpec(sensor_type=family, calibration_disabled=True)
|
||||||
service_module._OpenChain(chain, video, tmp_path).open()
|
service_module._OpenChain(chain, video, tmp_path).open()
|
||||||
assert applied == [None]
|
assert applied == [None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_field_model_setup_gets_its_own_key_and_the_unloaded_window(
|
||||||
|
monkeypatch, replay_recording, tmp_path
|
||||||
|
):
|
||||||
|
"""S4: the field model is a different experiment, so it is a different cache entry.
|
||||||
|
|
||||||
|
The strategy component is added by the analysis service from the chosen file's
|
||||||
|
``model.input_profile``; the 30-frame declaration travels with the setup. A
|
||||||
|
spatial result's key keeps its old components, so its existing cache still hits.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.analysis import bare_video
|
||||||
|
from orisys_app_shared.analysis import service as service_module
|
||||||
|
|
||||||
|
field_file = tmp_path / "field.json"
|
||||||
|
field_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 3,
|
||||||
|
"model": {
|
||||||
|
"type": "field_gain/1",
|
||||||
|
"input_profile": "hhd_normal_l1_field/1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service_module,
|
||||||
|
"calibration_profile",
|
||||||
|
lambda path: ("field_gain/1", "hhd_normal_l1_field/1"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(bare_video, "calibration_identity", lambda path: "field@abc")
|
||||||
|
|
||||||
|
service = AnalysisService(
|
||||||
|
Path(replay_recording),
|
||||||
|
chain=ChainSpec(sensor_type="finger", calibration=field_file),
|
||||||
|
store_force=True,
|
||||||
|
unloaded_reference_frames=30,
|
||||||
|
)
|
||||||
|
facts = service.dependency_report()
|
||||||
|
assert facts["model_profile"] == "hhd_normal_l1_field/1"
|
||||||
|
assert facts["key"].components["sensor_config"].endswith(";field-weighted=1")
|
||||||
|
assert facts["key"].components["calibration"] == "field@abc"
|
||||||
|
assert service.unloaded_reference_frames == 30
|
||||||
|
# The recording itself was not touched by key computation.
|
||||||
|
assert (Path(replay_recording) / "session.json").is_file()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from orisys_app_shared.acquisition.sdk_worker import (
|
|||||||
_capture_sequence,
|
_capture_sequence,
|
||||||
calibration_digest,
|
calibration_digest,
|
||||||
capture_status_of,
|
capture_status_of,
|
||||||
|
live_field_model_rejection,
|
||||||
loaded_calibration_version,
|
loaded_calibration_version,
|
||||||
loaded_path,
|
loaded_path,
|
||||||
process_chain,
|
process_chain,
|
||||||
@@ -408,6 +409,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
) -> FixedCameraMode:
|
) -> FixedCameraMode:
|
||||||
# Non-blocking: a previous session that is stuck opening must never hold
|
# Non-blocking: a previous session that is stuck opening must never hold
|
||||||
# the caller (the GUI thread) hostage.
|
# the caller (the GUI thread) hostage.
|
||||||
|
rejection = live_field_model_rejection(cal_path)
|
||||||
|
if rejection:
|
||||||
|
raise RuntimeError(rejection)
|
||||||
self.stop()
|
self.stop()
|
||||||
previous = self._thread
|
previous = self._thread
|
||||||
ok, detail = orisys_importable()
|
ok, detail = orisys_importable()
|
||||||
@@ -643,6 +647,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
session = self._session_id
|
session = self._session_id
|
||||||
if path is None or sensor is None:
|
if path is None or sensor is None:
|
||||||
return
|
return
|
||||||
|
rejection = live_field_model_rejection(path)
|
||||||
|
if rejection:
|
||||||
|
raise RuntimeError(rejection)
|
||||||
if not hasattr(sensor, "set_calibration"):
|
if not hasattr(sensor, "set_calibration"):
|
||||||
raise RuntimeError("sensor does not support set_calibration")
|
raise RuntimeError("sensor does not support set_calibration")
|
||||||
if device_id:
|
if device_id:
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def _high_resolution_timer():
|
|||||||
if winmm is not None:
|
if winmm is not None:
|
||||||
try:
|
try:
|
||||||
winmm.timeEndPeriod(1)
|
winmm.timeEndPeriod(1)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001, S110 - a failed timer restore is not fatal
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
|
# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the
|
||||||
@@ -109,6 +109,7 @@ __all__ = [
|
|||||||
"load_fixed_mode",
|
"load_fixed_mode",
|
||||||
"loaded_calibration_version",
|
"loaded_calibration_version",
|
||||||
"loaded_path",
|
"loaded_path",
|
||||||
|
"live_field_model_rejection",
|
||||||
"orisys_importable",
|
"orisys_importable",
|
||||||
"process_chain",
|
"process_chain",
|
||||||
"read_depth",
|
"read_depth",
|
||||||
@@ -394,6 +395,8 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
|
|||||||
if arr.size == 0:
|
if arr.size == 0:
|
||||||
depth = None
|
depth = None
|
||||||
calibrated = _sensor_force_calibrated(sensor, config_ref=config_ref)
|
calibrated = _sensor_force_calibrated(sensor, config_ref=config_ref)
|
||||||
|
result = getattr(sensor, "calibration_result", None)
|
||||||
|
diagnostics = getattr(result, "diagnostics", None)
|
||||||
return {
|
return {
|
||||||
"normal_force": float(fn),
|
"normal_force": float(fn),
|
||||||
"shear_x": float(fx),
|
"shear_x": float(fx),
|
||||||
@@ -410,6 +413,13 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
|
|||||||
"invalid_reason": error,
|
"invalid_reason": error,
|
||||||
"raw_available": read_raw,
|
"raw_available": read_raw,
|
||||||
"calibration_status": dict(getattr(sensor, "calibration_status", {}) or {}),
|
"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)
|
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):
|
class SdkAcquisitionWorker(RecordSinkMixin):
|
||||||
"""Background loop: Sensor.get_img + deformation + force readout."""
|
"""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
|
# Non-blocking: a previous session that is stuck opening the device must
|
||||||
# never hold the caller (the GUI thread) hostage.
|
# never hold the caller (the GUI thread) hostage.
|
||||||
del device_id # box/mini open path has no Finger-style serial check
|
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()
|
self.stop()
|
||||||
previous = self._thread
|
previous = self._thread
|
||||||
ok, detail = orisys_importable()
|
ok, detail = orisys_importable()
|
||||||
@@ -1023,6 +1071,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
session = self._session_id
|
session = self._session_id
|
||||||
if path is None or sensor is None:
|
if path is None or sensor is None:
|
||||||
return
|
return
|
||||||
|
rejection = live_field_model_rejection(path)
|
||||||
|
if rejection:
|
||||||
|
raise RuntimeError(rejection)
|
||||||
if not hasattr(sensor, "set_calibration"):
|
if not hasattr(sensor, "set_calibration"):
|
||||||
raise RuntimeError("sensor does not support set_calibration")
|
raise RuntimeError("sensor does not support set_calibration")
|
||||||
sensor.set_calibration(path)
|
sensor.set_calibration(path)
|
||||||
@@ -1050,7 +1101,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
sensor.disconnect()
|
sensor.disconnect()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110 - teardown must not mask the pass result
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _close_all_sensors(self) -> None:
|
def _close_all_sensors(self) -> None:
|
||||||
|
|||||||
@@ -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``:
|
``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 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
|
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
|
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
|
a mostly-zero byte plane is nearly free for zlib while pixel indices are not. Island
|
||||||
@@ -92,9 +103,14 @@ __all__ = [
|
|||||||
"CODEC_ZLIB",
|
"CODEC_ZLIB",
|
||||||
"DEPTH_BITS",
|
"DEPTH_BITS",
|
||||||
"DEPTH_HEADER",
|
"DEPTH_HEADER",
|
||||||
|
"FIELD_KEY_SUFFIX",
|
||||||
|
"FIELD_PAYLOAD",
|
||||||
|
"FIELD_PROFILE",
|
||||||
|
"FIELD_STRATEGY",
|
||||||
"FORCE_PAYLOAD",
|
"FORCE_PAYLOAD",
|
||||||
"FRAME_FLAG_DEPTH",
|
"FRAME_FLAG_DEPTH",
|
||||||
"FRAME_FLAG_DROPPED",
|
"FRAME_FLAG_DROPPED",
|
||||||
|
"FRAME_FLAG_FIELD",
|
||||||
"FRAME_FLAG_FORCE",
|
"FRAME_FLAG_FORCE",
|
||||||
"FRAME_FLAG_VALIDITY",
|
"FRAME_FLAG_VALIDITY",
|
||||||
"FRAME_HEADER",
|
"FRAME_HEADER",
|
||||||
@@ -145,6 +161,20 @@ ALGORITHM_VERSION = "flow-hhd-contact-2"
|
|||||||
#: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents.
|
#: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents.
|
||||||
UNIT = "uv_px"
|
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
|
#: ``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
|
#: non-zero count, depth minimum, depth maximum. The three trailing pad bytes keep
|
||||||
#: the value arrays 8-byte aligned inside a record.
|
#: 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
|
#: pass was not asked to collect it (a recording, whose forces live in its own
|
||||||
#: ``forces.csv``) or the chain exposed none.
|
#: ``forces.csv``) or the chain exposed none.
|
||||||
FRAME_FLAG_FORCE = 0x08
|
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
|
#: 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
|
#: 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.
|
#: always ignored trailing bytes.
|
||||||
FORCE_PAYLOAD = struct.Struct("<ffffffBB2x")
|
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
|
#: magic, kind, codec, count, height, width, flags, reserved, raw bytes, digest
|
||||||
KEYFRAME_HEADER = struct.Struct("<4sBBIIIBBQ8s")
|
KEYFRAME_HEADER = struct.Struct("<4sBBIIIBBQ8s")
|
||||||
KEYFRAME_MAGIC = b"ORAK"
|
KEYFRAME_MAGIC = b"ORAK"
|
||||||
@@ -515,6 +558,11 @@ class ForceSample:
|
|||||||
#: The calibration model's per-axis status for this frame, as ``(axis, status)``
|
#: 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.
|
#: pairs. Kept in memory for the pass's counters; not part of the stored payload.
|
||||||
status: tuple[tuple[str, str], ...] = field(default=(), compare=False)
|
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
|
@property
|
||||||
def any_finite(self) -> bool:
|
def any_finite(self) -> bool:
|
||||||
@@ -537,6 +585,23 @@ class ForceSample:
|
|||||||
def raw(self) -> tuple[float, float, float]:
|
def raw(self) -> tuple[float, float, float]:
|
||||||
return (self.normal_raw, self.shear_x_raw, self.shear_y_raw)
|
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]:
|
def values(self, unit: str = "raw") -> tuple[float, float, float]:
|
||||||
"""The triplet in ``unit``: newtons when a calibration was applied, else raw.
|
"""The triplet in ``unit``: newtons when a calibration was applied, else raw.
|
||||||
|
|
||||||
@@ -565,6 +630,7 @@ class ForceSample:
|
|||||||
return _NAN
|
return _NAN
|
||||||
return float(reading.get(key, _NAN))
|
return float(reading.get(key, _NAN))
|
||||||
|
|
||||||
|
diagnostics = reading.get("calibration_diagnostics")
|
||||||
return cls(
|
return cls(
|
||||||
normal=newton("fn", "normal_force"),
|
normal=newton("fn", "normal_force"),
|
||||||
shear_x=newton("fx", "shear_x"),
|
shear_x=newton("fx", "shear_x"),
|
||||||
@@ -575,6 +641,7 @@ class ForceSample:
|
|||||||
valid=bool(reading.get("valid", True)),
|
valid=bool(reading.get("valid", True)),
|
||||||
calibrated=bool(reading.get("force_calibrated", False)),
|
calibrated=bool(reading.get("force_calibrated", False)),
|
||||||
status=tuple(sorted(statuses.items())),
|
status=tuple(sorted(statuses.items())),
|
||||||
|
diagnostics=dict(diagnostics or {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
def encode(self) -> bytes:
|
def encode(self) -> bytes:
|
||||||
@@ -628,8 +695,9 @@ def encode_frame(
|
|||||||
depth_nonzero: int = 0,
|
depth_nonzero: int = 0,
|
||||||
dropped: bool = False,
|
dropped: bool = False,
|
||||||
force: ForceSample | None = None,
|
force: ForceSample | None = None,
|
||||||
|
field_quality: Sequence[float] | None = None,
|
||||||
) -> bytes:
|
) -> 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
|
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
|
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
|
``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
|
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.
|
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)
|
dx = np.ascontiguousarray(dx, dtype="<f4").reshape(-1)
|
||||||
dy = np.ascontiguousarray(dy, 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")
|
raise ValueError(f"grid id {grid_id} does not fit the frame header")
|
||||||
if count > 0xFFFF:
|
if count > 0xFFFF:
|
||||||
raise ValueError(f"{count} candidates do not fit the frame header")
|
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
|
flags = 0
|
||||||
if validity_digest is not None:
|
if validity_digest is not None:
|
||||||
flags |= FRAME_FLAG_VALIDITY
|
flags |= FRAME_FLAG_VALIDITY
|
||||||
@@ -667,6 +751,8 @@ def encode_frame(
|
|||||||
flags |= FRAME_FLAG_DROPPED
|
flags |= FRAME_FLAG_DROPPED
|
||||||
if force is not None:
|
if force is not None:
|
||||||
flags |= FRAME_FLAG_FORCE
|
flags |= FRAME_FLAG_FORCE
|
||||||
|
if quality_payload:
|
||||||
|
flags |= FRAME_FLAG_FIELD
|
||||||
depth_payload = b""
|
depth_payload = b""
|
||||||
depth_low = depth_high = 0.0
|
depth_low = depth_high = 0.0
|
||||||
if depth is not None:
|
if depth is not None:
|
||||||
@@ -696,6 +782,7 @@ def encode_frame(
|
|||||||
parts.append(depth_payload)
|
parts.append(depth_payload)
|
||||||
if force is not None:
|
if force is not None:
|
||||||
parts.append(force.encode())
|
parts.append(force.encode())
|
||||||
|
parts.append(quality_payload)
|
||||||
return b"".join(parts)
|
return b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -720,6 +807,9 @@ class DecodedFrame:
|
|||||||
#: ``None`` when the frame carried no force (an older result, a recording whose forces
|
#: ``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).
|
#: live in its own ``forces.csv``, or a chain that produced none).
|
||||||
force: ForceSample | None = 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:
|
def depth_map(self) -> np.ndarray | None:
|
||||||
"""This frame's depth map, or ``None`` when it had no depth channel."""
|
"""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:
|
if len(view) < cursor + FORCE_PAYLOAD.size:
|
||||||
raise ValueError("a frame says it carries force but is shorter than one sample")
|
raise ValueError("a frame says it carries force but is shorter than one sample")
|
||||||
force = ForceSample.decode(view, cursor)
|
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(
|
return DecodedFrame(
|
||||||
frame_id=int(frame_id),
|
frame_id=int(frame_id),
|
||||||
t_rel_s=float(t_rel),
|
t_rel_s=float(t_rel),
|
||||||
@@ -785,6 +883,7 @@ def decode_frame(
|
|||||||
validity_digest=validity,
|
validity_digest=validity,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
force=force,
|
force=force,
|
||||||
|
field_quality=field_quality,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -795,18 +894,22 @@ def frame_payload_bytes(
|
|||||||
with_depth: bool = False,
|
with_depth: bool = False,
|
||||||
depth_stored: int = 0,
|
depth_stored: int = 0,
|
||||||
with_force: bool = False,
|
with_force: bool = False,
|
||||||
|
with_field_quality: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Stored bytes of one frame record at ``candidates`` sample locations.
|
"""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;
|
``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
|
``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
|
total = FRAME_HEADER.size + int(candidates) * 12 + (int(candidates) + 7) // 8
|
||||||
if with_validity:
|
if with_validity:
|
||||||
total += 8
|
total += 8
|
||||||
if with_force:
|
if with_force:
|
||||||
total += FORCE_PAYLOAD.size
|
total += FORCE_PAYLOAD.size
|
||||||
|
if with_field_quality:
|
||||||
|
total += FIELD_PAYLOAD.size
|
||||||
if with_depth:
|
if with_depth:
|
||||||
total += DEPTH_HEADER.size + max(0, int(depth_stored))
|
total += DEPTH_HEADER.size + max(0, int(depth_stored))
|
||||||
return total
|
return total
|
||||||
@@ -872,6 +975,11 @@ class FramePayload:
|
|||||||
#: which is why the provenance — not this field — is what a chart must label a trace
|
#: which is why the provenance — not this field — is what a chart must label a trace
|
||||||
#: from.
|
#: from.
|
||||||
force: ForceSample | None = None
|
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
|
@property
|
||||||
def has_depth(self) -> bool:
|
def has_depth(self) -> bool:
|
||||||
@@ -928,6 +1036,7 @@ class FramePayload:
|
|||||||
depth_nonzero=self.depth_nonzero,
|
depth_nonzero=self.depth_nonzero,
|
||||||
dropped=self.dropped,
|
dropped=self.dropped,
|
||||||
force=self.force,
|
force=self.force,
|
||||||
|
field_quality=self.field_quality,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1035,9 +1144,20 @@ class Provenance:
|
|||||||
#: that time is not throughput.
|
#: that time is not throughput.
|
||||||
open_seconds: float = 0.0
|
open_seconds: float = 0.0
|
||||||
warnings: list[str] = field(default_factory=list)
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
#: True when the pass reached the end of every declared segment and committed.
|
||||||
complete: bool = False
|
complete: bool = False
|
||||||
stopped_reason: str = ""
|
stopped_reason: str = ""
|
||||||
counts: dict[str, Any] = field(default_factory=dict)
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return dict(self.__dict__)
|
return dict(self.__dict__)
|
||||||
|
|||||||
@@ -533,12 +533,22 @@ class AnalysisReader:
|
|||||||
depth_max=depth_max,
|
depth_max=depth_max,
|
||||||
depth_stored_bytes=depth_stored,
|
depth_stored_bytes=depth_stored,
|
||||||
force=decoded.force,
|
force=decoded.force,
|
||||||
|
field_quality=decoded.field_quality,
|
||||||
)
|
)
|
||||||
|
|
||||||
def force(self, frame_id: int) -> ForceSample | None:
|
def force(self, frame_id: int) -> ForceSample | None:
|
||||||
"""One frame's stored force sample, or ``None`` when the frame carries none."""
|
"""One frame's stored force sample, or ``None`` when the frame carries none."""
|
||||||
return self.frame(int(frame_id)).force
|
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
|
@property
|
||||||
def has_force(self) -> bool:
|
def has_force(self) -> bool:
|
||||||
"""Whether *any* frame in this result carries a force sample.
|
"""Whether *any* frame in this result carries a force sample.
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ from orisys_app_shared.analysis.cache import (
|
|||||||
)
|
)
|
||||||
from orisys_app_shared.analysis.model import (
|
from orisys_app_shared.analysis.model import (
|
||||||
ALGORITHM_VERSION,
|
ALGORITHM_VERSION,
|
||||||
|
FIELD_KEY_SUFFIX,
|
||||||
|
FIELD_PROFILE,
|
||||||
|
FIELD_STRATEGY,
|
||||||
KEYFRAME_MASK,
|
KEYFRAME_MASK,
|
||||||
ForceSample,
|
ForceSample,
|
||||||
Provenance,
|
Provenance,
|
||||||
@@ -86,6 +89,7 @@ __all__ = [
|
|||||||
"ChainSpec",
|
"ChainSpec",
|
||||||
"LiveFrame",
|
"LiveFrame",
|
||||||
"Progress",
|
"Progress",
|
||||||
|
"calibration_profile",
|
||||||
"chain_spec_for",
|
"chain_spec_for",
|
||||||
"recording_config_sha256",
|
"recording_config_sha256",
|
||||||
"resolve_finger_export_dir",
|
"resolve_finger_export_dir",
|
||||||
@@ -136,6 +140,116 @@ def recording_config_sha256(config_ref: str | Path | None) -> str:
|
|||||||
return ""
|
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:
|
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
||||||
"""``assets/30fps-0703`` beside a finger ``sensor.json``, when it is complete.
|
"""``assets/30fps-0703`` beside a finger ``sensor.json``, when it is complete.
|
||||||
|
|
||||||
@@ -509,6 +623,18 @@ class _PassState:
|
|||||||
frames_expected: int = 0
|
frames_expected: int = 0
|
||||||
frames_written: int = 0
|
frames_written: int = 0
|
||||||
started: float = 0.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:
|
class AnalysisService:
|
||||||
@@ -631,6 +757,14 @@ class AnalysisService:
|
|||||||
from orisys_app_shared.analysis.bare_video import calibration_identity
|
from orisys_app_shared.analysis.bare_video import calibration_identity
|
||||||
|
|
||||||
calibration = calibration_identity(self.chain.calibration)
|
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()
|
zero_frames = self._zero_reference_frames()
|
||||||
key = result_key(
|
key = result_key(
|
||||||
recording=identity,
|
recording=identity,
|
||||||
@@ -638,6 +772,7 @@ class AnalysisService:
|
|||||||
self.chain.identity()
|
self.chain.identity()
|
||||||
+ (";unloaded-reference=30" if zero_frames else "")
|
+ (";unloaded-reference=30" if zero_frames else "")
|
||||||
+ (";force=recomputed" if self.store_force else "")
|
+ (";force=recomputed" if self.store_force else "")
|
||||||
|
+ (FIELD_KEY_SUFFIX if model_profile == FIELD_PROFILE else "")
|
||||||
),
|
),
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
reference_frame=self.reference_frame,
|
reference_frame=self.reference_frame,
|
||||||
@@ -650,6 +785,8 @@ class AnalysisService:
|
|||||||
"segment_sizes": sizes,
|
"segment_sizes": sizes,
|
||||||
"recording_identity": identity,
|
"recording_identity": identity,
|
||||||
"calibration_id": calibration,
|
"calibration_id": calibration,
|
||||||
|
"model_type": model_type,
|
||||||
|
"model_profile": model_profile,
|
||||||
"key": key,
|
"key": key,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,6 +817,63 @@ class AnalysisService:
|
|||||||
f"frames ({span}); a reset that cannot be applied is not a result"
|
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
|
# ------------------------------------------------------------------------ pass
|
||||||
def recompute(self) -> Iterator[LiveFrame]:
|
def recompute(self) -> Iterator[LiveFrame]:
|
||||||
"""Re-run the chain over every segment and yield what it computes now.
|
"""Re-run the chain over every segment and yield what it computes now.
|
||||||
@@ -692,6 +886,7 @@ class AnalysisService:
|
|||||||
key: ResultKey = facts["key"]
|
key: ResultKey = facts["key"]
|
||||||
grouped = self.rows_by_segment(facts["info"].frames)
|
grouped = self.rows_by_segment(facts["info"].frames)
|
||||||
self._require_reference(grouped)
|
self._require_reference(grouped)
|
||||||
|
self._require_field_single_segment(facts)
|
||||||
working = self._make_working_directory(key)
|
working = self._make_working_directory(key)
|
||||||
state = _PassState(
|
state = _PassState(
|
||||||
provenance=self._provenance(key, facts),
|
provenance=self._provenance(key, facts),
|
||||||
@@ -724,6 +919,7 @@ class AnalysisService:
|
|||||||
# Checked before anything is written, so a caller error does not leave a
|
# Checked before anything is written, so a caller error does not leave a
|
||||||
# directory behind at all.
|
# directory behind at all.
|
||||||
self._require_reference(grouped)
|
self._require_reference(grouped)
|
||||||
|
self._require_field_single_segment(facts)
|
||||||
working = self._make_working_directory(key)
|
working = self._make_working_directory(key)
|
||||||
writer = AnalysisWriter(
|
writer = AnalysisWriter(
|
||||||
self.cache.directory(key), key=key, chunk_frames=self.chunk_frames
|
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}")
|
state.reasons.append(f"{type(exc).__name__}: {exc}")
|
||||||
finally:
|
finally:
|
||||||
self._discard_working_directory(working)
|
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 (
|
if (
|
||||||
not state.reasons
|
not state.reasons
|
||||||
and self.reference_frame is not None
|
and self.reference_frame is not None
|
||||||
@@ -784,6 +984,9 @@ class AnalysisService:
|
|||||||
writer.bytes_written / state.frames_written, 2
|
writer.bytes_written / state.frames_written, 2
|
||||||
)
|
)
|
||||||
counts["open_seconds"] = provenance.open_seconds
|
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)
|
provenance.decode_order_offsets = dict(state.offsets)
|
||||||
distinct = sorted(set(state.offsets.values()))
|
distinct = sorted(set(state.offsets.values()))
|
||||||
provenance.decode_order_offset = distinct[0] if len(distinct) == 1 else None
|
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}")
|
provenance.warnings.append(f"segment_frame_gap:{number}")
|
||||||
with _OpenChain(self.chain, path, state.working) as chain:
|
with _OpenChain(self.chain, path, state.working) as chain:
|
||||||
zero_samples = []
|
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)
|
offset, measured = self._segment_offset(chain, number, state.offsets)
|
||||||
state.offsets[str(number)] = offset
|
state.offsets[str(number)] = offset
|
||||||
state.sources.add("measured" if measured else "declared")
|
state.sources.add("measured" if measured else "declared")
|
||||||
@@ -888,25 +1110,79 @@ class AnalysisService:
|
|||||||
provenance.counts["reset_count"] = (
|
provenance.counts["reset_count"] = (
|
||||||
provenance.counts.get("reset_count", 0) + 1
|
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)
|
depth, error = process_chain(chain.sensor)
|
||||||
if error:
|
if error:
|
||||||
provenance.warnings.append(f"chain:{error}")
|
provenance.warnings.append(f"chain:{error}")
|
||||||
state.reasons.append(f"chain_failed:{row.frame_id}")
|
state.reasons.append(f"chain_failed:{row.frame_id}")
|
||||||
return
|
return
|
||||||
if zero_frames and (self.reference_frame is None or row.frame_id >= self.reference_frame) and len(zero_samples) < zero_frames:
|
if field_weighted:
|
||||||
sensor = getattr(chain.sensor, "raw_sensor", chain.sensor)
|
_accept_field_diagnostics(
|
||||||
model = getattr(sensor, "calibration", None)
|
state, raw_sensor, zero_frames=zero_frames
|
||||||
if getattr(model, "schema_version", None) != 3:
|
)
|
||||||
raise ValueError("unloaded reference requires a schema 3 calibration")
|
if (
|
||||||
keys = (sensor.info.FNORMAL_RAW, sensor.info.FSHEARX_RAW, sensor.info.FSHEARY_RAW)
|
not field_weighted
|
||||||
values = sensor.read_info(*keys)
|
and zero_frames
|
||||||
if getattr(sensor, "centroid_found", False) or not np.all(np.isfinite(values)):
|
and (
|
||||||
raise ValueError("unloaded reference contains contact or invalid readings")
|
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)
|
zero_samples.append(values)
|
||||||
if len(zero_samples) == zero_frames:
|
if len(zero_samples) == zero_frames:
|
||||||
raw = {axis: float(np.median([value[index] for value in zero_samples]))
|
raw = {
|
||||||
for index, axis in enumerate(("fn", "fx", "fy")) if axis in model.channels}
|
axis: float(
|
||||||
sensor.set_calibration_reference(raw, reference_id=f"video-unloaded-{row.frame_id}")
|
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_frames"] = zero_frames
|
||||||
provenance.counts["unloaded_reference_fn_raw"] = raw.get("fn")
|
provenance.counts["unloaded_reference_fn_raw"] = raw.get("fn")
|
||||||
force = self._read_force(chain)
|
force = self._read_force(chain)
|
||||||
@@ -945,8 +1221,16 @@ class AnalysisService:
|
|||||||
depth=depth_array,
|
depth=depth_array,
|
||||||
force=force,
|
force=force,
|
||||||
)
|
)
|
||||||
if zero_frames and len(zero_samples) != zero_frames:
|
if field_weighted:
|
||||||
raise ValueError("video ended before 30 unloaded reference frames were available")
|
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
|
# ------------------------------------------------------------------- internals
|
||||||
def _zero_reference_frames(self) -> int:
|
def _zero_reference_frames(self) -> int:
|
||||||
@@ -1055,6 +1339,7 @@ class AnalysisService:
|
|||||||
self._accumulate(
|
self._accumulate(
|
||||||
provenance.counts, samples, live.depth_nonzero, live.depth_abs_sum
|
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(
|
record = encode_frame(
|
||||||
frame_id=row.frame_id,
|
frame_id=row.frame_id,
|
||||||
t_rel_s=row.t_rel_s,
|
t_rel_s=row.t_rel_s,
|
||||||
@@ -1068,6 +1353,7 @@ class AnalysisService:
|
|||||||
depth_nonzero=live.depth_nonzero,
|
depth_nonzero=live.depth_nonzero,
|
||||||
dropped=row.dropped,
|
dropped=row.dropped,
|
||||||
force=live.force,
|
force=live.force,
|
||||||
|
field_quality=field_quality,
|
||||||
)
|
)
|
||||||
counts = provenance.counts
|
counts = provenance.counts
|
||||||
counts["frame_record_bytes"] = counts.get("frame_record_bytes", 0) + len(record)
|
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(
|
counts["force_zero_unavailable_frames"] = counts.get(
|
||||||
"force_zero_unavailable_frames", 0
|
"force_zero_unavailable_frames", 0
|
||||||
) + (1 if zero_missing else 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:
|
def _provenance(self, key: ResultKey, facts: dict[str, Any]) -> Provenance:
|
||||||
session = facts["session"]
|
session = facts["session"]
|
||||||
|
|||||||
@@ -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 math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from orisys_app_shared.analysis.model import ALGORITHM_VERSION, ForceSample
|
from orisys_app_shared.analysis.model import ALGORITHM_VERSION, ForceSample
|
||||||
from orisys_app_shared.analysis.service import AnalysisService
|
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
|
# 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.
|
# carries this string, so those results must miss the cache and be recomputed.
|
||||||
assert ALGORITHM_VERSION != "flow-hhd-contact-1"
|
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 len(list(processor._pass_frames(state))) == frame_count
|
||||||
assert applied == [{"fn": 100.}]
|
assert applied == [{"fn": 100.}]
|
||||||
assert state.provenance.counts["unloaded_reference_frames"] == 30
|
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