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

1130 lines
43 KiB
Python

"""Force calibration load, parse, check, build, and write.
Owns ``force_calibration.json`` schema v1/v2. ``Sensor`` loads once and applies
curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
"""
from __future__ import annotations
import json
import re
import warnings
from collections.abc import Iterable, Mapping
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
CURRENT_SCHEMA = 2
SUPPORTED_SCHEMAS = (1, 2, 3)
CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy")
CHANNEL_ALIASES: dict[str, str] = {
"fn": "fn",
"FN": "fn",
"normal": "fn",
"fnormal": "fn",
"fx": "fx",
"FX": "fx",
"shear_x": "fx",
"fshearx": "fx",
"fy": "fy",
"FY": "fy",
"shear_y": "fy",
"fsheary": "fy",
}
_CANONICAL_TO_JSON = {
"fn": "normal",
"fx": "shear_x",
"fy": "shear_y",
}
_JSON_RAW_KEY = {
"normal": "fnormal",
"shear_x": "fshearx",
"shear_y": "fsheary",
}
_KNOWN_TOP_KEYS = frozenset(
{"schema_version", "unit", "channels", "meta", "identity", "pipeline", "provenance", "measurements", "quality"}
)
#: Filter stages that change **what is measured**: they sit upstream of the
#: force computation, so two recordings with different values are not comparable.
#: They belong to the measurement-pipeline identity, for the same reason
#: ``dis_mode`` and ``hhd_downsample`` do.
FILTER_MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
#: Filter stages that only smooth the force triple on its way out. They are
#: recorded so a file explains its own numbers, but a difference between two
#: files does not invalidate a comparison.
FILTER_READOUT_KEYS: tuple[str, ...] = (
"force_median_window",
"force_lowpass_cutoff_hz",
"force_lowpass_order",
"force_lowpass_sample_rate_hz",
"force_lowpass_rate_auto",
)
#: Every recorded filter value, measurement group first.
FILTER_KEYS: tuple[str, ...] = FILTER_MEASUREMENT_KEYS + FILTER_READOUT_KEYS
#: What "off" looks like, per stage. ``enabled: false`` means exactly this.
_OFF_FILTER_VALUES: dict[str, Any] = {
"flow_ema_alpha": 1.0,
"force_median_window": 1,
"force_lowpass_cutoff_hz": 0.0,
}
_INT_FILTER_KEYS = frozenset({"force_median_window", "force_lowpass_order"})
_FLOAT_FILTER_KEYS = frozenset(
{"flow_ema_alpha", "force_lowpass_cutoff_hz", "force_lowpass_sample_rate_hz"}
)
class CalibrationError(ValueError):
"""Invalid or unusable force calibration document / path."""
def _coerce_filter_value(key: str, value: Any) -> Any:
"""A filter value in its recorded type, or ``None`` when unusable."""
if value is None:
return None
if key == "force_lowpass_rate_auto":
return bool(value)
try:
if key in _INT_FILTER_KEYS:
return int(value)
if key in _FLOAT_FILTER_KEYS:
return float(value)
except (TypeError, ValueError):
return None
return value
def _filters_from_config(block: Mapping[str, Any] | Any) -> dict[str, dict[str, Any]]:
"""Best-effort filter groups from a flat ``filters`` block.
Only what the mapping states — directly or through a named preset, with an
explicit key winning over the preset — is reported. Nothing is filled in from
a default: a default is how a document ends up claiming a filter the device
is not running. ``enabled: false`` keeps its deprecated meaning of "every
stage off".
"""
flat = dict(block) if isinstance(block, Mapping) else {}
preset_name = flat.get("preset") if isinstance(flat.get("preset"), str) else None
merged: dict[str, Any] = {}
if preset_name:
try:
from .sensor import FILTER_PRESETS # deferred: sensor imports this module
except Exception: # pragma: no cover - defensive
FILTER_PRESETS = {}
merged.update(FILTER_PRESETS.get(preset_name, {}) or {})
for key in FILTER_KEYS:
if flat.get(key) is not None:
merged[key] = flat[key]
if isinstance(flat.get("force_lowpass_sample_rate_hz"), str):
# "auto" / "measured" is a rate *source*, not a number; keep it as one.
merged["force_lowpass_rate_auto"] = True
merged.pop("force_lowpass_sample_rate_hz", None)
if flat.get("enabled", None) is False:
order = merged.get("force_lowpass_order")
rate_auto = merged.get("force_lowpass_rate_auto")
merged = dict(_OFF_FILTER_VALUES)
# Order and rate tracking stay meaningful even with the stage off.
if order is not None:
merged["force_lowpass_order"] = order
if rate_auto is not None:
merged["force_lowpass_rate_auto"] = rate_auto
return {
group: {key: _coerce_filter_value(key, merged.get(key)) for key in keys}
for group, keys in (
("measurement", FILTER_MEASUREMENT_KEYS),
("readout", FILTER_READOUT_KEYS),
)
}
def _flat_filters_from(*sources: Mapping[str, Any] | Any) -> dict[str, Any]:
"""The flat form older documents used: filter keys at the top of meta."""
out: dict[str, Any] = {}
for source in sources:
if not isinstance(source, Mapping):
continue
for key in FILTER_KEYS:
if key not in out and source.get(key) is not None:
out[key] = source[key]
return out
def filters_fingerprint(state: Mapping[str, Any] | Any) -> dict[str, Any]:
"""Split a filter state into its ``measurement`` and ``readout`` groups.
The honest input is ``Sensor.filters_state()`` — the values that actually
took effect, which is not necessarily what the config file says. A flat
mapping is accepted as a fallback (a sensor.json ``filters`` block, or the
flat form older documents wrote) and read as the intent it is.
Values a source does not state are absent from the result rather than
guessed, so ``{}`` means "nothing recorded" and never "probably off".
"""
if not isinstance(state, Mapping) or not state:
return {}
measurement = state.get("measurement")
readout = state.get("readout")
if isinstance(measurement, Mapping) or isinstance(readout, Mapping):
groups = {
"measurement": {
key: _coerce_filter_value(key, measurement.get(key))
for key in FILTER_MEASUREMENT_KEYS
}
if isinstance(measurement, Mapping)
else {},
"readout": {
key: _coerce_filter_value(key, readout.get(key))
for key in FILTER_READOUT_KEYS
}
if isinstance(readout, Mapping)
else {},
}
source = str(state.get("source") or "sensor.filters_state")
else:
groups = _filters_from_config(state)
source = "sensor.json.filters"
out: dict[str, Any] = {}
for group in ("measurement", "readout"):
values = {
key: value for key, value in (groups.get(group) or {}).items() if value is not None
}
if values:
out[group] = values
if not out:
return {}
if state.get("preset"):
out["preset"] = str(state["preset"])
out["source"] = source
return out
def filter_mismatches(
recorded: Mapping[str, Any] | Any, expected: Mapping[str, Any] | Any
) -> tuple[list[str], list[str]]:
"""Compare two filter fingerprints → ``(measurement, readout)`` differences.
A measurement difference means the two records are not comparable, so it is
reported as a warning. A readout difference only changes how the same
measurement was smoothed, so it is a note: recorded, never an obstacle.
"""
left = filters_fingerprint(recorded)
right = filters_fingerprint(expected)
warns: list[str] = []
notes: list[str] = []
for group, keys, bucket, label in (
("measurement", FILTER_MEASUREMENT_KEYS, warns, "measurement (not comparable)"),
("readout", FILTER_READOUT_KEYS, notes, "readout"),
):
for key in keys:
if key in (left.get(group) or {}) and key in (right.get(group) or {}):
if left[group][key] != right[group][key]:
bucket.append(
f"filter {label} mismatch: {key} "
f"{left[group][key]!r} != {right[group][key]!r}"
)
return warns, notes
def canonicalize_channel(name: str) -> str:
"""Map FN / normal / shear_x / … to canonical ``fn`` / ``fx`` / ``fy``."""
key = str(name or "").strip()
if key in CHANNEL_ALIASES:
return CHANNEL_ALIASES[key]
lower = key.lower()
if lower in CHANNEL_ALIASES:
return CHANNEL_ALIASES[lower]
raise CalibrationError(f"unknown calibration channel: {name!r}")
def _parse_point(point: Any) -> tuple[float, float]:
if isinstance(point, dict):
raw = point.get("fnormal", point.get("raw", point.get("x")))
force_n = point.get("force_n", point.get("n", point.get("y")))
if raw is None or force_n is None:
raise CalibrationError(f"invalid calibration point: {point!r}")
return float(raw), float(force_n)
if isinstance(point, (list, tuple)) and len(point) >= 2:
return float(point[0]), float(point[1])
raise CalibrationError(f"invalid calibration point: {point!r}")
@dataclass(frozen=True)
class ChannelCurve:
"""One force channel: linear coeffs and/or piecewise-linear breakpoints."""
id: str
method: str = "piecewise_linear"
points: tuple[tuple[float, float], ...] = ()
slope: float | None = None
offset: float | None = None
signed: bool = False
extrapolate: bool = True
clamp_output_min: float | None = 0.0
raw_key: str = ""
input_mode: str = "magnitude"
unit: str = "N"
def convert(self, raw: float) -> float:
raw = float(raw)
if self.method in ("linear", "affine"):
if self.slope is None or self.offset is None:
return raw
return float(self.slope) * raw + float(self.offset)
points = self.points
if len(points) < 2:
return raw
if raw <= points[0][0]:
segment = (points[0], points[1])
if not self.extrapolate:
return self._clamp(points[0][1])
elif raw >= points[-1][0]:
segment = (points[-2], points[-1])
if not self.extrapolate:
return self._clamp(points[-1][1])
else:
segment = None
for left, right in zip(points, points[1:]):
if left[0] <= raw <= right[0]:
segment = (left, right)
break
if segment is None:
return self._clamp(points[-1][1])
(raw0, n0), (raw1, n1) = segment
if raw1 == raw0:
value = n0
else:
ratio = (raw - raw0) / (raw1 - raw0)
value = n0 + ratio * (n1 - n0)
return self._clamp(value)
def convert_component(self, raw: float) -> float:
if not self.signed:
return self.convert(raw)
raw = float(raw)
sign = -1.0 if raw < 0.0 else 1.0
return sign * self.convert(abs(raw))
def _clamp(self, value: float) -> float:
if self.clamp_output_min is not None:
return max(float(self.clamp_output_min), float(value))
return float(value)
def to_dict(self) -> dict[str, Any]:
json_name = _CANONICAL_TO_JSON.get(self.id, self.id)
if self.method in ("linear", "affine"):
block: dict[str, Any] = {
"method": "linear",
"slope": self.slope,
"offset": self.offset,
}
else:
block = {
"method": "piecewise_linear",
"raw_key": self.raw_key or _JSON_RAW_KEY.get(json_name, ""),
"extrapolate": self.extrapolate,
"clamp_output_min": self.clamp_output_min,
"points": [list(p) for p in self.points],
}
if self.signed:
block["signed"] = True
if self.input_mode and self.input_mode != "magnitude":
block["input"] = self.input_mode
return block
@dataclass(frozen=True)
class CalSample:
weight_n: float
raw_mean: float
n: int
std: float = 0.0
@dataclass
class CheckResult:
ok: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
#: Differences worth recording that are not a reason to refuse anything,
#: such as a readout-only filter change between two documents.
notes: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Calibration:
"""Parsed force calibration document."""
schema_version: int
unit: str
origin: str
identity: dict[str, Any]
pipeline: dict[str, Any]
provenance: dict[str, Any]
channels: dict[str, ChannelCurve]
measurements: dict[str, Any]
extras: dict[str, Any] = field(default_factory=dict)
source_path: Path | None = None
# Legacy shared shear (v1) for magnitude-mode fallback in helpers.
_legacy_shear: ChannelCurve | None = None
def get(self, channel: str) -> ChannelCurve | None:
try:
cid = canonicalize_channel(channel)
except CalibrationError:
return self.channels.get(str(channel))
return self.channels.get(cid)
def has(self, channel: str) -> bool:
return self.get(channel) is not None
def convert(self, fn: float, fx: float, fy: float) -> tuple[float, float, float]:
"""Apply present curves; missing channel stays raw."""
out_fn = float(fn)
out_fx = float(fx)
out_fy = float(fy)
cal_fn = self.channels.get("fn")
cal_fx = self.channels.get("fx")
cal_fy = self.channels.get("fy")
if cal_fn is not None:
out_fn = cal_fn.convert(out_fn)
if cal_fx is not None:
out_fx = (
cal_fx.convert_component(out_fx)
if cal_fx.signed
else cal_fx.convert(out_fx)
)
if cal_fy is not None:
out_fy = (
cal_fy.convert_component(out_fy)
if cal_fy.signed
else cal_fy.convert(out_fy)
)
return out_fn, out_fx, out_fy
def to_dict(self) -> dict[str, Any]:
channels: dict[str, Any] = {}
for cid, curve in self.channels.items():
json_name = _CANONICAL_TO_JSON.get(cid, cid)
channels[json_name] = curve.to_dict()
# Prefer emitting v2 axis names even when parsed from v1 shared shear.
doc: dict[str, Any] = {
"schema_version": max(int(self.schema_version), 2) if self.channels else int(self.schema_version),
"unit": self.unit,
"channels": channels,
}
meta: dict[str, Any] = {}
meta.update(self.provenance)
meta.update({k: v for k, v in self.identity.items() if k not in meta})
for key in ("stitch_resolution", "dis_mode", "hhd_downsample", "config_ref"):
if key in self.pipeline and self.pipeline[key] is not None:
meta[key] = self.pipeline[key]
fingerprint = filters_fingerprint(self.pipeline.get("filters"))
if fingerprint:
# Structured, so a reader can tell a change that makes two records
# incomparable (measurement) from one that only changes how the same
# measurement was smoothed (readout).
meta["filters"] = fingerprint
# The flat keys are kept as well for readers that look them up by
# name; the groups above are the authoritative form.
for group in ("measurement", "readout"):
meta.update(fingerprint.get(group) or {})
if self.measurements:
meta["samples"] = self.measurements
if self.origin and "origin" not in meta:
meta["origin"] = self.origin
if meta:
doc["meta"] = meta
for key, value in self.extras.items():
if key not in doc:
doc[key] = deepcopy(value)
return doc
def _channel_from_block(
channel_id: str,
block: Any,
*,
unit: str = "N",
default_method: str = "piecewise_linear",
) -> ChannelCurve | None:
if not isinstance(block, dict):
return None
method = str(block.get("method", block.get("model", default_method))).lower()
if method in ("linear", "affine"):
slope = block.get("slope")
offset = block.get("offset")
if slope is None or offset is None:
return None
return ChannelCurve(
id=channel_id,
method="linear",
slope=float(slope),
offset=float(offset),
unit=str(block.get("unit", unit)),
raw_key=str(block.get("raw_key", "")),
)
if method != "piecewise_linear":
return None
raw_points = block.get("points", [])
if not isinstance(raw_points, (list, tuple)) or len(raw_points) < 2:
return None
try:
points = tuple(sorted(_parse_point(p) for p in raw_points))
except (TypeError, ValueError, CalibrationError):
return None
if len(points) < 2:
return None
return ChannelCurve(
id=channel_id,
method="piecewise_linear",
points=points,
extrapolate=bool(block.get("extrapolate", True)),
clamp_output_min=block.get("clamp_output_min", 0.0),
input_mode=str(block.get("input", "magnitude")).lower(),
signed=bool(block.get("signed", False)),
unit=str(block.get("unit", unit)),
raw_key=str(block.get("raw_key", _JSON_RAW_KEY.get(_CANONICAL_TO_JSON.get(channel_id, ""), ""))),
)
def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResult:
"""Validate a document or Calibration. Errors refuse apply/write."""
errors: list[str] = []
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
schema = doc.schema_version
identity = doc.identity
elif isinstance(doc, dict):
data = doc
schema_raw = data.get("schema_version", 1)
try:
schema = int(schema_raw)
except (TypeError, ValueError):
errors.append(f"invalid schema_version: {schema_raw!r}")
return CheckResult(ok=False, errors=errors, warnings=warns)
if schema not in SUPPORTED_SCHEMAS:
errors.append(f"unsupported schema_version: {schema}")
channels = data.get("channels")
if not isinstance(channels, dict):
errors.append("missing or invalid channels object")
return CheckResult(ok=False, errors=errors, warnings=warns)
channels_obj = {}
unit = str(data.get("unit", "N"))
for json_name, block in channels.items():
try:
cid = canonicalize_channel(json_name) if json_name != "shear" else "shear"
except CalibrationError:
# Future / unknown channel names: skip strict mapping.
continue
if json_name == "shear":
curve = _channel_from_block("fx", block, unit=unit)
else:
curve = _channel_from_block(cid, block, unit=unit)
if curve is None:
method = None
if isinstance(block, dict):
method = block.get("method", block.get("model"))
if isinstance(block, dict) and method not in (
None,
"piecewise_linear",
"linear",
"affine",
):
errors.append(f"unknown method for {json_name}: {method!r}")
elif isinstance(block, dict):
pts = block.get("points", [])
if method in ("piecewise_linear", None) and (
not isinstance(pts, (list, tuple)) or len(pts) < 2
):
errors.append(f"{json_name}: need at least 2 points")
else:
errors.append(f"invalid channel block: {json_name}")
else:
channels_obj[cid if json_name != "shear" else "fx"] = curve
meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
identity = {
"sensor_type": meta.get("sensor_type", ""),
"sn": meta.get("sn", ""),
}
else:
return CheckResult(ok=False, errors=["document must be dict or Calibration"])
if not channels_obj and not errors:
errors.append("no usable channel curves")
if "fn" not in channels_obj and "fx" not in channels_obj and "fy" not in channels_obj:
if not errors:
errors.append("no FN/FX/FY channels")
if "fx" not in channels_obj:
warns.append("missing FX channel")
if "fy" not in channels_obj:
warns.append("missing FY channel")
sn = str(identity.get("sn") or "")
origin = str(identity.get("origin") or data.get("meta", {}).get("origin", "") if isinstance(data, dict) else "")
if not sn and origin in ("factory",):
warns.append("empty sn on factory calibration")
if pipeline is not None and isinstance(doc, Calibration):
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
if key in doc.pipeline and key in pipeline and doc.pipeline[key] != pipeline[key]:
warns.append(f"pipeline mismatch: {key}")
# The filter groups are compared apart on purpose: ``flow_ema_alpha``
# changes what was measured, the readout stages only smooth it.
filter_warns, filter_notes = filter_mismatches(
doc.pipeline.get("filters"), pipeline.get("filters")
)
warns.extend(filter_warns)
notes.extend(filter_notes)
return CheckResult(ok=not errors, errors=errors, warnings=warns, notes=notes)
def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
"""Parse a force_calibration dict into a Calibration object."""
if not isinstance(data, dict):
raise CalibrationError("calibration document must be a JSON object")
schema_raw = data.get("schema_version", 1)
try:
schema = int(schema_raw)
except (TypeError, ValueError) as exc:
raise CalibrationError(f"invalid schema_version: {schema_raw!r}") from exc
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):
raise CalibrationError("missing or invalid channels object")
channels: dict[str, ChannelCurve] = {}
legacy_shear: ChannelCurve | None = None
# Prefer explicit axis channels (v2).
for json_name, cid in (("normal", "fn"), ("shear_x", "fx"), ("shear_y", "fy")):
if json_name in channels_raw:
curve = _channel_from_block(cid, channels_raw[json_name], unit=unit)
if curve is None:
raise CalibrationError(f"invalid channel block: {json_name}")
channels[cid] = curve
# v1 shared shear → both fx and fy when axes absent.
if "shear" in channels_raw and ("fx" not in channels or "fy" not in channels):
shear_block = channels_raw["shear"]
legacy_shear = _channel_from_block("fx", shear_block, unit=unit)
if legacy_shear is None:
raise CalibrationError("invalid channel block: shear")
# Copy with correct ids for each axis.
shared = ChannelCurve(
id="fx",
method=legacy_shear.method,
points=legacy_shear.points,
slope=legacy_shear.slope,
offset=legacy_shear.offset,
signed=legacy_shear.signed or legacy_shear.input_mode == "components",
extrapolate=legacy_shear.extrapolate,
clamp_output_min=legacy_shear.clamp_output_min,
raw_key=legacy_shear.raw_key,
input_mode=legacy_shear.input_mode,
unit=legacy_shear.unit,
)
if "fx" not in channels:
channels["fx"] = shared
if "fy" not in channels:
channels["fy"] = ChannelCurve(
id="fy",
method=shared.method,
points=shared.points,
slope=shared.slope,
offset=shared.offset,
signed=shared.signed,
extrapolate=shared.extrapolate,
clamp_output_min=shared.clamp_output_min,
raw_key=_JSON_RAW_KEY["shear_y"],
input_mode=shared.input_mode,
unit=shared.unit,
)
legacy_shear = shared
# Legacy single-channel file: {"normal_force": {"model": "linear", ...}}
if "fn" not in channels:
block = data.get("normal_force", data if "slope" in data else None)
if isinstance(block, dict):
curve = _channel_from_block("fn", block, unit=unit, default_method="linear")
if curve is not None:
channels["fn"] = curve
if not channels:
raise CalibrationError("no usable channel curves")
meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
identity_in = data.get("identity") if isinstance(data.get("identity"), dict) else {}
pipeline_in = data.get("pipeline") if isinstance(data.get("pipeline"), dict) else {}
provenance_in = data.get("provenance") if isinstance(data.get("provenance"), dict) else {}
measurements_in = data.get("measurements") if isinstance(data.get("measurements"), dict) else {}
identity = {
"sensor_type": identity_in.get("sensor_type", meta.get("sensor_type", "")),
"sn": identity_in.get("sn", meta.get("sn", "")),
"hardware_rev": identity_in.get("hardware_rev", meta.get("hardware_rev", "")),
"gel_id": identity_in.get("gel_id", meta.get("gel_id", "")),
}
pipeline = {
"stitch_resolution": pipeline_in.get(
"stitch_resolution", meta.get("stitch_resolution")
),
"dis_mode": pipeline_in.get("dis_mode", meta.get("dis_mode")),
"hhd_downsample": pipeline_in.get("hhd_downsample", meta.get("hhd_downsample")),
"config_ref": pipeline_in.get("config_ref", meta.get("config_ref")),
"config_hash": pipeline_in.get("config_hash", meta.get("config_hash")),
# Structured when the document has it, otherwise rebuilt from the flat
# keys older documents wrote — reading them back is what makes the
# fingerprint checkable at all.
"filters": filters_fingerprint(
pipeline_in.get("filters")
or meta.get("filters")
or _flat_filters_from(pipeline_in, meta)
),
}
provenance = {
"origin": provenance_in.get("origin", meta.get("origin", "")),
"created_at": provenance_in.get(
"created_at", meta.get("date", meta.get("created_at", ""))
),
"calibration_id": provenance_in.get("calibration_id", meta.get("calibration_id", "")),
"tool": provenance_in.get("tool", meta.get("tool")),
"operator": provenance_in.get("operator", meta.get("operator", "")),
"station": provenance_in.get("station", meta.get("station", "")),
"record_duration_s": provenance_in.get(
"record_duration_s", meta.get("record_duration_s")
),
}
measurements = measurements_in or meta.get("samples") or {}
origin = str(provenance.get("origin") or "default")
extras = {k: deepcopy(v) for k, v in data.items() if k not in _KNOWN_TOP_KEYS}
cal = Calibration(
schema_version=schema,
unit=unit,
origin=origin,
identity=identity,
pipeline=pipeline,
provenance=provenance,
channels=channels,
measurements=measurements if isinstance(measurements, dict) else {},
extras=extras,
source_path=Path(source_path).resolve() if source_path is not None else None,
_legacy_shear=legacy_shear,
)
result = check(cal)
if not result.ok:
raise CalibrationError("; ".join(result.errors))
# Missing FX/FY are optional; leave them on CheckResult for callers.
# Only surface non-channel warnings as Python warnings.
for w in result.warnings:
if w.startswith("missing F"):
continue
warnings.warn(f"calibration: {w}", stacklevel=2)
return cal
def load(
source: str | Path | None = None,
*,
sensor_type: str | None = None,
sn: str | None = None, # noqa: ARG001 — reserved for SN files
origin: str | None = None, # noqa: ARG001
strict: bool = False,
) -> Calibration | None:
"""Load calibration from an explicit path or product bundle.
Load order:
1. ``source`` file path when it exists
2. ``sensors/<sensor_type>/force_calibration.json``
3. ``None`` (missing bundle file)
Explicit ``source`` that is missing or invalid raises when the path was
provided (or when ``strict=True``). Missing bundle file returns ``None``.
"""
del sn, origin # reserved
path: Path | None = None
explicit = source is not None and str(source).strip() != ""
if explicit:
path = select_calibration_path(cal_path=source)
if not path.is_file():
msg = f"calibration file not found: {path}"
if strict or explicit:
raise CalibrationError(msg)
return None
elif sensor_type:
from .configs import resolve_sensor_bundle
bundle = resolve_sensor_bundle(str(sensor_type))
if bundle is not None and bundle.force_calibration_json is not None:
path = bundle.force_calibration_json
elif strict:
raise CalibrationError(
f"no force_calibration.json for sensor_type={sensor_type!r}"
)
else:
return None
else:
if strict:
raise CalibrationError("load() requires source or sensor_type")
return None
assert path is not None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
msg = f"failed to read calibration JSON: {path}: {exc}"
if strict or explicit:
raise CalibrationError(msg) from exc
return None
try:
return parse(data, source_path=path)
except CalibrationError:
if strict or explicit or (isinstance(data, dict) and (data.get("schema_version") == 3 or "model" in data)):
raise
return None
def load_for_device(
calibration_dir: str | Path,
sensor_type: str,
device_id: str,
*,
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}")
for label, value in (("sensor_type", sensor_type), ("device_id", device_id)):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", value):
raise CalibrationError(f"invalid {label}: {value!r}")
path = root / sensor_type / f"{device_id}.json"
if not path.exists():
if path.is_symlink():
raise CalibrationError(f"calibration link is broken: {path}")
return None
if not path.is_file():
raise CalibrationError(f"calibration path is not a file: {path}")
return path
def validate_for_device(
cal: Calibration,
sensor_type: str,
device_id: str,
*,
pipeline: Mapping[str, Any] | None = None,
) -> Calibration:
"""Reject a calibration for another device or measurement pipeline."""
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():
raise CalibrationError(f"calibration requires FN, FX and FY: {path}")
if pipeline is not None:
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
recorded, current = cal.pipeline.get(key), pipeline.get(key)
if recorded is not None and current is not None and recorded != current:
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
def pipeline_from_sensor_json(
path: str | Path | None,
*,
filters_state: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Pull stitch / tracker / filter fields from a sensor.json for export pipeline/meta.
``filters_state`` is the device's ``Sensor.filters_state()``. Pass it
whenever a device is available: a config file states an intent that a runtime
``configure()`` may have moved on from, so only the effective values are
worth recording. Without it the file's ``filters`` block is reported for what
it is — the intent — and values it does not state are left out rather than
filled in with a default.
The result carries the filter settings in one structured ``"filters"`` key
(``measurement`` / ``readout`` / ``preset`` / ``source``), not as loose
top-level keys: the groups decide whether two records are comparable, so they
have to survive a round trip intact.
"""
pipeline: dict[str, Any] = {
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"filters": {},
"config_ref": None,
}
if path is None and filters_state is None:
return pipeline
p = Path(path) if path is not None else None
if p is not None:
pipeline["config_ref"] = str(p)
data: dict[str, Any] = {}
if p is not None and p.is_file():
try:
loaded = json.loads(p.read_text(encoding="utf-8"))
except Exception:
loaded = None
if isinstance(loaded, dict):
data = loaded
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
stitch = camera.get("resolution_stitch")
if isinstance(stitch, (list, tuple)) and len(stitch) >= 2:
pipeline["stitch_resolution"] = [int(stitch[0]), int(stitch[1])]
if "dis_mode" in tracker:
try:
pipeline["dis_mode"] = int(tracker["dis_mode"])
except (TypeError, ValueError):
pipeline["dis_mode"] = tracker.get("dis_mode")
if "hhd_downsample" in tracker:
try:
pipeline["hhd_downsample"] = int(tracker["hhd_downsample"])
except (TypeError, ValueError):
pipeline["hhd_downsample"] = tracker.get("hhd_downsample")
# Signal conditioning is part of the measurement-pipeline identity: these
# stages change the reported force values, so they are recorded next to
# dis_mode / hhd_downsample. The effective state wins over the file.
pipeline["filters"] = filters_fingerprint(
filters_state if filters_state else data.get("filters")
)
return pipeline
def _piecewise_from_samples(
channel_id: str,
samples: Iterable[CalSample],
*,
signed: bool,
) -> ChannelCurve | None:
rows = sorted(samples, key=lambda s: abs(float(s.raw_mean)))
if len(rows) < 2:
return None
points = tuple((abs(float(s.raw_mean)), float(s.weight_n)) for s in rows)
json_name = _CANONICAL_TO_JSON[channel_id]
return ChannelCurve(
id=channel_id,
method="piecewise_linear",
points=points,
signed=signed,
extrapolate=True,
clamp_output_min=0.0,
raw_key=_JSON_RAW_KEY[json_name],
input_mode="components" if signed else "magnitude",
unit="N",
)
def build(
samples: Mapping[str, list[CalSample]],
*,
identity: dict[str, Any] | None = None,
pipeline: dict[str, Any] | None = None,
origin: str = "user",
extra_meta: dict[str, Any] | None = None,
) -> Calibration:
"""Fit piecewise curves from samples; omit a channel if fewer than 2 points."""
channels: dict[str, ChannelCurve] = {}
warnings_list: list[str] = []
for key in CHANNEL_IDS:
rows = list(samples.get(key, []) or samples.get(key.upper(), []) or [])
# Also accept FN/FX/FY aliases in the mapping keys.
if not rows:
for alias, cid in CHANNEL_ALIASES.items():
if cid == key and alias in samples:
rows = list(samples[alias] or [])
break
signed = key in ("fx", "fy")
curve = _piecewise_from_samples(key, rows, signed=signed)
if curve is None:
if rows:
warnings_list.append(f"{key}: need at least 2 points (have {len(rows)})")
else:
warnings_list.append(f"{key}: no samples")
continue
channels[key] = curve
if not channels:
raise CalibrationError(
"No complete channels to build (each needs ≥2 points). "
+ "; ".join(warnings_list)
)
identity = dict(identity or {})
pipeline = dict(pipeline or {})
measurements: dict[str, list[dict[str, float | int]]] = {}
for key in CHANNEL_IDS:
rows = list(samples.get(key, []) or [])
if not rows:
for alias, cid in CHANNEL_ALIASES.items():
if cid == key and alias in samples:
rows = list(samples[alias] or [])
break
measurements[key] = [asdict(s) for s in rows]
provenance: dict[str, Any] = {
"origin": origin,
"created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
}
if extra_meta:
for k, v in extra_meta.items():
if k == "samples":
continue
if k in ("stitch_resolution", "dis_mode", "hhd_downsample", "config_ref"):
if k not in pipeline or pipeline[k] is None:
pipeline[k] = v
elif k == "filters":
if not pipeline.get("filters"):
pipeline["filters"] = filters_fingerprint(v)
elif k in FILTER_KEYS:
# Flat form: fold the loose key into the structured groups.
merged = deepcopy(pipeline.get("filters") or {})
group = "measurement" if k in FILTER_MEASUREMENT_KEYS else "readout"
merged.setdefault(group, {})[k] = v
pipeline["filters"] = filters_fingerprint(merged)
elif k in ("sensor_type", "sn", "hardware_rev", "gel_id"):
if k not in identity or not identity[k]:
identity[k] = v
else:
provenance[k] = v
cal = Calibration(
schema_version=CURRENT_SCHEMA,
unit="N",
origin=origin,
identity=identity,
pipeline=pipeline,
provenance=provenance,
channels=channels,
measurements=measurements,
extras={},
source_path=None,
)
for w in warnings_list:
warnings.warn(f"calibration build: {w}", stacklevel=2)
return cal
def write(
cal: Calibration,
path: str | Path,
*,
overwrite: bool = False,
) -> Path:
"""Write calibration JSON. Runs check() first; raises on errors."""
result = check(cal)
if not result.ok:
raise CalibrationError("; ".join(result.errors))
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()
# 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",
)
return path.resolve()
def write_user(
cal: Calibration,
*,
bundle_dir: str | Path,
sn: str,
overwrite: bool = True,
) -> Path:
"""Write a per-SN user calibration under ``bundle_dir/calibrations/<SN>.json``."""
sn_clean = str(sn or "").strip()
if not sn_clean:
raise CalibrationError("write_user requires a non-empty sn")
dest = Path(bundle_dir) / "calibrations" / f"{sn_clean}.json"
return write(cal, dest, overwrite=overwrite)