分析结果在缺少会话零点时保留原始力值并说明原因
多点标定未测零点时,分析把整帧判无效,原始值与牛顿值都显示"不可用";现按 SDK 标定模型约定只清空该轴牛顿值,并在回放提示与分析设置中说明需要零点。算法版本升为 flow-hhd-contact-2,旧缓存不再复用;新增测试在旧代码上失败、新代码通过,studio/shared 全量与 HEAD 失败集合一致。
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"multical": {
|
"multical": {
|
||||||
"video_zero": "I confirm 30 frames from the reference frame are unloaded",
|
"video_zero": "I confirm 30 frames from the reference frame are unloaded",
|
||||||
|
"video_zero_needed": "The chosen calibration is a multi-point model and needs a session zero. Without the box above, the result has raw values only, no newtons.",
|
||||||
"video_zero_tip": "For models requiring a session zero. Measure it from this interval; tracker reference alone does not imply zero load.",
|
"video_zero_tip": "For models requiring a session zero. Measure it from this interval; tracker reference alone does not imply zero load.",
|
||||||
"zero": "Unloaded zero",
|
"zero": "Unloaded zero",
|
||||||
"zero_confirm": "Remove all load and keep the sensor unloaded for the next 30 processed frames. For video, pause at a known unloaded interval first. Measure a new session zero now?",
|
"zero_confirm": "Remove all load and keep the sensor unloaded for the next 30 processed frames. For video, pause at a known unloaded interval first. Measure a new session zero now?",
|
||||||
@@ -518,6 +519,7 @@
|
|||||||
"video_required": "Select a video file first",
|
"video_required": "Select a video file first",
|
||||||
"config_missing": "Sensor config not found for {type}",
|
"config_missing": "Sensor config not found for {type}",
|
||||||
"units_raw_fallback": "No usable calibration; showing raw for now",
|
"units_raw_fallback": "No usable calibration; showing raw for now",
|
||||||
|
"units_zero_missing": "This analysis measured no session zero, so only raw values exist: re-run it with “I confirm 30 frames from the reference frame are unloaded” checked",
|
||||||
"reveal_failed": "Could not reveal config: {error}"
|
"reveal_failed": "Could not reveal config: {error}"
|
||||||
},
|
},
|
||||||
"scene3d": {
|
"scene3d": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"multical": {
|
"multical": {
|
||||||
"video_zero": "我确认从参考帧开始的 30 帧均无载荷",
|
"video_zero": "我确认从参考帧开始的 30 帧均无载荷",
|
||||||
|
"video_zero_needed": "所选标定是多点模型,需要会话零点。不勾选上面一项,分析结果只有原始值,没有牛顿值。",
|
||||||
"video_zero_tip": "适用于需要单独测量零点的模型。用此片段测量会话零点;选择跟踪参考帧本身不代表零载荷。",
|
"video_zero_tip": "适用于需要单独测量零点的模型。用此片段测量会话零点;选择跟踪参考帧本身不代表零载荷。",
|
||||||
"zero": "无载荷置零",
|
"zero": "无载荷置零",
|
||||||
"zero_confirm": "请移除全部载荷,并在接下来 30 个处理帧内保持无载荷。使用录像时,请先暂停在已确认的无载荷片段。现在测量本次会话零点吗?",
|
"zero_confirm": "请移除全部载荷,并在接下来 30 个处理帧内保持无载荷。使用录像时,请先暂停在已确认的无载荷片段。现在测量本次会话零点吗?",
|
||||||
@@ -518,6 +519,7 @@
|
|||||||
"video_required": "请先选择视频文件",
|
"video_required": "请先选择视频文件",
|
||||||
"config_missing": "未找到传感器配置: {type}",
|
"config_missing": "未找到传感器配置: {type}",
|
||||||
"units_raw_fallback": "无可用标定,暂显示原始单位",
|
"units_raw_fallback": "无可用标定,暂显示原始单位",
|
||||||
|
"units_zero_missing": "这次分析没有测零点,只能显示原始值:请在分析设置中勾选「我确认从参考帧开始的 30 帧均无载荷」后重新分析",
|
||||||
"reveal_failed": "无法打开配置位置: {error}"
|
"reveal_failed": "无法打开配置位置: {error}"
|
||||||
},
|
},
|
||||||
"scene3d": {
|
"scene3d": {
|
||||||
|
|||||||
@@ -1519,9 +1519,16 @@ class ReplayController(QObject):
|
|||||||
something to show in newtons — over the result's own force instead of a CSV.
|
something to show in newtons — over the result's own force instead of a CSV.
|
||||||
"""
|
"""
|
||||||
wanted = "N" if self._charts.unit() == "N" else "raw"
|
wanted = "N" if self._charts.unit() == "N" else "raw"
|
||||||
|
reason = newton_missing_reason(getattr(source, "reader", None))
|
||||||
|
if reason == "status.units_zero_missing":
|
||||||
|
# Said whichever unit is on screen: the operator has to re-run the pass with
|
||||||
|
# an unloaded segment to get newtons at all, and raw hides that otherwise.
|
||||||
|
self._host.notify(t(reason))
|
||||||
|
self._charts.show_unit_fallback(t(reason))
|
||||||
|
return "raw"
|
||||||
if wanted == "N" and not source.unit_available("N"):
|
if wanted == "N" and not source.unit_available("N"):
|
||||||
self._host.notify(t("status.units_raw_fallback"))
|
self._host.notify(t(reason))
|
||||||
self._charts.show_unit_fallback()
|
self._charts.show_unit_fallback(t(reason))
|
||||||
return "raw"
|
return "raw"
|
||||||
return wanted
|
return wanted
|
||||||
|
|
||||||
@@ -1541,3 +1548,21 @@ class ReplayController(QObject):
|
|||||||
return "raw"
|
return "raw"
|
||||||
values = np.asarray(newtons, dtype=float)
|
values = np.asarray(newtons, dtype=float)
|
||||||
return "N" if values.size and bool(np.isfinite(values).any()) else "raw"
|
return "N" if values.size and bool(np.isfinite(values).any()) else "raw"
|
||||||
|
|
||||||
|
|
||||||
|
def newton_missing_reason(reader: Any) -> str:
|
||||||
|
"""The locale key explaining why a recomputed result has no newton trace.
|
||||||
|
|
||||||
|
``status.units_zero_missing`` when the pass loaded a calibration model but every
|
||||||
|
frame lacked a session zero (the operator did not confirm an unloaded segment);
|
||||||
|
otherwise the generic ``status.units_raw_fallback``.
|
||||||
|
"""
|
||||||
|
meta = getattr(reader, "meta", None) or {}
|
||||||
|
counts = dict(meta.get("counts") or {}) if isinstance(meta, dict) else {}
|
||||||
|
if (
|
||||||
|
int(counts.get("force_calibrated_frames") or 0) > 0
|
||||||
|
and int(counts.get("force_newton_frames") or 0) == 0
|
||||||
|
and int(counts.get("force_zero_unavailable_frames") or 0) > 0
|
||||||
|
):
|
||||||
|
return "status.units_zero_missing"
|
||||||
|
return "status.units_raw_fallback"
|
||||||
|
|||||||
@@ -765,11 +765,17 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._last_overview_at = now
|
self._last_overview_at = now
|
||||||
self._sync_overview()
|
self._sync_overview()
|
||||||
|
|
||||||
def show_unit_fallback(self) -> None:
|
def show_unit_fallback(self, message: str | None = None) -> None:
|
||||||
"""Explain a failed Newton switch beside the unit control."""
|
"""Explain a failed Newton switch beside the unit control.
|
||||||
self.unit_notice.setText(t("status.units_raw_fallback"))
|
|
||||||
|
``message`` names a specific reason (for example a calibration model with no
|
||||||
|
session zero); without one the generic "no calibration" wording is shown.
|
||||||
|
"""
|
||||||
|
text = str(message) if message else t("status.units_raw_fallback")
|
||||||
|
self.unit_notice.setText(text)
|
||||||
|
self.unit_notice.setToolTip(text)
|
||||||
self.unit_notice.show()
|
self.unit_notice.show()
|
||||||
self._unit_notice_timer.start(6000)
|
self._unit_notice_timer.start(12000 if message else 6000)
|
||||||
|
|
||||||
def set_units(self, unit: str) -> None:
|
def set_units(self, unit: str) -> None:
|
||||||
"""Switch display units without mixing units in the curve buffer.
|
"""Switch display units without mixing units in the curve buffer.
|
||||||
|
|||||||
@@ -204,6 +204,15 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.unloaded_reference = QCheckBox(t("multical.video_zero"))
|
self.unloaded_reference = QCheckBox(t("multical.video_zero"))
|
||||||
self.unloaded_reference.setToolTip(t("multical.video_zero_tip"))
|
self.unloaded_reference.setToolTip(t("multical.video_zero_tip"))
|
||||||
calibration_form.addRow(self.unloaded_reference)
|
calibration_form.addRow(self.unloaded_reference)
|
||||||
|
# Said before the pass, not after it: a multi-point model with no session
|
||||||
|
# zero yields raw values only, and a long video takes minutes to show that.
|
||||||
|
self.zero_warning = QLabel()
|
||||||
|
self.zero_warning.setObjectName("hintText")
|
||||||
|
self.zero_warning.setWordWrap(True)
|
||||||
|
self.zero_warning.hide()
|
||||||
|
calibration_form.addRow(self.zero_warning)
|
||||||
|
self.unloaded_reference.toggled.connect(self._sync_zero_warning)
|
||||||
|
self.calibration_combo.currentIndexChanged.connect(self._sync_zero_warning)
|
||||||
root.addLayout(calibration_form)
|
root.addLayout(calibration_form)
|
||||||
|
|
||||||
self.note_label = ShrinkableLabel()
|
self.note_label = ShrinkableLabel()
|
||||||
@@ -344,6 +353,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
||||||
self.calibration_combo.blockSignals(False)
|
self.calibration_combo.blockSignals(False)
|
||||||
self._sync_device_row()
|
self._sync_device_row()
|
||||||
|
self._sync_zero_warning()
|
||||||
|
|
||||||
def _apply_remembered_calibration(self) -> str:
|
def _apply_remembered_calibration(self) -> str:
|
||||||
"""Select the sensor dialog's saved choice for this profile.
|
"""Select the sensor dialog's saved choice for this profile.
|
||||||
@@ -371,6 +381,25 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.calibration_combo.setCurrentIndex(index)
|
self.calibration_combo.setCurrentIndex(index)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def needs_session_zero(self) -> bool:
|
||||||
|
"""Whether the chosen calibration is a model needing a measured session zero."""
|
||||||
|
path = self._chosen_calibration()
|
||||||
|
if path is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
document = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
return isinstance(document, dict) and document.get("schema_version") == 3
|
||||||
|
|
||||||
|
def _sync_zero_warning(self, *_args) -> None:
|
||||||
|
warning = getattr(self, "zero_warning", None)
|
||||||
|
if warning is None:
|
||||||
|
return
|
||||||
|
show = self.needs_session_zero() and not self.unloaded_reference.isChecked()
|
||||||
|
warning.setText(t("multical.video_zero_needed") if show else "")
|
||||||
|
warning.setVisible(show)
|
||||||
|
|
||||||
def _chosen_calibration(self) -> Path | None:
|
def _chosen_calibration(self) -> Path | None:
|
||||||
role = str(self.calibration_combo.currentData() or "")
|
role = str(self.calibration_combo.currentData() or "")
|
||||||
entry = self.entry()
|
entry = self.entry()
|
||||||
@@ -478,3 +507,4 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
||||||
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("dialog.cancel"))
|
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("dialog.cancel"))
|
||||||
self._refresh_time_label()
|
self._refresh_time_label()
|
||||||
|
self._sync_zero_warning()
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""A multi-point calibration with no session zero is explained, before and after a pass."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.analysis import bare_video
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.sensors import scan_sensor_catalog
|
||||||
|
|
||||||
|
from orisys_studio.replay_controller import newton_missing_reason
|
||||||
|
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
|
||||||
|
|
||||||
|
|
||||||
|
def _reader(**counts) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(meta={"counts": counts})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_result_without_a_session_zero_names_the_reason():
|
||||||
|
reader = _reader(
|
||||||
|
force_calibrated_frames=9800, force_newton_frames=0, force_zero_unavailable_frames=9800
|
||||||
|
)
|
||||||
|
assert newton_missing_reason(reader) == "status.units_zero_missing"
|
||||||
|
assert t("status.units_zero_missing") != "status.units_zero_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_results_keep_the_generic_fallback():
|
||||||
|
assert newton_missing_reason(_reader()) == "status.units_raw_fallback"
|
||||||
|
assert newton_missing_reason(None) == "status.units_raw_fallback"
|
||||||
|
# Some newtons exist: the zero was measured, so "no zero" would be wrong.
|
||||||
|
reader = _reader(
|
||||||
|
force_calibrated_frames=10, force_newton_frames=4, force_zero_unavailable_frames=2
|
||||||
|
)
|
||||||
|
assert newton_missing_reason(reader) == "status.units_raw_fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def _finger_dialog(tmp_path, document: dict) -> VideoAnalysisDialog:
|
||||||
|
clip = tmp_path / "clip.mp4"
|
||||||
|
writer = cv2.VideoWriter(str(clip), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (32, 24))
|
||||||
|
for _ in range(4):
|
||||||
|
writer.write(np.zeros((24, 32, 3), dtype=np.uint8))
|
||||||
|
writer.release()
|
||||||
|
facts = bare_video.probe(bare_video.resolve_video(clip))
|
||||||
|
catalog = scan_sensor_catalog()
|
||||||
|
finger = next(
|
||||||
|
entry
|
||||||
|
for entry in catalog.entries
|
||||||
|
if str(entry.product_family) == "finger"
|
||||||
|
and bare_video.setup_blocked_reason(entry) is None
|
||||||
|
)
|
||||||
|
path = tmp_path / "DD02-14.json"
|
||||||
|
path.write_text(json.dumps(document), encoding="utf-8")
|
||||||
|
dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog)
|
||||||
|
assert dialog.select_profile(finger.ref)
|
||||||
|
dialog.select_calibration(path)
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dialog_warns_before_a_model_pass_without_a_session_zero(qt_app, tmp_path):
|
||||||
|
dialog = _finger_dialog(
|
||||||
|
tmp_path,
|
||||||
|
{"schema_version": 3, "identity": {"sensor_type": "finger", "sn": "DD02-14"}},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert dialog.needs_session_zero()
|
||||||
|
assert not dialog.unloaded_reference.isChecked()
|
||||||
|
assert not dialog.zero_warning.isHidden()
|
||||||
|
assert dialog.zero_warning.text() == t("multical.video_zero_needed")
|
||||||
|
dialog.unloaded_reference.setChecked(True)
|
||||||
|
assert dialog.zero_warning.isHidden()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_legacy_calibration_needs_no_session_zero(qt_app, tmp_path):
|
||||||
|
dialog = _finger_dialog(tmp_path, {"meta": {"sensor_type": "finger", "sn": "DD02-14"}})
|
||||||
|
try:
|
||||||
|
assert not dialog.needs_session_zero()
|
||||||
|
assert dialog.zero_warning.isHidden()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -135,7 +135,11 @@ ANALYSIS_FORMAT_VERSION = 2
|
|||||||
#: ``compute_contact()`` — the same pair the live acquisition loop and the offline
|
#: ``compute_contact()`` — the same pair the live acquisition loop and the offline
|
||||||
#: depth replay use. A change to either call, or to the chain they drive, must change
|
#: depth replay use. A change to either call, or to the chain they drive, must change
|
||||||
#: this string or two different results would share one cache entry.
|
#: this string or two different results would share one cache entry.
|
||||||
ALGORITHM_VERSION = "flow-hhd-contact-1"
|
#: ``-2``: a force sample's ``valid`` is the sensor readout's own verdict; a calibration
|
||||||
|
#: model status other than ``ok`` empties only that axis's newton value (SDK
|
||||||
|
#: calibration-model contract: raw is kept). ``-1`` results marked every frame invalid
|
||||||
|
#: when the model had no session zero, so they must not be served again.
|
||||||
|
ALGORITHM_VERSION = "flow-hhd-contact-2"
|
||||||
|
|
||||||
#: Unit of ``dx``/``dy``/``magnitude``: UV pixels of arrow-tip displacement, the same
|
#: Unit of ``dx``/``dy``/``magnitude``: UV pixels of arrow-tip displacement, the same
|
||||||
#: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents.
|
#: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents.
|
||||||
@@ -508,6 +512,9 @@ class ForceSample:
|
|||||||
shear_y_raw: float = _NAN
|
shear_y_raw: float = _NAN
|
||||||
valid: bool = True
|
valid: bool = True
|
||||||
calibrated: bool = False
|
calibrated: bool = False
|
||||||
|
#: The calibration model's per-axis status for this frame, as ``(axis, status)``
|
||||||
|
#: pairs. Kept in memory for the pass's counters; not part of the stored payload.
|
||||||
|
status: tuple[tuple[str, str], ...] = field(default=(), compare=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def any_finite(self) -> bool:
|
def any_finite(self) -> bool:
|
||||||
@@ -543,8 +550,21 @@ class ForceSample:
|
|||||||
"""One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor`
|
"""One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor`
|
||||||
reading, as this record's own type."""
|
reading, as this record's own type."""
|
||||||
axes = reading.get("calibrated_axes")
|
axes = reading.get("calibrated_axes")
|
||||||
|
statuses = {
|
||||||
|
str(axis): str(value)
|
||||||
|
for axis, value in (reading.get("calibration_status") or {}).items()
|
||||||
|
}
|
||||||
|
|
||||||
def newton(axis, key):
|
def newton(axis, key):
|
||||||
return float(reading.get(key, _NAN)) if axes is None or axis in axes else _NAN
|
if axes is not None and axis not in axes:
|
||||||
|
return _NAN
|
||||||
|
# A model status other than "ok" means the model gave no usable value for
|
||||||
|
# this axis on this frame (no contact, no session zero, out of range): the
|
||||||
|
# newton value is absent. The raw counts are still this frame's measurement.
|
||||||
|
if statuses.get(axis, "ok") != "ok":
|
||||||
|
return _NAN
|
||||||
|
return float(reading.get(key, _NAN))
|
||||||
|
|
||||||
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"),
|
||||||
@@ -552,9 +572,9 @@ class ForceSample:
|
|||||||
normal_raw=float(reading.get("normal_force_raw", _NAN)),
|
normal_raw=float(reading.get("normal_force_raw", _NAN)),
|
||||||
shear_x_raw=float(reading.get("shear_x_raw", _NAN)),
|
shear_x_raw=float(reading.get("shear_x_raw", _NAN)),
|
||||||
shear_y_raw=float(reading.get("shear_y_raw", _NAN)),
|
shear_y_raw=float(reading.get("shear_y_raw", _NAN)),
|
||||||
valid=bool(reading.get("valid", True)) and all(
|
valid=bool(reading.get("valid", True)),
|
||||||
value == "ok" for value in reading.get("calibration_status", {}).values()),
|
|
||||||
calibrated=bool(reading.get("force_calibrated", False)),
|
calibrated=bool(reading.get("force_calibrated", False)),
|
||||||
|
status=tuple(sorted(statuses.items())),
|
||||||
)
|
)
|
||||||
|
|
||||||
def encode(self) -> bytes:
|
def encode(self) -> bytes:
|
||||||
|
|||||||
@@ -1119,6 +1119,16 @@ class AnalysisService:
|
|||||||
counts["force_calibrated_frames"] = counts.get(
|
counts["force_calibrated_frames"] = counts.get(
|
||||||
"force_calibrated_frames", 0
|
"force_calibrated_frames", 0
|
||||||
) + (1 if force.calibrated else 0)
|
) + (1 if force.calibrated else 0)
|
||||||
|
# Newtons can be absent while raw is valid: the two counters below are what
|
||||||
|
# lets a chart say *why* a calibrated result has nothing to show in newtons.
|
||||||
|
newton_finite = any(value == value for value in force.newtons())
|
||||||
|
counts["force_newton_frames"] = counts.get("force_newton_frames", 0) + (
|
||||||
|
1 if newton_finite else 0
|
||||||
|
)
|
||||||
|
zero_missing = any(value == "zero_unavailable" for _axis, value in force.status)
|
||||||
|
counts["force_zero_unavailable_frames"] = counts.get(
|
||||||
|
"force_zero_unavailable_frames", 0
|
||||||
|
) + (1 if zero_missing else 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,99 @@
|
|||||||
|
"""A recomputed force sample: raw validity is the sensor's, newtons follow the model status.
|
||||||
|
|
||||||
|
The SDK calibration-model contract says a frame the model cannot answer (no contact, no
|
||||||
|
session zero, ...) has an empty calibrated output while the raw readout is kept. These
|
||||||
|
tests pin that a missing session zero no longer invalidates the raw counts of every
|
||||||
|
frame — the defect that made a whole analysed video read "unavailable" in both units.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from orisys_app_shared.analysis.model import ALGORITHM_VERSION, ForceSample
|
||||||
|
from orisys_app_shared.analysis.service import AnalysisService
|
||||||
|
|
||||||
|
|
||||||
|
def _reading(status: dict[str, str], *, normal: float, valid: bool = True) -> dict:
|
||||||
|
return {
|
||||||
|
"normal_force": normal,
|
||||||
|
"shear_x": float("nan"),
|
||||||
|
"shear_y": float("nan"),
|
||||||
|
"normal_force_raw": 1234.0,
|
||||||
|
"shear_x_raw": -5.0,
|
||||||
|
"shear_y_raw": 7.0,
|
||||||
|
"valid": valid,
|
||||||
|
"force_calibrated": True,
|
||||||
|
"calibrated_axes": ("fn",),
|
||||||
|
"calibration_status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_session_zero_keeps_the_raw_counts_valid():
|
||||||
|
sample = ForceSample.from_reading(
|
||||||
|
_reading({"fn": "zero_unavailable"}, normal=float("nan"))
|
||||||
|
)
|
||||||
|
assert sample.valid, "a model without a session zero invalidated the raw readout"
|
||||||
|
assert sample.raw() == (1234.0, -5.0, 7.0)
|
||||||
|
assert math.isnan(sample.normal)
|
||||||
|
assert sample.calibrated
|
||||||
|
assert dict(sample.status) == {"fn": "zero_unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_ok_status_empties_the_newton_value_even_when_one_is_reported():
|
||||||
|
# ``out_of_range`` carries a number in the SDK, but outside the validated range.
|
||||||
|
sample = ForceSample.from_reading(_reading({"fn": "out_of_range"}, normal=42.0))
|
||||||
|
assert math.isnan(sample.normal)
|
||||||
|
assert sample.valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ok_status_keeps_the_newton_value():
|
||||||
|
sample = ForceSample.from_reading(_reading({"fn": "ok"}, normal=3.5))
|
||||||
|
assert sample.normal == 3.5
|
||||||
|
assert sample.valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_sensor_readout_is_still_invalid():
|
||||||
|
sample = ForceSample.from_reading(_reading({"fn": "ok"}, normal=3.5, valid=False))
|
||||||
|
assert not sample.valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_legacy_calibration_without_statuses_is_unchanged():
|
||||||
|
reading = _reading({}, normal=2.0)
|
||||||
|
reading["calibrated_axes"] = None
|
||||||
|
sample = ForceSample.from_reading(reading)
|
||||||
|
assert sample.normal == 2.0 and sample.valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_status_is_not_part_of_the_stored_payload():
|
||||||
|
sample = ForceSample.from_reading(
|
||||||
|
_reading({"fn": "zero_unavailable"}, normal=float("nan"))
|
||||||
|
)
|
||||||
|
decoded = ForceSample.decode(sample.encode())
|
||||||
|
assert decoded.valid and decoded.raw() == sample.raw()
|
||||||
|
assert decoded.status == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pass_counts_why_a_calibrated_result_has_no_newtons():
|
||||||
|
counts: dict = {}
|
||||||
|
missing = ForceSample.from_reading(
|
||||||
|
_reading({"fn": "zero_unavailable"}, normal=float("nan"))
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
AnalysisService._accumulate_force(counts, missing)
|
||||||
|
assert counts["force_valid_frames"] == 3
|
||||||
|
assert counts["force_calibrated_frames"] == 3
|
||||||
|
assert counts["force_newton_frames"] == 0
|
||||||
|
assert counts["force_zero_unavailable_frames"] == 3
|
||||||
|
|
||||||
|
AnalysisService._accumulate_force(
|
||||||
|
counts, ForceSample.from_reading(_reading({"fn": "ok"}, normal=1.0))
|
||||||
|
)
|
||||||
|
assert counts["force_newton_frames"] == 1
|
||||||
|
assert counts["force_zero_unavailable_frames"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_results_stored_under_the_old_validity_rule_are_not_served():
|
||||||
|
# Version 1 marked every frame invalid when the model lacked a zero; the cache key
|
||||||
|
# carries this string, so those results must miss the cache and be recomputed.
|
||||||
|
assert ALGORITHM_VERSION != "flow-hhd-contact-1"
|
||||||
Reference in New Issue
Block a user