接入力标定 schema 3 与多点空间增益模型

保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
This commit is contained in:
ykcheungab
2026-10-18 12:56:40 +08:00
parent f7f4682ff9
commit ff7614a6a0
13 changed files with 1422 additions and 19 deletions
+27 -7
View File
@@ -1,6 +1,7 @@
# 统一力标定模型契约(草案)
# 统一力标定模型契约
Purpose: plan. Scope: SDK 标定文件选择、SN 绑定和推断接口。状态:2026-10-18 提案,未实现。
Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。
状态:2026-10-18 已实现 schema 3 的空间/全局增益;ML 后端仅保留契约,尚未实现。
保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。
## 现状与边界
@@ -38,12 +39,14 @@ profile 创建处理器统一复用内部选择器;现有入口参数只映射
保留 `device_id` 单独传入时的参数错误;目录本身不存在也报错,不能当成 SN 文件缺失。
```python
# 沿用现有调用参数;schema 3 需未来实现支持后使用。
# 沿用现有调用参数;cal_path 可以选择 schema 1/2/3 文件。
runtime = FingerRuntime.open(
config="finger", video="test.mp4",
device_id="DD02-GL-001",
cal_path="calibrations/finger/DD02-GL-001.json",
)
# 归零值来自本次无载窗口,不是模型内固定常量。
runtime.set_calibration_reference({"fn": measured_zero_raw}, reference_id="unloaded-1")
# 自动按 SN 选择仍使用 calibration_dir="calibrations",不同时传 cal_path。
```
@@ -107,12 +110,14 @@ runtime = FingerRuntime.open(
修改输入或算法语义必须升级相应 `/1` 标识。
`geometry_sha256` 是所用计算 mesh 文件字节的 SHA-256;`map_sha256` 是 SDK 坐标映射定义的
内容摘要,包含坐标变换及其版本,不能只比较图幅。pipeline 的必需键由输入 profile 固定,
附件字节摘要,包含坐标转换结果,不能只比较图幅。pipeline 的必需键由输入 profile 固定,
覆盖该 backend 所有影响测量的设置;只检查示例中的部分配置不算兼容校验。
新增 profile 必须定义闭合的签名字段表。首个 profile 的 `feature_signature_sha256` 摘要
对象包含 `feature_version`、`processing_backend`、有效的图像预处理/光流/HHD 配置和计算资产哈希;
不含文件绝对路径、SN、显示资源或力读数滤波。以 UTF-8、键排序、无多余空格的 JSON 计算 SHA-256。
特征定义或单位改变须升级 `feature_version`,配置覆盖后重新计算,不能用配置文件名代替。
实现由 `calibration_context.build_context` 统一生成,特征版本为 `orisys_hhd_raw/1`。
## 空间增益模型
@@ -132,6 +137,11 @@ runtime = FingerRuntime.open(
未覆盖、位置失败、归零无效均返回无效,不能偷偷改用 M0。
- `valid_force_range_N` 必填。估计超范围时保留数值并标记 `out_of_range`,不截断。
FingerRuntime 使用 `artifacts` 中 `role="coordinate_map"` 的只读 NPZ:
`uv_by_output_px` 为 H×W×2 有符号 UV,`valid_mask` 为 H×W bool;可附 `origin_xy` 与
`flat_mapping_sha256`。`pipeline.output_domain_sha256` 绑定实际展开的 remap、mask 和 world_xyz;
形状、几何或特征身份不同则在打开视频前拒绝。离线直接提供 UV 的模型可以没有映射附件。
单点/两点数据不自动切换算法;可导出独立的 `global_gain/1`,参数为各输出轴的增益和有效范围,
输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。
这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。
@@ -179,7 +189,7 @@ runtime = FingerRuntime.open(
## 每帧推断与有效性
内部统一生命周期如下;是拟议接口,不是现有 API。
内部统一生命周期如下,`calibration.load` 分流到旧曲线或新模型:
```text
load(selected_file, device_identity, pipeline) -> CalibrationModel
@@ -192,7 +202,8 @@ CalibrationResult:
coordinate_unmapped | outside_coverage | out_of_range}
```
FN 空间模型输入仅允许单一接触;无法确认单接触时不能宣称已标定总力。
FN 空间模型输入仅允许单一接触;当前定位器只返回一个中心,无法证明不存在多接触,
不能宣称已标定总力。全局 `force_delta/1` 不要求定位,未提供接触状态不等同于无接触。
无接触、失败帧的校准输出为空,raw 另行保留;缺失值不是 0 N。
`out_of_range` 有数值但不在验证范围,其余失败状态无数值;未声明轴不可用、单位仍为 raw。
不把某轴 raw 填进 `values_N`,也不只靠全局 `force_unit` 判断三轴单位。
@@ -206,6 +217,10 @@ schema 1/2 仍保留旧滤波/转换顺序。状态模型不能塞入会调用
加载诊断至少包含路径、来源、SN 校验状态、模型/输入 profile、轴集合、内容指纹和失败原因。
加载不匹配失败与逐帧暂时无效分开;替换时先完成加载校验,失败不得破坏原活动模型。
Sensor 提供 `bind_calibration_context`、`set_calibration_reference`、`apply_calibration_frame`;
FingerRuntime 自动绑定真实上下文,并转发归零。`FrameSnapshot.force_units` 和
`calibration_status` 随帧保存,不能用稍后读取的 Sensor 状态解释旧帧。
归零与换模型须在计算线程或暂停处理时调用,不承诺并发修改安全。
## 兼容、实现位置与验收
@@ -219,9 +234,14 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
| `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 |
| 新 `calibration_models.py` | 受控类型表、空间增益与模型生命周期;ML 后端另行实现 |
| `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 |
| `force_readout.py` | 保留旧路径,为 schema 3 分离推断与读数滤波 |
| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 |
| 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 |
当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。
`data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付,
`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。
它不是生产包默认文件,需显式 `cal_path` 选择。
实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用;
3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触;
FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。
+61 -8
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from typing import Any
CURRENT_SCHEMA = 2
SUPPORTED_SCHEMAS = (1, 2)
SUPPORTED_SCHEMAS = (1, 2, 3)
CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy")
CHANNEL_ALIASES: dict[str, str] = {
@@ -500,6 +500,17 @@ def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResu
warns: list[str] = []
notes: list[str] = []
if getattr(doc, "schema_version", None) == 3 or (isinstance(doc, dict) and doc.get("schema_version") == 3):
from .calibration_models import parse_model
try:
selected = parse_model(doc) if isinstance(doc, dict) else doc
if pipeline is not None:
selected.validate_for_context(pipeline)
except (CalibrationError, ValueError, TypeError) as exc:
return CheckResult(ok=False, errors=[str(exc)])
return CheckResult(ok=True, errors=[], warnings=[])
if isinstance(doc, Calibration):
data = doc.to_dict()
channels_obj = doc.channels
@@ -605,6 +616,14 @@ def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
if schema not in SUPPORTED_SCHEMAS:
raise CalibrationError(f"unsupported schema_version: {schema}")
if schema == 3:
from .calibration_models import parse_model
try:
return parse_model(data, source_path=source_path)
except (ValueError, TypeError, KeyError) as exc:
raise CalibrationError(f"invalid schema 3 calibration: {exc}") from exc
unit = str(data.get("unit", "N"))
channels_raw = data.get("channels")
if not isinstance(channels_raw, dict):
@@ -766,7 +785,7 @@ def load(
explicit = source is not None and str(source).strip() != ""
if explicit:
path = Path(str(source)).expanduser()
path = select_calibration_path(cal_path=source)
if not path.is_file():
msg = f"calibration file not found: {path}"
if strict or explicit:
@@ -801,7 +820,7 @@ def load(
try:
return parse(data, source_path=path)
except CalibrationError:
if strict or explicit:
if strict or explicit or (isinstance(data, dict) and (data.get("schema_version") == 3 or "model" in data)):
raise
return None
@@ -814,6 +833,26 @@ def load_for_device(
pipeline: Mapping[str, Any] | None = None,
) -> Calibration | None:
"""Load ``<directory>/<sensor_type>/<device_id>.json``; missing file means raw."""
path = select_calibration_path(calibration_dir=calibration_dir, sensor_type=sensor_type, device_id=device_id)
if path is None:
return None
cal = load(path, strict=True)
assert cal is not None
return validate_for_device(cal, sensor_type, device_id, pipeline=pipeline)
def select_calibration_path(*, cal_path=None, calibration_dir=None, sensor_type=None,
device_id=None, default_path=None) -> Path | None:
"""Select a file without guessing a model or falling back across device identities."""
explicit = cal_path is not None and bool(str(cal_path).strip())
if explicit and calibration_dir is not None:
raise CalibrationError("cal_path and calibration_dir are mutually exclusive")
if device_id is not None and not explicit and calibration_dir is None:
raise CalibrationError("device_id requires calibration_dir or cal_path")
if explicit:
return Path(cal_path).expanduser()
if calibration_dir is None:
return Path(default_path) if default_path is not None else None
root = Path(calibration_dir).expanduser()
if not root.is_dir():
raise CalibrationError(f"calibration directory not found: {root}")
@@ -827,9 +866,7 @@ def load_for_device(
return None
if not path.is_file():
raise CalibrationError(f"calibration path is not a file: {path}")
cal = load(path, strict=True)
assert cal is not None
return validate_for_device(cal, sensor_type, device_id, pipeline=pipeline)
return path
def validate_for_device(
@@ -843,6 +880,19 @@ def validate_for_device(
path = cal.source_path or "<in-memory calibration>"
if cal.identity.get("sensor_type") != sensor_type or cal.identity.get("sn") != device_id:
raise CalibrationError(f"calibration identity mismatch: {path}")
if cal.schema_version == 3:
if cal.unit != "N" or not cal.channels:
raise CalibrationError(f"calibration requires declared force axes in N: {path}")
# Full feature/geometry validation happens when the processor binds its
# actual context. Device construction only knows this legacy subset.
if pipeline is not None:
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
if pipeline.get(key) is not None and cal.pipeline.get(key) != pipeline[key]:
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
measurement, _ = filter_mismatches(cal.pipeline.get("filters"), pipeline.get("filters"))
if measurement:
raise CalibrationError(f"calibration pipeline mismatch ({'; '.join(measurement)}): {path}")
return cal
if cal.unit != "N" or any(cal.channels[axis].unit != "N" for axis in cal.channels):
raise CalibrationError(f"calibration unit must be N: {path}")
if set(CHANNEL_IDS) - cal.channels.keys():
@@ -1050,10 +1100,13 @@ def write(
path = Path(path)
if path.exists() and not overwrite:
raise FileExistsError(f"calibration file exists: {path}")
if cal.schema_version == 3:
# A moved manifest must not acquire unresolved or different attachments.
parse(cal.to_dict(), source_path=path)
path.parent.mkdir(parents=True, exist_ok=True)
doc = cal.to_dict()
# Always emit schema 2 for new writes.
doc["schema_version"] = CURRENT_SCHEMA
# Preserve an explicitly selected model format; legacy builders still emit v2.
doc["schema_version"] = 3 if cal.schema_version == 3 else CURRENT_SCHEMA
path.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
+75
View File
@@ -0,0 +1,75 @@
"""Measurement identities shared by SDK runtime and offline calibration exporters."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import numpy as np
from .calibration import filters_fingerprint
def sha256_file(path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def domain_fingerprint(domain) -> str:
"""Bind a coordinate lookup to the actual unroll, not just its dimensions."""
data = domain.to_dict() if hasattr(domain, "to_dict") else domain
digest = hashlib.sha256()
for key, dtype in (("map_x", "<f4"), ("map_y", "<f4"), ("valid_mask", "u1"), ("world_xyz", "<f4")):
array = np.ascontiguousarray(data[key], dtype=dtype)
digest.update(key.encode("ascii"))
digest.update(str(array.shape).encode("ascii"))
digest.update(array.tobytes())
return digest.hexdigest()
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
coordinates=None, output_domain_sha256=None, processing_backend=None,
preprocessing=None) -> dict:
"""Build the closed v1 feature identity from effective values, excluding readout filters."""
camera = config.get("camera", {})
tracker = dict(config.get("tracker", {}))
raw_downsample = tracker.get("hhd_downsample", 1)
try:
downsample = max(1, int(raw_downsample)) if raw_downsample is not None else 1
except (TypeError, ValueError):
downsample = 1
tracker["hhd_downsample"] = downsample
if source_scale is not None:
source_scale = float(source_scale)
measurement = filters_fingerprint(config.get("filters", {}) if filters is None else filters).get("measurement", {})
# A missing EMA setting has the same effective meaning as the SDK default.
measurement.setdefault("flow_ema_alpha", 1.0)
result = {
"stitch_resolution": camera.get("resolution_stitch"),
"dis_mode": tracker.get("dis_mode"),
"hhd_downsample": downsample,
"filters": {"measurement": measurement},
}
if geometry_sha256 is not None:
result["geometry_sha256"] = geometry_sha256
if source_scale is not None:
result["source_scale"] = float(source_scale)
if coordinates is not None:
result["coordinates"] = coordinates
if output_domain_sha256 is not None:
result["output_domain_sha256"] = output_domain_sha256
effective = {
"feature_version": "orisys_hhd_raw/1",
"processing_backend": processing_backend or config.get("type", "compound_eye"),
"camera": {key: camera.get(key) for key in ("resolution_raw", "resolution_stitch")},
"tracker": tracker,
"contact": config.get("contact", {}),
"measurement": measurement,
"geometry_sha256": geometry_sha256,
"output_domain_sha256": output_domain_sha256,
"source_scale": source_scale,
"preprocessing": preprocessing or {},
}
encoded = json.dumps(effective, sort_keys=True, separators=(",", ":"), allow_nan=False)
result["feature_signature_sha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
return result
+307
View File
@@ -0,0 +1,307 @@
"""Validated, versioned force models; acquisition and training stay outside here."""
from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
import numpy as np
from scipy.spatial import ConvexHull, QhullError
def _require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def _keys(value: Any, required: set[str], optional: set[str] | None = None) -> None:
_require(isinstance(value, dict), "expected a JSON object")
_require(required <= value.keys(), f"missing fields: {required - value.keys()}")
_require(value.keys() <= required | (optional or set()), "unknown semantic fields")
def _number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def _sha(value: Any) -> bool:
return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
def _area(polygon: np.ndarray) -> float:
if len(polygon) < 3:
return 0.0
return abs(float(np.dot(polygon[:, 0], np.roll(polygon[:, 1], 1)) -
np.dot(polygon[:, 1], np.roll(polygon[:, 0], 1)))) / 2
def _cross(a: np.ndarray, b: np.ndarray) -> float:
return float(a[0] * b[1] - a[1] * b[0])
def _intersection_area(first: np.ndarray, second: np.ndarray) -> float:
"""Clip two counterclockwise convex polygons, including shared edges."""
polygon = list(first)
for a, b in zip(second, np.roll(second, -1, axis=0)):
previous = polygon
polygon = []
if not previous:
break
for start, end in zip(previous, previous[1:] + previous[:1]):
ds, de = _cross(b - a, start - a), _cross(b - a, end - a)
if (ds >= 0) != (de >= 0):
polygon.append(start + (end - start) * ds / (ds - de))
if de >= 0:
polygon.append(end)
return _area(np.asarray(polygon))
def _mesh(params: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
points, gains, triangles = (params[k] for k in ("points_uv", "gains_N_per_raw", "triangles"))
_require(isinstance(points, list) and len(points) >= 3, "spatial mesh needs at least 3 points")
_require(all(isinstance(p, list) and len(p) == 2 and all(_number(x) for x in p) for p in points),
"points_uv must be finite Nx2 numbers")
_require(len({tuple(p) for p in points}) == len(points), "duplicate spatial points")
_require(isinstance(gains, list) and len(gains) == len(points) and
all(_number(g) and g > 0 for g in gains), "gains must be finite positive numbers")
_require(isinstance(triangles, list) and bool(triangles), "triangles are required")
_require(all(isinstance(t, list) and len(t) == 3 and
all(type(i) is int and 0 <= i < len(points) for i in t) and len(set(t)) == 3
for t in triangles),
"invalid triangle indices")
vertices = np.asarray(points, dtype=float)
try:
hull_area = ConvexHull(vertices).volume
except QhullError as exc:
raise ValueError("spatial mesh points are collinear") from exc
tolerance = max(1.0, hull_area) * 1e-9
polygons = []
for indices in triangles:
polygon = vertices[indices]
signed = _cross(polygon[1] - polygon[0], polygon[2] - polygon[0])
_require(abs(signed) / 2 > tolerance, "degenerate triangle")
if signed < 0:
polygon = polygon[::-1]
_require(all(_intersection_area(polygon, other) <= tolerance for other in polygons),
"overlapping spatial triangles")
polygons.append(polygon)
_require({i for triangle in triangles for i in triangle} == set(range(len(points))),
"spatial mesh must use all support points")
_require(abs(sum(_area(p) for p in polygons) - hull_area) <= tolerance,
"triangulation must cover the complete convex hull")
return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int)
def _pipeline(data: Any, spatial: bool) -> None:
required = {"stitch_resolution", "dis_mode", "hhd_downsample",
"filters", "feature_signature_sha256"}
_keys(data, required | ({"geometry_sha256", "coordinates", "source_scale"} if spatial else set()),
{"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
size = data["stitch_resolution"]
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
"invalid stitch_resolution")
for key in ("dis_mode", "hhd_downsample"):
_require(type(data[key]) is int and data[key] >= (1 if key == "hhd_downsample" else 0),
f"invalid {key}")
if "source_scale" in data:
_require(_number(data["source_scale"]) and data["source_scale"] > 0, "invalid source_scale")
_require(_sha(data["feature_signature_sha256"]), "invalid feature signature SHA-256")
_keys(data["filters"], {"measurement"}, {"readout"})
_keys(data["filters"]["measurement"], {"flow_ema_alpha"})
alpha = data["filters"]["measurement"]["flow_ema_alpha"]
_require(_number(alpha) and 0 < alpha <= 1, "invalid flow_ema_alpha")
if "geometry_sha256" in data:
_require(_sha(data["geometry_sha256"]), "invalid geometry SHA-256")
if "output_domain_sha256" in data:
_require(_sha(data["output_domain_sha256"]), "invalid output domain SHA-256")
if "coordinates" in data:
coord = data["coordinates"]
_keys(coord, {"frame", "unit", "origin_xy", "map_sha256"})
_require(coord["frame"] == "autocal_flat" and coord["unit"] == "px", "unsupported coordinates")
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
_require(_sha(coord["map_sha256"]), "invalid map SHA-256")
@dataclass(frozen=True)
class CalibrationResult:
values_N: dict[str, float | None]
status: dict[str, str]
class ModelCalibration:
"""One validated model per stream. Current gain backends are stateless."""
schema_version = 3
unit = "N"
def __init__(self, document: dict[str, Any], source_path: str | Path | None = None):
self._document = deepcopy(document)
self.source_path = Path(source_path).resolve() if source_path is not None else None
self.identity = deepcopy(document["identity"])
self.pipeline = deepcopy(document["pipeline"])
self.provenance = deepcopy(document.get("provenance", {}))
self.model_type = document["model"]["type"]
self.input_profile = document["model"]["input_profile"]
self.outputs = tuple(document["model"]["outputs"])
self.channels = {axis: "N" for axis in self.outputs}
self._params = self._document["model"]["params"]
self.coordinate_map = self.coordinate_valid_mask = None
self._artifact_hashes: list[str] = []
if self.model_type == "spatial_gain/1":
self._points, self._gains, self._triangles = _mesh(self._params)
self._load_artifacts(document["model"].get("artifacts", []))
def _load_artifacts(self, artifacts: Any) -> None:
_require(isinstance(artifacts, list), "artifacts must be an array")
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
for artifact in artifacts:
_keys(artifact, {"role", "path", "sha256"})
_require(self.model_type == "spatial_gain/1" and artifact["role"] == "coordinate_map",
"unsupported artifact role")
name = artifact["path"]
_require(isinstance(name, str) and bool(name) and ":" not in name and
not PureWindowsPath(name).is_absolute() and not PurePosixPath(name).is_absolute() and
".." not in PurePosixPath(name.replace("\\", "/")).parts,
"artifact path must be relative without parent traversal")
_require(self.source_path is not None, "artifact requires calibration source_path")
path = (self.source_path.parent / name).resolve()
_require(path.is_relative_to(self.source_path.parent) and path.is_file(),
"artifact missing or escapes calibration directory")
digest = hashlib.sha256(path.read_bytes()).hexdigest()
_require(_sha(artifact["sha256"]) and digest == artifact["sha256"], "artifact SHA-256 mismatch")
_require(self.pipeline["coordinates"]["map_sha256"] == digest,
"coordinate map identity does not match artifact SHA-256")
self._artifact_hashes.append(digest)
try:
with np.load(path, allow_pickle=False) as archive:
keys = set(archive.files)
_require({"uv_by_output_px", "valid_mask"} <= keys <=
{"uv_by_output_px", "valid_mask", "origin_xy", "flat_mapping_sha256"},
"invalid coordinate_map keys")
uv, mask = archive["uv_by_output_px"], archive["valid_mask"]
_require(uv.ndim == 3 and uv.shape[2] == 2 and mask.shape == uv.shape[:2],
"invalid coordinate_map shape")
_require(mask.dtype == np.bool_ and np.issubdtype(uv.dtype, np.floating),
"invalid coordinate_map dtype")
_require(np.isfinite(uv[mask]).all(), "non-finite mapped coordinates")
if "origin_xy" in keys:
_require(np.array_equal(archive["origin_xy"], self.pipeline["coordinates"]["origin_xy"]),
"coordinate_map origin mismatch")
self.coordinate_map, self.coordinate_valid_mask = uv.copy(), mask.copy()
self.coordinate_map.flags.writeable = False
self.coordinate_valid_mask.flags.writeable = False
except (OSError, TypeError) as exc:
raise ValueError("invalid coordinate_map artifact") from exc
@property
def fingerprint(self) -> str:
content = json.dumps(self._document, sort_keys=True, separators=(",", ":"), allow_nan=False)
return hashlib.sha256((content + "".join(self._artifact_hashes)).encode("utf-8")).hexdigest()
def to_dict(self) -> dict[str, Any]:
return deepcopy(self._document)
def validate_for_context(self, pipeline: Mapping[str, Any]) -> None:
for key, value in self.pipeline.items():
if key == "filters":
actual = pipeline.get(key, {})
_require(isinstance(actual, Mapping) and actual.get("measurement") == value["measurement"],
"calibration pipeline mismatch: filters.measurement")
else:
_require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
def reset(self, reference_id: Any = None) -> None:
"""Gain backends have no history; preserve the lifecycle for future models."""
def convert(self, fn: float, fx: float, fy: float) -> None:
"""Reject legacy scalar calls which lack frame, position and zero features."""
from .calibration import CalibrationError
raise CalibrationError("schema 3 requires frame features; use predict/apply_calibration_frame")
def predict(self, frame_id: int, timestamp_s: float | None, features: Mapping[str, Any],
reference_id: Any = None) -> CalibrationResult:
def invalid(reason: str) -> CalibrationResult:
return CalibrationResult({a: None for a in self.outputs}, {a: reason for a in self.outputs})
if (not isinstance(features, Mapping) or type(frame_id) is not int or
(timestamp_s is not None and not _number(timestamp_s))):
return invalid("invalid_input")
if features.get("zero_valid") is not True:
return invalid("zero_unavailable")
if features.get("contact_found") is False:
return invalid("no_contact")
values = {}
if self.model_type == "spatial_gain/1":
if features.get("contact_found") is not True:
return invalid("invalid_input")
if features.get("single_contact", True) is not True or features.get("contact_count", 1) != 1:
return invalid("invalid_input")
if features.get("mapping_valid") is not True:
return invalid("coordinate_unmapped")
if not all(_number(features.get(k)) for k in ("delta_raw", "u", "v")):
return invalid("invalid_input")
query = np.asarray([features["u"], features["v"]])
for indices in self._triangles:
triangle = self._points[indices]
weights = np.linalg.solve((triangle[1:] - triangle[0]).T, query - triangle[0])
weights = np.asarray([1 - sum(weights), *weights])
if np.min(weights) >= -1e-10:
values["fn"] = float(np.dot(weights, self._gains[indices]) * features["delta_raw"])
break
if not values:
return invalid("outside_coverage")
else:
deltas = features.get("delta_raw_by_axis", features.get("force_delta_raw", {}))
if not isinstance(deltas, Mapping):
return invalid("invalid_input")
for axis in self.outputs:
delta = deltas.get(axis, features.get("delta_raw") if axis == "fn" else None)
values[axis] = delta * self._params["gains_N_per_raw"][axis] if _number(delta) else None
low, high = self._params["valid_force_range_N"]
statuses = {}
for axis, value in values.items():
if value is None or not _number(value):
values[axis], statuses[axis] = None, "invalid_input"
else:
statuses[axis] = "ok" if low <= value <= high else "out_of_range"
return CalibrationResult(values, statuses)
def parse_model(data: dict[str, Any], source_path: str | Path | None = None) -> ModelCalibration:
"""Parse schema 3 without executing document-specified code or installing backends."""
_keys(data, {"schema_version", "unit", "identity", "pipeline", "model"}, {"provenance"})
_require(type(data["schema_version"]) is int and data["schema_version"] == 3, "expected schema_version 3")
_require(data["unit"] == "N", "schema 3 unit must be N")
_keys(data["identity"], {"sensor_type", "sn"})
_require(all(isinstance(v, str) and bool(v.strip()) for v in data["identity"].values()), "invalid identity")
_require(isinstance(data.get("provenance", {}), dict), "provenance must be an object")
model = data["model"]
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
_require(model["type"] in ("spatial_gain/1", "global_gain/1"), f"unsupported calibration model: {model['type']}")
spatial = model["type"] == "spatial_gain/1"
_require(model["input_profile"] == ("fn_delta_autocal_uv/1" if spatial else "force_delta/1"),
"unsupported input_profile for model")
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
outputs = model["outputs"]
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
len(set(outputs)) == len(outputs), "invalid model outputs")
_require(not spatial or outputs == ["fn"], "spatial model supports fn only")
_pipeline(data["pipeline"], spatial)
params = model["params"]
_keys(params, {"gains_N_per_raw", "valid_force_range_N"} | ({"points_uv", "triangles"} if spatial else set()))
force_range = params["valid_force_range_N"]
_require(isinstance(force_range, list) and len(force_range) == 2 and
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
if not spatial:
gains = params["gains_N_per_raw"]
_keys(gains, set(outputs))
_require(all(_number(g) and g > 0 for g in gains.values()), "gains must be finite positive numbers")
return ModelCalibration(data, source_path)
+40
View File
@@ -105,6 +105,17 @@ class TactileProcessor:
def force_units(self) -> tuple[str, str, str]:
return self._sensor.force_units
@property
def calibration_status(self):
return self._sensor.calibration_status
@property
def calibration_result(self):
return self._sensor.calibration_result
def set_calibration_reference(self, raw, *, reference_id):
return self._sensor.set_calibration_reference(raw, reference_id=reference_id)
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
"""Feed one processed tactile frame into the analysis pipeline."""
mask = self._default_valid_mask if valid_mask is None else valid_mask
@@ -305,6 +316,26 @@ class FingerRuntime:
calibration_dir=calibration_dir, device_id=device_id,
verbose=verbose, mirror=mirror,
)
if getattr(getattr(sensor, "calibration", None), "schema_version", None) == 3:
from ..calibration_context import (
build_context,
domain_fingerprint,
sha256_file,
)
model = sensor.calibration
uv_map = getattr(model, "coordinate_map", None)
if model.input_profile == "fn_delta_autocal_uv/1":
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
raise ValueError("calibration coordinate map does not match finger output shape")
sensor.bind_calibration_context(build_context(
sensor._config, sensor.filters_state(),
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
coordinates=model.pipeline.get("coordinates"),
output_domain_sha256=domain_fingerprint(domain),
processing_backend="uv_unroll",
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
))
capture_config = CaptureConfig.from_camera_dict(
video,
@@ -351,6 +382,15 @@ class FingerRuntime:
def valid_mask(self):
return self.frame_processor.valid_mask
def set_calibration_reference(self, raw, *, reference_id):
"""Set an explicitly measured unloaded reference for this finger stream."""
self.tactile.raw_sensor.set_calibration_reference(raw, reference_id=reference_id)
@property
def calibration_status(self):
"""Copy the current per-axis calibration validity state."""
return dict(self.tactile.raw_sensor.calibration_status)
def read_source(self):
if self.capture_source is not None and self.capture_source.running:
return self.capture_source.read(copy=True, wait_s=1.0)
+4
View File
@@ -154,6 +154,8 @@ class FrameSnapshot:
#: compare it with ``image.shape`` to see how much the frame was reduced.
image_size_raw: tuple[int, int] | None = None
source_metadata: Any = None
force_units: tuple[str, str, str] | None = None
calibration_status: dict[str, str] | None = None
@dataclass
@@ -654,6 +656,8 @@ class StreamPipeline:
shear_x_raw=float(sensor._force_raw[1]),
shear_y_raw=float(sensor._force_raw[2]),
torque=float(getattr(sensor, "ftor", 0.0)),
force_units=tuple(getattr(sensor, "force_units", (getattr(sensor, "force_unit", "raw"),) * 3)),
calibration_status=dict(getattr(sensor, "calibration_status", {})),
# Both already private per-frame copies owned by this snapshot.
image=frame if "image" in include else None,
flow=flow if "flow" in include else None,
+225 -3
View File
@@ -182,6 +182,23 @@ class Sensor:
config_fps=config_fps,
verbose=self.verbose,
)
self._model_frame_id = 0
self._calibration_result_frame = None
self._calibration_reference = {}
self._calibration_reference_id = None
self._model_readouts = {}
self.calibration_result = None
self.calibration_status = {}
self._calibration_context = None
if self._uses_calibration_model():
self._reset_calibration_session()
if (self._uses_calibration_model() and self.calibration.input_profile == "force_delta/1"
and not self._external_only):
from .calibration_context import build_context
self.bind_calibration_context(build_context(
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
))
if self.verbose:
if self.calibration is not None and self.calibration.source_path is not None:
@@ -726,7 +743,15 @@ class Sensor:
self._force_raw = (float(fn), float(fx), float(fy))
self.ftor = float(ftor)
self._force_readout.update_sample_rate()
self._apply_force_filter()
if self._uses_calibration_model():
self._model_frame_id += 1
self.compute_contact_valid = False
self.centroid_found = False
self._force_readout.apply(self._force_raw)
self._force_filtered_raw = self._force_readout.filtered_raw
self._force_raw_unfiltered = self._force_raw
else:
self._apply_force_filter()
vnormal_ds = self.__hhd.get_normal()
vshear_ds = self.__hhd.get_shear()
@@ -762,6 +787,14 @@ class Sensor:
self.compute_deformation_valid = True
if self._uses_calibration_model():
# Spatial inference must use this HHD frame's contact, even when the
# caller did not request contact diagnostics from StreamPipeline.
if self.calibration.input_profile == "fn_delta_autocal_uv/1":
self.compute_contact()
else:
self.apply_calibration_frame(frame_id=self._model_frame_id)
return True
def compute_contact(self, vnormal=None):
@@ -774,6 +807,11 @@ class Sensor:
Returns:
dict: Contact result with centroid, contour, and depth_map
"""
if self._uses_calibration_model() and not self.compute_deformation_valid:
raise cal_mod.CalibrationError("compute current deformation before schema 3 contact")
if (self._uses_calibration_model() and self.compute_contact_valid
and self._calibration_result_frame == self._model_frame_id):
return self._model_contact_result
multiple_foe = False
# Use provided vnormal or fall back to internal state
@@ -807,6 +845,13 @@ class Sensor:
print("Warning: contact centroid lies outside the valid UV mask.")
self.compute_contact_valid = True
if self._uses_calibration_model():
self._model_contact_result = result
uv = self._calibration_contact_uv()
self.apply_calibration_frame(
frame_id=self._model_frame_id, contact_found=bool(self.centroid_found),
uv=uv, mapping_valid=uv is not None,
)
return result
@@ -871,6 +916,8 @@ class Sensor:
# session must not report the previous one's reading.
self._force_unfiltered = None
self._force_raw_unfiltered = None
self._reset_calibration_session()
self.compute_deformation_valid = False
if self.img is not None:
self.initial_img = self.img.copy()
@@ -950,6 +997,10 @@ class Sensor:
alpha = float(flow_ema_alpha)
if not (0.0 < alpha <= 1.0):
raise ValueError("flow_ema_alpha must be in (0, 1]")
if self._uses_calibration_model() and alpha != self._force_readout.flow_ema_alpha:
raise cal_mod.CalibrationError(
"flow_ema_alpha changes schema 3 measurement identity; load a matching pipeline"
)
if self.calibration is not None:
mismatches, _ = cal_mod.filter_mismatches(
self.calibration.pipeline.get("filters"),
@@ -965,8 +1016,17 @@ class Sensor:
flow_ema_alpha=flow_ema_alpha,
force_median_window=force_median_window,
)
for readout in getattr(self, "_model_readouts", {}).values():
readout.configure(
lowpass_cutoff_hz=lowpass_cutoff_hz,
lowpass_sample_rate_hz=lowpass_sample_rate_hz,
lowpass_order=lowpass_order,
lowpass_enabled=lowpass_enabled,
force_median_window=force_median_window,
)
if filter_changed and self.compute_deformation_valid:
self._apply_force_filter()
if not self._uses_calibration_model():
self._apply_force_filter()
return self
@@ -1139,6 +1199,7 @@ class Sensor:
def set_calibration(
self,
cal: "cal_mod.Calibration | str | Path | None",
*, context=None,
) -> None:
"""Attach or replace the active force calibration (live swap after save)."""
if cal is None:
@@ -1146,8 +1207,12 @@ class Sensor:
self.device_calibration_missing = False
self._force_cal_sensor_name = None
self._force_cal_attempts = None
self._reset_calibration_session()
self._calibration_context = None
self._force_readout.reset()
self._apply_force_filter()
return
if isinstance(cal, cal_mod.Calibration):
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
selected = cal
else:
selected = cal_mod.load(cal, strict=True)
@@ -1156,9 +1221,30 @@ class Sensor:
selected = cal_mod.validate_for_device(
selected, family, self.device_id, pipeline=pipeline
)
if getattr(selected, "schema_version", None) == 3:
context = context if context is not None else getattr(self, "_calibration_context", None)
if context is None and selected.input_profile == "force_delta/1":
from .calibration_context import build_context
context = build_context(
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
)
if context is None:
raise cal_mod.CalibrationError("actual pipeline context required before replacing model")
try:
selected.validate_for_context(context)
except ValueError as exc:
raise cal_mod.CalibrationError(str(exc)) from exc
else:
context = None
self.calibration = selected
self._calibration_context = context
self.device_calibration_missing = False
self._force_cal_attempts = None
self._reset_calibration_session()
if not self._uses_calibration_model():
self._force_readout.reset()
self._apply_force_filter()
def _apply_force_filter(self):
convert = self.calibration.convert if self.calibration is not None else None
@@ -1169,6 +1255,142 @@ class Sensor:
self._force_raw_unfiltered = readout.raw_unfiltered
self._force_unfiltered = readout.unfiltered_newton
def _uses_calibration_model(self):
return getattr(self.calibration, "schema_version", None) == 3
def bind_calibration_context(self, context):
"""Verify the actual feature pipeline before allowing schema 3 inference."""
if not self._uses_calibration_model():
raise cal_mod.CalibrationError("calibration context requires schema 3")
try:
self.calibration.validate_for_context(context)
except ValueError as exc:
raise cal_mod.CalibrationError(str(exc)) from exc
self._calibration_context = context
def _reset_calibration_session(self):
self._calibration_reference = {}
self._calibration_reference_id = None
self._calibration_result_frame = None
self.calibration_result = None
self.calibration_status = {}
self.compute_contact_valid = False
self.centroid_found = False
self.centroid = (0, 0)
self._model_contact_result = None
for readout in getattr(self, "_model_readouts", {}).values():
readout.reset()
if self._uses_calibration_model():
self.calibration.reset(None)
self.calibration_status = {axis: "zero_unavailable" for axis in self.calibrated_axes}
values = [float("nan") if axis in self.calibrated_axes else raw
for axis, raw in zip(cal_mod.CHANNEL_IDS, self._force_raw)]
self.fnormal, self.fshearx, self.fsheary = values
self._force_unfiltered = tuple(values)
def set_calibration_reference(self, raw, *, reference_id):
"""Declare a measured unloaded raw reference for this stream, not the model file."""
if not isinstance(raw, dict) or not reference_id:
raise ValueError("raw must map axes to finite values and reference_id is required")
values = {}
for axis, value in raw.items():
if axis not in cal_mod.CHANNEL_IDS or not np.isfinite(float(value)):
raise ValueError("reference axes and values must be valid and finite")
values[axis] = float(value)
self._reset_calibration_session()
self._calibration_reference = values
self._calibration_reference_id = str(reference_id)
if self._uses_calibration_model():
self.calibration.reset(self._calibration_reference_id)
def _calibration_contact_uv(self):
if not self.centroid_found:
return None
uv_map = getattr(self.calibration, "coordinate_map", None)
valid = getattr(self.calibration, "coordinate_valid_mask", None)
if uv_map is None or valid is None:
return None
if not np.all(np.isfinite(self.centroid)):
return None
x, y = np.rint(self.centroid).astype(int)
if not (0 <= y < valid.shape[0] and 0 <= x < valid.shape[1] and valid[y, x]):
return None
return tuple(float(value) for value in uv_map[y, x])
def apply_calibration_frame(
self, *, frame_id, timestamp_s=None, raw=None, uv=None,
contact_found=None, mapping_valid=False, single_contact=True,
):
"""Infer once from an unfiltered raw triple and same-frame optional contact UV."""
if not self._uses_calibration_model():
raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3")
if self._calibration_context is None:
raise cal_mod.CalibrationError("bind the actual calibration context before inference")
if type(frame_id) is not int:
raise ValueError("frame_id must be an integer")
if frame_id == self._calibration_result_frame:
return self.calibration_result
if self._calibration_result_frame is not None and frame_id < self._calibration_result_frame:
self._reset_calibration_session()
raw_values = tuple(self._force_raw if raw is None else raw)
if len(raw_values) != 3:
raise ValueError("raw must contain FN, FX and FY")
if raw is not None:
self._force_raw = tuple(float(value) for value in raw_values)
if np.all(np.isfinite(self._force_raw)):
self._force_readout.apply(self._force_raw)
self._force_filtered_raw = self._force_readout.filtered_raw
raw_by_axis = dict(zip(cal_mod.CHANNEL_IDS, raw_values))
reference = self._calibration_reference
deltas = {axis: float(value) - reference.get(axis, float("nan"))
for axis, value in raw_by_axis.items()}
axes = self.calibrated_axes
features = {
"delta_raw": deltas["fn"], "force_delta_raw": deltas,
"zero_valid": all(axis in reference for axis in axes),
"mapping_valid": bool(mapping_valid), "single_contact": single_contact,
"u": None if uv is None else uv[0], "v": None if uv is None else uv[1],
}
if contact_found is not None:
features["contact_found"] = bool(contact_found)
result = self.calibration.predict(
frame_id=frame_id, timestamp_s=timestamp_s,
features=features, reference_id=self._calibration_reference_id,
)
filtered = list(self._force_readout.filtered_raw or raw_values)
unfiltered = list(raw_values)
for index, axis in enumerate(cal_mod.CHANNEL_IDS):
if axis not in axes:
continue
value = result.values_N.get(axis)
unfiltered[index] = float("nan") if value is None else float(value)
filtered[index] = unfiltered[index]
if value is not None and np.isfinite(value):
readout = self._model_readouts.get(axis)
if readout is None:
readout = ForceReadout(self._config.get("filters"),
config_fps=self._force_readout.config_fps)
base = self._force_readout
readout.configure(
lowpass_sample_rate_hz=base.lowpass_sample_rate_hz,
lowpass_cutoff_hz=base.lowpass_cutoff_hz,
lowpass_order=base.lowpass_order,
lowpass_enabled=base.lowpass_enabled,
force_median_window=base.force_median_window,
)
self._model_readouts[axis] = readout
base = self._force_readout
if readout.lowpass_sample_rate_hz != base.lowpass_sample_rate_hz:
readout.configure(lowpass_sample_rate_hz=base.lowpass_sample_rate_hz)
filtered[index] = readout.apply((value, value, value))[0]
self.fnormal, self.fshearx, self.fsheary = filtered
self._force_raw_unfiltered = tuple(float(value) for value in raw_values)
self._force_unfiltered = tuple(unfiltered)
self.calibration_result = result
self.calibration_status = dict(result.status)
self._calibration_result_frame = frame_id
return result
# ------------------------------------------------------- filter contract
def filters_state(self) -> dict:
"""What the filter chain is **actually** doing, for records and audits.
+7
View File
@@ -253,6 +253,13 @@ def create_processor(
elif verbose:
print(f"[processor] using the supplied sensor for {profile.profile_id}")
# A resolved selection (including explicit NONE) is authoritative. Otherwise
# Sensor's bundle auto-load can silently defeat the caller's selected file.
if hasattr(sensor, "set_calibration"):
sensor.set_calibration(profile.calibration.document)
elif profile.calibration.document is not None:
raise ProcessorCreationError("supplied sensor cannot apply the selected calibration")
if backend_id == BACKEND_COMPOUND_EYE:
config_stitcher = profile.config.get("stitcher")
stitcher = Stitcher(
+10 -1
View File
@@ -119,12 +119,13 @@ def resolve_calibration(
)
if mode is CalibrationSelection.EXPLICIT:
path = explicit
path = cal_mod.select_calibration_path(cal_path=explicit)
else:
path = declared_default
if path is None:
fallback = bundle_root / "force_calibration.json"
path = fallback if fallback.is_file() else None
path = cal_mod.select_calibration_path(default_path=path)
if path is None:
# Not an error: a profile may legitimately ship no calibration. Calibrated force is
@@ -197,6 +198,12 @@ def resolve_calibration(
identity = _identity_of(document)
declared_type = str(identity.get("sensor_type") or "")
if document.schema_version == 3 and declared_type != product_family:
return ResolvedCalibration(
selection=mode, path=path,
issues=(Issue(code="calibration.model-family-mismatch",
message="selected model belongs to another product family", path=path),),
)
if declared_type:
applicability = "declared"
if declared_type != product_family:
@@ -273,6 +280,8 @@ def calibration_fingerprint(document: "cal_mod.Calibration", path: Path) -> str:
a different directory must produce the same fingerprint.
"""
try:
if getattr(document, "schema_version", None) == 3:
return document.fingerprint
payload = document.to_dict()
except Exception: # noqa: BLE001 - fall back to the raw bytes rather than fail
payload = path.read_bytes().decode("utf-8", errors="replace")
+59
View File
@@ -0,0 +1,59 @@
"""Feature identities use effective measurement settings, never display metadata."""
from copy import deepcopy
import numpy as np
import pytest
from orisys.calibration_context import build_context, domain_fingerprint
def config():
return {"camera": {"resolution_raw": [1280, 720], "resolution_stitch": [700, 750]},
"tracker": {"dis_mode": 4}}
@pytest.mark.parametrize("raw,expected", [(None, 1), ("bad", 1), (0, 1), (-3, 1), ("3", 3), (3.7, 3)])
def test_hhd_uses_actual_sensor_normalization(raw, expected):
data = config()
data["tracker"]["hhd_downsample"] = raw
assert build_context(data)["hhd_downsample"] == expected
def test_default_values_equal_explicit_effective_settings():
implicit = build_context(config())
data = config()
data["tracker"]["hhd_downsample"] = 1
explicit = build_context(data, {"flow_ema_alpha": 1.0})
assert implicit["hhd_downsample"] == 1
assert implicit["filters"]["measurement"] == {"flow_ema_alpha": 1.0}
assert implicit["feature_signature_sha256"] == explicit["feature_signature_sha256"]
def test_measurement_filters_change_identity_but_readout_and_paths_do_not():
data = config()
original = build_context(data)
assert build_context(data, {"flow_ema_alpha": 0.5})["feature_signature_sha256"] != original["feature_signature_sha256"]
data.update(name="other", video="D:/other.mp4", cal_path="E:/calibration.json")
assert build_context(data, {"force_median_window": 7, "force_lowpass_cutoff_hz": 2}) == original
def test_explicit_empty_filter_mapping_overrides_config_defaults():
data = config()
data["filters"] = {"flow_ema_alpha": 0.5}
assert build_context(data, {})["filters"]["measurement"] == {"flow_ema_alpha": 1.0}
def test_source_scale_numeric_types_have_identical_identity():
assert build_context(config(), source_scale=1) == build_context(config(), source_scale=1.0)
def test_domain_fingerprint_binds_map_content_not_only_dimensions():
domain = {"map_x": np.zeros((2, 3)), "map_y": np.zeros((2, 3)),
"valid_mask": np.ones((2, 3), dtype=bool), "world_xyz": np.zeros((2, 3, 3))}
original = domain_fingerprint(domain)
for key in domain:
changed = deepcopy(domain)
changed[key].flat[0] = not changed[key].flat[0] if key == "valid_mask" else 1
assert domain_fingerprint(changed) != original
assert domain_fingerprint({key: value.copy() for key, value in domain.items()}) == original
+179
View File
@@ -0,0 +1,179 @@
"""Schema 3 model validation and deterministic inference."""
import hashlib
from copy import deepcopy
import numpy as np
import pytest
from orisys.calibration import CalibrationError
from orisys.calibration_models import parse_model
def document():
return {
"schema_version": 3, "unit": "N",
"identity": {"sensor_type": "finger", "sn": "DD02-GL"},
"pipeline": {
"stitch_resolution": [700, 750], "dis_mode": 4,
"hhd_downsample": 3, "source_scale": 0.5,
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
"feature_signature_sha256": "a" * 64,
"geometry_sha256": "b" * 64,
"coordinates": {"frame": "autocal_flat", "unit": "px",
"origin_xy": [511, 1021], "map_sha256": "c" * 64},
},
"model": {
"type": "spatial_gain/1", "input_profile": "fn_delta_autocal_uv/1",
"output_frame": "sdk_force/1", "outputs": ["fn"],
"params": {"points_uv": [[0, 0], [10, 0], [0, 10]],
"gains_N_per_raw": [0.001, 0.002, 0.003],
"triangles": [[0, 1, 2]], "valid_force_range_N": [0, 3]},
},
}
def features(**overrides):
return dict(delta_raw=1000, u=2.5, v=2.5, contact_found=True,
zero_valid=True, mapping_valid=True, **overrides)
def test_interpolates_gain_not_sensitivity_and_roundtrips():
data = document()
model = parse_model(data)
result = model.predict(1, 0.1, features(), "zero-1")
assert result.values_N == {"fn": pytest.approx(1.75)}
assert result.status == {"fn": "ok"}
assert model.to_dict() == data
assert "fx" not in model.channels
data["model"]["params"]["gains_N_per_raw"][0] = 999
assert model.predict(2, 0.2, features(), "zero-1").values_N["fn"] == 1.75
def test_legacy_scalar_convert_explicitly_rejected():
with pytest.raises(CalibrationError, match="schema 3 requires frame features"):
parse_model(document()).convert(1, 2, 3)
@pytest.mark.parametrize("change,status", [
({"u": 20}, "outside_coverage"), ({"contact_found": False}, "no_contact"),
({"zero_valid": False}, "zero_unavailable"),
({"mapping_valid": False}, "coordinate_unmapped"),
({"delta_raw": float("nan")}, "invalid_input"),
({"delta_raw": "1000"}, "invalid_input"),
({"single_contact": False}, "invalid_input"),
])
def test_frame_invalidity(change, status):
values = features()
values.update(change)
result = parse_model(document()).predict(1, 0.1, values, "zero")
assert result.status == {"fn": status}
assert result.values_N == {"fn": None}
def test_out_of_range_retains_value():
values = features()
values["delta_raw"] = 3000
result = parse_model(document()).predict(1, 0, values, "zero")
assert result.values_N["fn"] == 5.25
assert result.status["fn"] == "out_of_range"
@pytest.mark.parametrize("mutate", [
lambda d: d.update(schema_version=True),
lambda d: d.update(extra="ignored"),
lambda d: d["identity"].update(sn=""),
lambda d: d["model"].update(outputs=["fn", "fx"]),
lambda d: d["model"]["params"].update(gains_N_per_raw=[0.001, 0, 0.003]),
lambda d: d["model"]["params"].update(triangles=[[0, 1, 1]]),
lambda d: d["model"]["params"].update(triangles=[[0, 1, 2], [2, 1, 0]]),
lambda d: d["pipeline"].pop("geometry_sha256"),
lambda d: d["pipeline"]["coordinates"].update(map_sha256="bad"),
])
def test_rejects_bad_documents(mutate):
data = document()
mutate(data)
with pytest.raises(ValueError):
parse_model(data)
def test_rejects_gap_and_overlapping_equal_area_meshes():
data = document()
params = data["model"]["params"]
params.update(points_uv=[[0, 0], [10, 0], [10, 10], [0, 10]],
gains_N_per_raw=[0.001] * 4, triangles=[[0, 1, 2]])
with pytest.raises(ValueError, match="triang|mesh|hull"):
parse_model(data)
params["triangles"] = [[0, 1, 2], [0, 1, 3]]
with pytest.raises(ValueError, match="overlap"):
parse_model(data)
params["triangles"] = [[0, 1, 2], [0, 2, 3]]
assert parse_model(data).predict(1, 0, features(), "zero").status["fn"] == "ok"
def test_global_gain_and_context():
data = document()
data["pipeline"].pop("coordinates")
data["pipeline"].pop("geometry_sha256")
data["model"].update(type="global_gain/1", input_profile="force_delta/1",
outputs=["fn", "fx"], params={
"gains_N_per_raw": {"fn": 0.002, "fx": 0.004},
"valid_force_range_N": [-3, 3]})
model = parse_model(data)
result = model.predict(1, 0, {"delta_raw_by_axis": {"fn": 100, "fx": -100},
"zero_valid": True, "contact_found": True}, "z")
assert result.values_N == {"fn": 0.2, "fx": -0.4}
model.validate_for_context(data["pipeline"])
wrong = deepcopy(data["pipeline"])
wrong["source_scale"] = 0.42
with pytest.raises(ValueError, match="source_scale"):
model.validate_for_context(wrong)
model.reset("new-zero")
data["pipeline"].pop("source_scale")
assert parse_model(data).input_profile == "force_delta/1"
assert model.predict(2, None, {"force_delta_raw": {"fn": 100, "fx": -100},
"zero_valid": True}, "z").values_N == {"fn": 0.2, "fx": -0.4}
assert model.predict(3, None, {"force_delta_raw": {"fn": 100, "fx": -100},
"zero_valid": True, "contact_found": False}, "z").status == {
"fn": "no_contact", "fx": "no_contact"}
def test_unknown_backend_fails_explicitly():
data = document()
data["model"]["type"] = "onnx/1"
with pytest.raises(ValueError, match="unsupported.*onnx/1"):
parse_model(data)
def test_coordinate_artifact_hash_and_paths(tmp_path):
path = tmp_path / "map.npz"
np.savez(path, uv_by_output_px=np.zeros((2, 3, 2)), valid_mask=np.ones((2, 3), dtype=bool),
origin_xy=np.array([511, 1021]))
digest = hashlib.sha256(path.read_bytes()).hexdigest()
data = document()
data["pipeline"]["coordinates"]["map_sha256"] = digest
data["model"]["artifacts"] = [{"role": "coordinate_map", "path": "map.npz", "sha256": digest}]
model = parse_model(data, tmp_path / "calibration.json")
assert model.coordinate_map.shape == (2, 3, 2)
assert len(model.fingerprint) == 64
assert not model.coordinate_map.flags.writeable
for unsafe in ("../map.npz", "https://example.org/x.npz", "C:\\map.npz", "/map.npz"):
data["model"]["artifacts"][0]["path"] = unsafe
with pytest.raises(ValueError, match="relative"):
parse_model(data, tmp_path / "calibration.json")
data["model"]["artifacts"][0]["path"] = "map.npz"
path.write_bytes(b"corrupt")
with pytest.raises(ValueError, match="SHA-256"):
parse_model(data, tmp_path / "calibration.json")
def test_many_points_complete_mesh():
from scipy.spatial import Delaunay
data = document()
points = np.array([[x, y] for x in range(5) for y in range(5)])
data["model"]["params"].update(points_uv=points.tolist(), gains_N_per_raw=[0.001] * 25,
triangles=Delaunay(points).simplices.tolist())
model = parse_model(data)
values = features()
values.update(u=2, v=2)
assert model.predict(1, 0, values, "z").values_N["fn"] == 1.0
+120
View File
@@ -0,0 +1,120 @@
"""File selection, SN isolation and legacy compatibility at the public loader boundary."""
import json
import time
from types import SimpleNamespace
import numpy as np
import pytest
from orisys import calibration
from orisys.calibration_context import build_context
from orisys.sensors.calibration import resolve_calibration
def model_doc():
config = {"type": "single_camera", "camera": {"resolution_stitch": [64, 64]},
"tracker": {"dis_mode": 1, "hhd_downsample": 3}}
return {"schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "A1"},
"pipeline": build_context(config), "model": {
"type": "global_gain/1", "input_profile": "force_delta/1", "output_frame": "sdk_force/1",
"outputs": ["fn"], "params": {"gains_N_per_raw": {"fn": .001}, "valid_force_range_N": [0, 3]}}}
def test_schema3_public_load_write_and_fn_only_sn(tmp_path):
path = tmp_path / "finger" / "A1.json"
path.parent.mkdir()
path.write_text(json.dumps(model_doc()))
model = calibration.load_for_device(tmp_path, "finger", "A1")
assert tuple(model.channels) == ("fn",)
assert calibration.check(model).ok
target = tmp_path / "copy.json"
calibration.write(model, target)
assert json.loads(target.read_text())["schema_version"] == 3
assert calibration.load(target).fingerprint == model.fingerprint
with pytest.raises(calibration.CalibrationError, match="identity mismatch"):
calibration.validate_for_device(model, "finger", "another-SN")
def test_missing_sn_does_not_pick_any_neighbor_or_default(tmp_path):
(tmp_path / "finger").mkdir()
(tmp_path / "finger" / "A1.json").write_text(json.dumps(model_doc()))
(tmp_path / "force_calibration.json").write_text(json.dumps(model_doc()))
assert calibration.load_for_device(tmp_path, "finger", "B2") is None
with pytest.raises(calibration.CalibrationError, match="directory"):
calibration.load_for_device(tmp_path / "missing", "finger", "B2")
@pytest.mark.parametrize("change", ["unknown_type", "unknown_schema", "malformed"])
def test_selected_bad_model_never_falls_back(tmp_path, change):
data = model_doc()
if change == "unknown_type":
data["model"]["type"] = "onnx/1"
elif change == "unknown_schema":
data["schema_version"] = 99
else:
data["model"]["params"]["gains_N_per_raw"] = {}
path = tmp_path / "selected.json"
path.write_text(json.dumps(data))
with pytest.raises(calibration.CalibrationError):
calibration.load(path, sensor_type="box")
def test_invalid_default_schema3_does_not_silently_become_raw(monkeypatch, tmp_path):
from orisys import configs
data = model_doc()
data["model"]["type"] = "onnx/1"
path = tmp_path / "force_calibration.json"
path.write_text(json.dumps(data))
monkeypatch.setattr(configs, "resolve_sensor_bundle", lambda name: SimpleNamespace(force_calibration_json=path))
with pytest.raises(calibration.CalibrationError, match="unsupported calibration model"):
calibration.load(sensor_type="finger")
def test_path_selection_keeps_explicit_and_sn_modes_separate(tmp_path):
with pytest.raises(calibration.CalibrationError, match="mutually exclusive"):
calibration.select_calibration_path(cal_path="file.json", calibration_dir=tmp_path)
with pytest.raises(calibration.CalibrationError, match="requires"):
calibration.select_calibration_path(device_id="A1")
assert calibration.select_calibration_path(cal_path=tmp_path / "A1.json") == tmp_path / "A1.json"
def test_profile_declares_only_available_axes_and_checks_family(tmp_path):
path = tmp_path / "selected.json"
path.write_text(json.dumps(model_doc()))
options = dict(bundle_root=tmp_path, declared_default=None, profile_id="test", selection=path)
resolved = resolve_calibration(product_family="finger", **options)
assert resolved.axes == ("fn",) and resolved.document is not None
assert resolved.fingerprint == resolved.document.fingerprint
wrong = resolve_calibration(product_family="box", **options)
assert wrong.document is None and wrong.issues[0].code == "calibration.model-family-mismatch"
def test_processor_applies_resolved_selection_including_none(monkeypatch):
from orisys.sensors import backends
monkeypatch.setattr(backends, "validate_profile", lambda profile: SimpleNamespace(usable=True))
selected = calibration.parse(model_doc())
calls = []
sensor = SimpleNamespace(set_calibration=calls.append)
for model in (selected, None):
profile = SimpleNamespace(processing_backend="single_camera", config={"camera": {}},
calibration=SimpleNamespace(document=model), profile_id="test")
session = backends.create_processor(profile, sensor=sensor)
assert session.sensor is sensor
assert calls == [selected, None]
def test_snapshot_keeps_per_frame_units_and_validity_copy():
from orisys.pipeline import StreamPipeline
sensor = SimpleNamespace(info=object(), force_unit="N", force_units=("N", "raw", "raw"),
calibration_status={"fn": "outside_coverage"}, fnormal=float("nan"),
fshearx=2., fsheary=3., _force_raw=(1., 2., 3.))
pipeline = StreamPipeline(sensor, lambda: None, include=())
snapshot = pipeline._build(np.zeros((2, 2)), None, 0., 0., 0., time.perf_counter())
assert snapshot.force_units == ("N", "raw", "raw")
assert snapshot.calibration_status == {"fn": "outside_coverage"}
sensor.calibration_status["fn"] = "ok"
assert snapshot.calibration_status["fn"] == "outside_coverage"
+308
View File
@@ -0,0 +1,308 @@
"""Schema 3 lifecycle and readout tests without camera or hardware."""
from types import SimpleNamespace
import numpy as np
import pytest
from orisys.calibration import CalibrationError
from orisys.force_readout import ForceReadout
from orisys.sensor import Sensor
class RecordingModel:
schema_version = 3
channels = {"fn": None}
input_profile = "fn_delta_autocal_uv/1"
def __init__(self):
self.calls = []
self.resets = []
self.coordinate_map = np.array([[[0., 0.], [1., 0.]], [[0., 1.], [1., 1.]]])
self.coordinate_valid_mask = np.array([[True, False], [True, True]])
def reset(self, reference_id):
self.resets.append(reference_id)
def validate_for_context(self, context):
if context != {"signature": "actual"}:
raise CalibrationError("pipeline mismatch")
def predict(self, **kwargs):
self.calls.append(kwargs)
features = kwargs["features"]
status = "ok"
if not features["zero_valid"]:
status = "zero_unavailable"
elif not features["contact_found"]:
status = "no_contact"
elif not features["mapping_valid"]:
status = "coordinate_unmapped"
value = features["delta_raw"] * .1 if status == "ok" else None
return SimpleNamespace(values_N={"fn": value}, status={"fn": status})
def make_sensor(*, median=1):
sensor = Sensor.__new__(Sensor)
sensor.calibration = RecordingModel()
sensor._config = {"filters": {"force_median_window": median}}
sensor._force_readout = ForceReadout(sensor._config["filters"])
sensor._force_raw = (10., 20., 30.)
sensor._model_readouts = {}
sensor._calibration_context = None
sensor._reset_calibration_session()
sensor.bind_calibration_context({"signature": "actual"})
return sensor
def predict(sensor, frame, fn, *, contact=True, mapped=True):
return sensor.apply_calibration_frame(frame_id=frame, timestamp_s=frame / 30.,
raw=(fn, 20., 30.), uv=(0., 0.),
contact_found=contact, mapping_valid=mapped)
def test_once_per_frame_and_explicit_zero_subtracted_once():
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="zero-a")
first = predict(sensor, 1, 25.)
assert first.values_N["fn"] == pytest.approx(1.5)
assert predict(sensor, 1, 99.) is first
assert len(sensor.calibration.calls) == 1
assert sensor.force_units == ("N", "raw", "raw")
assert (sensor.fnormal, sensor.fshearx, sensor.fsheary) == (1.5, 20., 30.)
assert sensor._force_raw_unfiltered == (25., 20., 30.)
def test_unknown_zero_and_invalid_contact_never_become_zero_newtons():
sensor = make_sensor()
assert predict(sensor, 1, 20.).status["fn"] == "zero_unavailable"
assert np.isnan(sensor.fnormal)
sensor.set_calibration_reference({"fn": 10.}, reference_id="zero-b")
assert predict(sensor, 2, 20., contact=False).status["fn"] == "no_contact"
assert np.isnan(sensor.fnormal)
assert predict(sensor, 3, 20., mapped=False).status["fn"] == "coordinate_unmapped"
assert np.isnan(sensor.fnormal)
def test_failed_frame_does_not_enter_newton_median_history():
sensor = make_sensor(median=3)
sensor.set_calibration_reference({"fn": 0.}, reference_id="zero")
predict(sensor, 1, 10.)
predict(sensor, 2, 1000., contact=False)
predict(sensor, 3, 20.)
predict(sensor, 4, 30.)
assert sensor.fnormal == pytest.approx(2.)
assert sensor._force_unfiltered[0] == 3.
def test_new_reference_clears_cached_prediction_and_filter():
sensor = make_sensor(median=3)
sensor.set_calibration_reference({"fn": 0.}, reference_id="first")
predict(sensor, 1, 10.)
sensor.set_calibration_reference({"fn": 20.}, reference_id="second")
assert sensor.calibration_result is None
assert np.isnan(sensor.fnormal)
predict(sensor, 1, 30.)
assert sensor.fnormal == pytest.approx(1.)
assert sensor.calibration.resets[-1] == "second"
def test_requires_actual_pipeline_context():
sensor = make_sensor()
with pytest.raises(CalibrationError, match="pipeline mismatch"):
sensor.bind_calibration_context({"signature": "different"})
assert sensor._calibration_context == {"signature": "actual"}
sensor._calibration_context = None
with pytest.raises(CalibrationError, match="actual calibration context"):
predict(sensor, 1, 10.)
def test_contact_map_never_reuses_previous_coordinate():
sensor = make_sensor()
sensor.centroid_found = True
sensor.centroid = (0, 1)
assert sensor._calibration_contact_uv() == (0., 1.)
sensor.centroid = (1, 0)
assert sensor._calibration_contact_uv() is None
sensor.centroid = (10, 10)
assert sensor._calibration_contact_uv() is None
sensor.centroid_found = False
sensor.centroid = (0, 1)
assert sensor._calibration_contact_uv() is None
def test_hhd_infers_after_current_contact_only_once_and_clears_old_contact():
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="zero")
field = np.zeros((2, 2, 2), dtype=np.float32)
sensor._Sensor__hhd = SimpleNamespace(
get_force=lambda *args, **kwargs: (None, (20., 0., 0., 0.)),
get_normal=lambda: field, get_shear=lambda: field,
)
contact = {"centroid": (0, 1), "centroid_found": True,
"divergence_map": np.zeros((2, 2))}
sensor._Sensor__contact3d = SimpleNamespace(compute_foe=lambda *args, **kwargs: contact)
sensor.cuda = sensor.verbose = False
sensor._hhd_downsample = 1
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
sensor.valid_mask = sensor.valid_mask_hhd = None
sensor._model_frame_id = 0
sensor.compute_hhd(field)
assert sensor.calibration.calls[-1]["features"]["v"] == 1.
assert sensor.fnormal == pytest.approx(1.)
sensor.compute_contact()
assert len(sensor.calibration.calls) == 1
contact["centroid_found"] = False
sensor.compute_hhd(field)
assert len(sensor.calibration.calls) == 2
assert sensor.calibration_status == {"fn": "no_contact"}
assert sensor.calibration.calls[-1]["features"]["u"] is None
assert np.isnan(sensor.fnormal)
def test_real_model_adapter_uses_same_lifecycle_as_stateful_model():
from orisys.calibration import parse
data = {
"schema_version": 3, "unit": "N",
"identity": {"sensor_type": "finger", "sn": "test"},
"pipeline": {"stitch_resolution": [2, 2], "dis_mode": 4, "hhd_downsample": 1,
"filters": {"measurement": {"flow_ema_alpha": 1.}},
"feature_signature_sha256": "a" * 64},
"model": {"type": "global_gain/1", "input_profile": "force_delta/1",
"output_frame": "sdk_force/1", "outputs": ["fn"],
"params": {"gains_N_per_raw": {"fn": .1}, "valid_force_range_N": [0., 3.]}},
}
sensor = make_sensor()
sensor.calibration = parse(data)
sensor.bind_calibration_context(data["pipeline"])
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
result = sensor.apply_calibration_frame(frame_id=1, raw=(20., 30., 40.))
assert result.values_N == {"fn": 1.}
assert sensor.force_units == ("N", "raw", "raw")
assert sensor.calibration_status == {"fn": "ok"}
result = sensor.apply_calibration_frame(frame_id=2, raw=(20., 30., 40.), contact_found=False)
assert result.status == {"fn": "no_contact"}
def test_reversed_frame_requires_a_new_zero_reference():
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
predict(sensor, 10, 20.)
assert predict(sensor, 2, 20.).status == {"fn": "zero_unavailable"}
assert sensor.calibration.resets[-1] is None
def test_replacement_is_atomic_when_pipeline_cannot_be_validated():
sensor = make_sensor()
sensor.device_id = None
original = sensor.calibration
replacement = RecordingModel()
with pytest.raises(CalibrationError, match="pipeline mismatch"):
sensor.set_calibration(replacement, context={"signature": "different"})
assert sensor.calibration is original
sensor._calibration_context = None
with pytest.raises(CalibrationError, match="actual pipeline context"):
sensor.set_calibration(replacement)
assert sensor.calibration is original
sensor.set_calibration(replacement, context={"signature": "actual"})
assert sensor.calibration is replacement
assert sensor._calibration_reference == {}
@pytest.mark.parametrize("use_legacy", [False, True])
def test_schema3_swap_cannot_relabel_cached_newtons_as_raw_or_legacy(use_legacy):
from orisys.calibration import parse
sensor = make_sensor()
sensor.device_id = None
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
predict(sensor, 1, 25.)
assert sensor.fnormal == 1.5
legacy = parse({"schema_version": 2, "unit": "N", "channels": {
"normal": {"method": "piecewise_linear", "points": [[0., 0.], [100., 2.]],
"extrapolate": True},
}}) if use_legacy else None
sensor.set_calibration(legacy)
expected = .5 if use_legacy else 25.
assert sensor.fnormal == pytest.approx(expected)
assert sensor.force_triplets()["unfiltered"]["newton"][0] == pytest.approx(expected)
assert sensor.force_triplets()["filtered"]["raw"][0] == 25.
assert sensor.calibration_status == {}
def test_reset_invalidates_contact_cache_and_coordinate():
sensor = make_sensor()
sensor._Sensor__tracker = SimpleNamespace(reset=lambda: None)
sensor.img = None
sensor.compute_contact_valid = sensor.centroid_found = True
sensor.centroid = (1, 1)
sensor._model_contact_result = {"centroid": (1, 1)}
sensor.compute_deformation_valid = True
sensor.reset()
assert sensor.compute_contact_valid is False
assert sensor.compute_deformation_valid is False
assert sensor.centroid_found is False
assert sensor._model_contact_result is None
assert sensor._calibration_contact_uv() is None
with pytest.raises(CalibrationError, match="current deformation"):
sensor.compute_contact()
def test_schema3_rejects_measurement_filter_change_before_mutation():
sensor = make_sensor()
sensor.compute_deformation_valid = False
with pytest.raises(CalibrationError, match="measurement identity"):
sensor.configure(flow_ema_alpha=.5)
assert sensor._force_readout.flow_ema_alpha == 1.
assert sensor._calibration_context == {"signature": "actual"}
def test_readout_configuration_applies_before_and_after_first_model_frame():
sensor = make_sensor()
sensor.compute_deformation_valid = False
sensor.configure(force_median_window=3, lowpass_sample_rate_hz=20.)
sensor.set_calibration_reference({"fn": 0.}, reference_id="reference")
predict(sensor, 1, 10.)
readout = sensor._model_readouts["fn"]
assert readout.force_median_window == 3
assert readout.lowpass_sample_rate_hz == 20.
predict(sensor, 2, 20.)
predict(sensor, 3, 30.)
assert sensor.fnormal == 2.
sensor.configure(force_median_window=1)
assert readout.force_median_window == 1
predict(sensor, 4, 40.)
assert sensor.fnormal == 4.
def test_generic_global_fallback_binds_actual_mirror_identity():
from orisys.calibration import parse
from orisys.calibration_context import build_context
sensor = make_sensor()
sensor.device_id = None
sensor.mirror = True
sensor._config.update(camera={"resolution_stitch": [2, 2]}, tracker={"dis_mode": 1})
context = build_context(sensor._config, sensor.filters_state(), preprocessing={"mirror": True})
data = {"schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "test"},
"pipeline": context,
"model": {"type": "global_gain/1", "input_profile": "force_delta/1",
"output_frame": "sdk_force/1", "outputs": ["fn"],
"params": {"gains_N_per_raw": {"fn": .1}, "valid_force_range_N": [0., 3.]}}}
sensor._calibration_context = None
sensor.set_calibration(parse(data))
assert sensor._calibration_context == context
sensor.mirror = False
sensor._calibration_context = None
with pytest.raises(CalibrationError, match="feature_signature"):
sensor.set_calibration(parse(data))
@pytest.mark.parametrize("reference", [{"fn": float("nan")}, {"other": 0.}])
def test_bad_zero_reference_is_rejected_without_reset(reference):
sensor = make_sensor()
sensor.set_calibration_reference({"fn": 10.}, reference_id="valid")
with pytest.raises(ValueError):
sensor.set_calibration_reference(reference, reference_id="bad")
assert sensor._calibration_reference == {"fn": 10.}