Files
orisys-sdk/src/orisys/sensors/calibration.py
T
ykcheungab ff7614a6a0 接入力标定 schema 3 与多点空间增益模型
保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
2026-10-18 12:56:40 +08:00

290 lines
10 KiB
Python

"""Calibration selection and compatibility for a resolved profile.
Three outcomes are distinct and must not be collapsed:
* an explicitly selected calibration that loads and applies - the caller asked for it;
* an explicit "no calibration" - the caller asked for raw output and gets it, reported as such;
* a selected calibration that does not load or does not fit - an error, never a quiet
substitution with some other file.
A calibration file merely existing proves nothing, so presence is never the check: the document
is parsed through :mod:`orisys.calibration`, its units and channels are read, and its declared
applicability is compared with the profile it was selected for. Legacy documents that carry no
identifying metadata are reported as *unknown applicability* rather than as compatible.
"""
from __future__ import annotations
import enum
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from .. import calibration as cal_mod
from .capabilities import Availability, Issue, ok as _ok, unavailable
#: Axis availability when no calibration curve is present for that axis.
_NO_CURVE = "no calibration curve for this axis"
#: Sentinel digest for "explicitly no calibration". A distinct constant rather than an empty
#: string, so a stored fingerprint always says which state it recorded.
_NONE_FINGERPRINT = "none"
class CalibrationSelection(enum.Enum):
"""How the caller wants the calibration chosen."""
#: Use the bundle's declared default, else ``force_calibration.json`` beside ``sensor.json``.
BUNDLE_DEFAULT = "bundle_default"
#: Explicitly run uncalibrated. Raw force is still available; it is a choice, not a failure.
NONE = "none"
#: Use this file - see :meth:`from_value`.
EXPLICIT = "explicit"
@classmethod
def from_value(cls, value: "CalibrationSelection | str | Path | None") -> tuple["CalibrationSelection", Path | None]:
"""Normalise a caller-supplied selection into ``(mode, path)``."""
if value is None:
return cls.BUNDLE_DEFAULT, None
if isinstance(value, cls):
return value, None
text = str(value).strip()
if not text or text.lower() in ("none", "raw", "off"):
return cls.NONE, None
if text.lower() in ("default", "bundle", "bundle_default"):
return cls.BUNDLE_DEFAULT, None
return cls.EXPLICIT, Path(text).expanduser()
@dataclass(frozen=True)
class ResolvedCalibration:
"""The calibration side of a resolved profile."""
selection: CalibrationSelection
path: Path | None
document: "cal_mod.Calibration | None" = None
#: ``"declared"`` when the document carries identity metadata, ``"unknown"`` when it does
#: not (legacy), ``"none"`` for an explicit uncalibrated run.
applicability: str = "none"
#: Axes this calibration provides a curve for.
axes: tuple[str, ...] = ()
unit: str = ""
fingerprint: str = ""
issues: tuple[Issue, ...] = ()
@property
def applied(self) -> bool:
return self.document is not None
def axis_detail(self, axis: str) -> str:
if self.document is None:
return _NO_CURVE if self.selection is CalibrationSelection.NONE else "no calibration"
return "" if axis in self.axes else _NO_CURVE
def as_dict(self) -> dict[str, object]:
return {
"selection": self.selection.value,
"path": None if self.path is None else str(self.path),
"applicability": self.applicability,
"axes": list(self.axes),
"unit": self.unit,
"fingerprint": self.fingerprint,
}
def _identity_of(document: "cal_mod.Calibration") -> Mapping[str, str]:
identity = getattr(document, "identity", None)
return identity if isinstance(identity, Mapping) else {}
def resolve_calibration(
*,
bundle_root: Path,
declared_default: Path | None,
profile_id: str,
product_family: str,
selection: CalibrationSelection | str | Path | None = None,
) -> ResolvedCalibration:
"""Resolve and validate the calibration for one profile.
Returns a :class:`ResolvedCalibration` whose ``issues`` carry every reason a selected
calibration could not be used. It never falls back to a different file.
"""
mode, explicit = CalibrationSelection.from_value(selection)
if mode is CalibrationSelection.NONE:
return ResolvedCalibration(
selection=mode, path=None, applicability="none", fingerprint=_NONE_FINGERPRINT
)
if mode is CalibrationSelection.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
# simply unavailable, per axis, and the caller can still select a file explicitly.
issue = Issue(
code="calibration.absent",
message="no calibration for this profile; raw force is available",
path=bundle_root,
)
return ResolvedCalibration(
selection=mode,
path=None,
applicability="none",
fingerprint=_NONE_FINGERPRINT,
issues=(issue,),
)
path = Path(path)
if not path.is_file():
return ResolvedCalibration(
selection=mode,
path=path,
issues=(
Issue(
code="calibration.missing",
message="the selected calibration file does not exist",
path=path,
),
),
)
try:
document = cal_mod.load(str(path), strict=True)
except Exception as exc: # noqa: BLE001 - reported as an actionable issue
return ResolvedCalibration(
selection=mode,
path=path,
issues=(
Issue(
code="calibration.unreadable",
message=f"selected calibration could not be loaded: {exc}",
path=path,
),
),
)
if document is None: # defensive: strict load raises rather than returning None
return ResolvedCalibration(
selection=mode,
path=path,
issues=(
Issue(
code="calibration.unreadable",
message="selected calibration produced no document",
path=path,
),
),
)
axes = tuple(sorted(document.channels))
unit = str(getattr(document, "unit", "") or "")
issues: list[Issue] = []
if unit and unit.upper() not in ("N", "NEWTON", "NEWTONS"):
issues.append(
Issue(
code="calibration.unit",
message=f"calibration unit {unit!r} is not a force unit in newtons",
path=path,
)
)
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:
issues.append(
Issue(
code="calibration.family-mismatch",
message=(
f"calibration declares sensor_type {declared_type!r} but the profile is "
f"{product_family!r}; it was selected explicitly, so it applies as given"
),
path=path,
)
)
else:
# No metadata to compare against: honest answer is that applicability is unknown.
applicability = "unknown"
issues.append(
Issue(
code="calibration.applicability-unknown",
message=(
"calibration carries no identifying metadata (no meta.sensor_type); "
f"its applicability to {profile_id} cannot be verified"
),
path=path,
)
)
if not axes:
issues.append(
Issue(
code="calibration.no-channels",
message="calibration defines no usable channel curve",
path=path,
)
)
return ResolvedCalibration(
selection=mode,
path=path,
document=document,
applicability=applicability,
axes=axes,
unit=unit,
fingerprint=calibration_fingerprint(document, path),
issues=tuple(issues),
)
def axis_availability(resolved: ResolvedCalibration) -> tuple[object, object, object]:
"""``(fn, fx, fy)`` availability, each with its own reason when unavailable."""
if resolved.document is None:
reason = (
"explicitly uncalibrated"
if resolved.selection is CalibrationSelection.NONE
else (resolved.issues[0].message if resolved.issues else "no calibration")
)
blank = unavailable(reason)
return blank, blank, blank
out: list[Availability] = []
for axis in ("fn", "fx", "fy"):
if axis in resolved.axes:
out.append(_ok(f"applicability {resolved.applicability}"))
else:
out.append(
unavailable(_NO_CURVE, detail=f"{resolved.path.name} has no {axis} curve")
)
return out[0], out[1], out[2]
def calibration_fingerprint(document: "cal_mod.Calibration", path: Path) -> str:
"""Content hash of the calibration document.
Hashes what the document *says*, not where it lives: selecting an identical calibration from
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")
text = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(text.encode("utf-8")).hexdigest()