Add a sensor bundle contract separating family, backend and profile
Product name decided the pipeline: Mini worked live but replay refused it. A bundle descriptor now declares profile_id, product_family and processing_backend, with computational and visualization assets kept apart, and one resolver serves live and recorded input. Legacy bundles load through a documented adapter that never guesses a backend from a folder name.
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""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 = explicit
|
||||
else:
|
||||
path = declared_default
|
||||
if path is None:
|
||||
fallback = bundle_root / "force_calibration.json"
|
||||
path = fallback if fallback.is_file() else None
|
||||
|
||||
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 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:
|
||||
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()
|
||||
Reference in New Issue
Block a user