"""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()