ff7614a6a0
保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
290 lines
10 KiB
Python
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()
|