Files
2026-06-09 14:29:44 +08:00

122 lines
3.8 KiB
Python

from dataclasses import dataclass
DEFAULT_FORCE_CALIBRATION_POINTS = [
{"raw": 1000.0, "force_n": 0.0},
{"raw": 10000.0, "force_n": 0.377},
{"raw": 30000.0, "force_n": 1.377},
{"raw": 62000.0, "force_n": 2.377},
]
DEFAULT_NORMAL_FORCE_CALIBRATION = {
"enabled": True,
"method": "piecewise_linear",
"extrapolate": True,
"clamp_output_min": 0.0,
"points": [
{"fnormal": 1000.0, "force_n": 0.0},
{"fnormal": 10000.0, "force_n": 0.377},
{"fnormal": 30000.0, "force_n": 1.377},
{"fnormal": 62000.0, "force_n": 2.377},
],
}
DEFAULT_SHEAR_FORCE_CALIBRATION = {
"enabled": True,
"method": "piecewise_linear",
"extrapolate": True,
"clamp_output_min": 0.0,
"points": DEFAULT_FORCE_CALIBRATION_POINTS,
}
def parse_calibration_point(point):
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")))
return float(raw), float(force_n)
if isinstance(point, (list, tuple)) and len(point) >= 2:
return float(point[0]), float(point[1])
raise ValueError(f"invalid calibration point: {point!r}")
@dataclass(frozen=True)
class ForceConverter:
points: tuple
unit: str = "N"
enabled: bool = True
extrapolate: bool = True
clamp_output_min: float | None = 0.0
input_mode: str = "magnitude"
signed: bool = False
@classmethod
def from_config(cls, calibration, config_name):
if calibration is None:
calibration = DEFAULT_NORMAL_FORCE_CALIBRATION
if not calibration.get("enabled", True):
return cls(points=(), unit="raw", enabled=False)
method = str(calibration.get("method", "piecewise_linear")).lower()
if method != "piecewise_linear":
raise ValueError(f"unsupported {config_name} method: {method}")
points = tuple(sorted(
parse_calibration_point(point)
for point in calibration.get("points", [])
))
if len(points) < 2:
raise ValueError(f"{config_name}.points must contain at least two points")
return cls(
points=points,
enabled=True,
extrapolate=bool(calibration.get("extrapolate", True)),
clamp_output_min=calibration.get("clamp_output_min", 0.0),
input_mode=str(calibration.get("input", "magnitude")).lower(),
signed=bool(calibration.get("signed", False)),
)
def convert(self, raw):
if not self.enabled:
return float(raw)
raw = float(raw)
points = self.points
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):
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):
if self.clamp_output_min is not None:
value = max(float(self.clamp_output_min), value)
return value