added calibration data handling functions

This commit is contained in:
ykcheungab
2026-09-10 21:47:36 +08:00
parent 9cbba08d68
commit 96f4428230
13 changed files with 1399 additions and 175 deletions
+782
View File
@@ -0,0 +1,782 @@
"""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 warnings
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
CURRENT_SCHEMA = 2
SUPPORTED_SCHEMAS = (1, 2)
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"}
)
class CalibrationError(ValueError):
"""Invalid or unusable force calibration document / path."""
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)
@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]
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] = []
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}")
return CheckResult(ok=not errors, errors=errors, warnings=warns)
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}")
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")),
}
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 = Path(str(source)).expanduser()
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:
raise
return None
def pipeline_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
"""Pull stitch / tracker fields from a sensor.json for export pipeline/meta."""
pipeline: dict[str, Any] = {
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"config_ref": None,
}
if path is None:
return pipeline
p = Path(path)
pipeline["config_ref"] = str(p)
if not p.is_file():
return pipeline
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return pipeline
if not isinstance(data, dict):
return pipeline
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")
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 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}")
path.parent.mkdir(parents=True, exist_ok=True)
doc = cal.to_dict()
# Always emit schema 2 for new writes.
doc["schema_version"] = 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)