新增统一力读数会话 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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user