新增统一力读数会话 ForceSession

把同帧测量、空载归零、模型推断与共用后处理收进一个 SDK 会话,Studio 显示与记录使用同一样本;旧 Sensor 读数接口保持兼容。以 force session 与受影响 SDK 套件、lint 与文档门禁验证,硬件与真实载荷精度未验证。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CpCfJF6SvonMKmPZhzKZHb
This commit is contained in:
ykcheungab
2026-10-20 07:07:22 +00:00
parent dc8a630df3
commit ee830d8505
17 changed files with 4175 additions and 29 deletions
+137
View File
@@ -366,6 +366,143 @@ class CausalMedianFilter:
return np.median(self._buf, axis=0)
class MultiAxisCausalFilter:
"""Causal median → low-pass over a vector, one independent state per axis.
The force output needs the same chain twice (raw features and newtons), and
an unavailable axis (NaN, or an axis a model never calibrated) must neither
enter the filter history nor pull a valid axis towards zero. One
:class:`CausalMedianFilter` and one :class:`LowpassFilter` per axis give that
without a second implementation of either operator: callers keep one instance
per processed triplet and feed a per-axis validity mask.
``median_window <= 1`` and ``lowpass_cutoff_hz <= 0`` each switch their own
stage off; with both off the input is returned unchanged, NaN included.
"""
def __init__(
self,
*,
median_window: int = 1,
lowpass_cutoff_hz: float = 0.0,
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
lowpass_order: int = DEFAULT_LOWPASS_ORDER,
n_channels: int = 3,
):
self.n_channels = max(1, int(n_channels))
try:
self._median_window = max(1, int(median_window))
except (TypeError, ValueError):
self._median_window = 1
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz or 0.0)
self._sample_rate_hz = float(sample_rate_hz or DEFAULT_SAMPLE_RATE_HZ)
try:
self._lowpass_order = max(1, int(lowpass_order))
except (TypeError, ValueError):
self._lowpass_order = DEFAULT_LOWPASS_ORDER
self._medians: list[CausalMedianFilter] = []
self._lowpasses: list[LowpassFilter] = []
self._build()
@property
def enabled(self) -> bool:
return bool(self._medians and self._medians[0].enabled) or bool(
self._lowpasses and self._lowpasses[0].enabled
)
@property
def median_window(self) -> int:
return int(self._medians[0].window) if self._medians else 1
@property
def lowpass_cutoff_hz(self) -> float:
return float(self._lowpasses[0].cutoff_hz) if self._lowpasses else 0.0
def configure(
self,
*,
median_window: int | None = None,
lowpass_cutoff_hz: float | None = None,
sample_rate_hz: float | None = None,
lowpass_order: int | None = None,
) -> None:
"""Re-tune the chain; every axis state is dropped (callers re-warm it)."""
if median_window is not None:
try:
self._median_window = max(1, int(median_window))
except (TypeError, ValueError):
self._median_window = 1
if lowpass_cutoff_hz is not None:
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz)
if sample_rate_hz is not None:
self._sample_rate_hz = float(sample_rate_hz)
if lowpass_order is not None:
try:
self._lowpass_order = max(1, int(lowpass_order))
except (TypeError, ValueError):
self._lowpass_order = DEFAULT_LOWPASS_ORDER
self._build()
def _build(self) -> None:
self._medians = [
CausalMedianFilter(window=self._median_window, n_channels=1)
for _ in range(self.n_channels)
]
self._lowpasses = [
LowpassFilter(
cutoff_hz=self._lowpass_cutoff_hz,
sample_rate_hz=self._sample_rate_hz,
order=self._lowpass_order,
n_channels=1,
)
for _ in range(self.n_channels)
]
def reset(self) -> None:
"""Drop every axis' filter memory (new zero, model change, discontinuity)."""
for median in self._medians:
median.reset()
for lowpass in self._lowpasses:
lowpass.reset()
def process(self, values, valid=None) -> np.ndarray:
"""Filter one vector; invalid axes stay NaN and do not advance their state."""
x = np.asarray(values, dtype=np.float64).reshape(-1)
if x.size != self.n_channels:
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
if valid is None:
usable = np.isfinite(x)
else:
usable = np.asarray(valid, dtype=bool).reshape(-1) & np.isfinite(x)
if usable.size != self.n_channels:
raise ValueError(
f"expected {self.n_channels} validity flags, got {usable.size}"
)
out = np.full(self.n_channels, np.nan, dtype=np.float64)
for index in range(self.n_channels):
if not usable[index]:
continue
value = self._medians[index].process(x[index : index + 1])
out[index] = self._lowpasses[index].process(value)[0]
return out
def state(self) -> dict:
return {
"median_window": self.median_window,
"median_applied": bool(self._medians and self._medians[0].enabled),
"lowpass_cutoff_hz": self.lowpass_cutoff_hz,
"lowpass_applied": bool(self._lowpasses and self._lowpasses[0].enabled),
"lowpass_order": (
int(self._lowpasses[0].order) if self._lowpasses else DEFAULT_LOWPASS_ORDER
),
"lowpass_sample_rate_hz": (
float(self._lowpasses[0].sample_rate_hz)
if self._lowpasses
else DEFAULT_SAMPLE_RATE_HZ
),
}
class SampleRateEstimator:
"""Robust online estimate of the rate at which :meth:`tick` is called.