feat: ForceStreamSession 使用固定设计帧率的输出滤波(T07)
新增 FixedRateOutputFilter:设计帧率在会话创建时固定,实测帧率漂移和长间隔只报告、不重新设计也不清历史;request_output_settings 可跨线程排队,在力线程帧边界切换,只重建滤波链、清空历史,不改零点和参考版本,并发出 output_filter_changed StreamEvent(设置、指纹、生效帧、预热)。结果行带滤波设置身份与预热状态。已发布 ForceReadout 和旧 ForceSession/ForceOutputFilter 默认行为不变;设计帧率下与旧会话逐位一致。回归 391 项、lint 通过。
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""Fixed-frame-rate output filter for the new force stream session (T07).
|
||||
|
||||
The published :class:`~orisys.force_readout.ForceOutputFilter` re-designs its
|
||||
low-pass whenever the measured signal rate drifts more than 10% from the current
|
||||
design (``ForceOutputFilter._track_signal_rate``). The stage-1 contract (K3.3,
|
||||
K3.4) instead fixes the design rate when the session starts: later measured-rate
|
||||
drift and long gaps are *reported*, never used to re-tune or clear the filter.
|
||||
|
||||
:class:`FixedRateOutputFilter` subclasses the published filter, keeping its
|
||||
median → low-pass → threshold chain (and its exact pass-through/NaN behaviour),
|
||||
and overrides only:
|
||||
|
||||
* rate tracking: measure and report, never reconfigure;
|
||||
* :meth:`switch`: explicit, frame-boundary settings replacement that returns
|
||||
whether the chain changed and starts a documented warm-up counter;
|
||||
* warm-up and measured-rate diagnostics used by ``ForceStreamOutput``.
|
||||
|
||||
The algorithm version is ``output-filter/1``: its number is part of the settings
|
||||
identity emitted in ``StreamEvent`` payloads, so T14 can rebuild the same chain
|
||||
from an event.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, replace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .force_readout import ForceOutputFilter, OutputSettings
|
||||
|
||||
__all__ = [
|
||||
"FIXED_RATE_ALGORITHM",
|
||||
"FixedRateOutputFilter",
|
||||
"output_settings_fingerprint",
|
||||
"warmup_frames_for",
|
||||
]
|
||||
|
||||
#: Identity of this filter's algorithm; recorded with every settings switch.
|
||||
FIXED_RATE_ALGORITHM = "output-filter/1"
|
||||
|
||||
|
||||
def output_settings_fingerprint(settings: OutputSettings) -> str:
|
||||
"""Content identity of a normalised output-setting group."""
|
||||
payload = {
|
||||
"algorithm": FIXED_RATE_ALGORITHM,
|
||||
"settings": asdict(settings.normalised()),
|
||||
}
|
||||
text = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def warmup_frames_for(settings: OutputSettings) -> int:
|
||||
"""Frames needed before a rebuilt chain is reported as warmed up.
|
||||
|
||||
Median: one full window. Low-pass: two orders of magnitude of settling is a
|
||||
conservative small count (each order costs about one sample time constant at
|
||||
the configured cutoff); thresholds are memory-less and add nothing. The value
|
||||
is an explicit contract, not a measured optimum.
|
||||
"""
|
||||
resolved = settings.normalised()
|
||||
frames = max(1, int(resolved.median_window))
|
||||
if resolved.lowpass_hz > 0:
|
||||
frames += 2 * max(1, int(resolved.lowpass_order))
|
||||
return int(frames)
|
||||
|
||||
|
||||
class FixedRateOutputFilter(ForceOutputFilter):
|
||||
"""Published filter chain with a fixed design rate and explicit switching."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: OutputSettings,
|
||||
*,
|
||||
design_sample_rate_hz: float | None = None,
|
||||
) -> None:
|
||||
resolved = settings.normalised()
|
||||
design = (
|
||||
float(resolved.sample_rate_hz)
|
||||
if design_sample_rate_hz is None
|
||||
else float(design_sample_rate_hz)
|
||||
)
|
||||
if not np.isfinite(design) or design <= 0.0:
|
||||
raise ValueError("design_sample_rate_hz must be a positive finite number")
|
||||
# The chain itself is designed once, at the fixed rate.
|
||||
super().__init__(replace(resolved, sample_rate_hz=design))
|
||||
self.design_sample_rate_hz = design
|
||||
self._measured_sample_rate_hz: float | None = None
|
||||
self._sample_rate_irregular = False
|
||||
self._warmup_elapsed_frames = 0
|
||||
|
||||
# ------------------------------------------------------------ diagnostics
|
||||
@property
|
||||
def measured_sample_rate_hz(self) -> float | None:
|
||||
"""Measured signal rate (diagnostic only; never re-tunes the chain)."""
|
||||
return self._measured_sample_rate_hz
|
||||
|
||||
@property
|
||||
def sample_rate_irregular(self) -> bool:
|
||||
"""True when the measured rate differs from the design by more than 10%."""
|
||||
return bool(self._sample_rate_irregular)
|
||||
|
||||
@property
|
||||
def settings_fingerprint(self) -> str:
|
||||
return output_settings_fingerprint(self._settings)
|
||||
|
||||
@property
|
||||
def warmup_required_frames(self) -> int:
|
||||
return warmup_frames_for(self._settings)
|
||||
|
||||
@property
|
||||
def warmup_elapsed_frames(self) -> int:
|
||||
return int(self._warmup_elapsed_frames)
|
||||
|
||||
@property
|
||||
def warming_up(self) -> bool:
|
||||
return self._warmup_elapsed_frames < self.warmup_required_frames
|
||||
|
||||
# ------------------------------------------------------- rate observation
|
||||
def _track_signal_rate(self, signal_time_s: float | None) -> None:
|
||||
"""Measure the achieved rate for diagnostics only; never re-design."""
|
||||
if signal_time_s is None:
|
||||
return
|
||||
try:
|
||||
now = float(signal_time_s)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not np.isfinite(now):
|
||||
return
|
||||
rate = float(self._rate.tick(now))
|
||||
if not self._rate.measured:
|
||||
return
|
||||
self._measured_sample_rate_hz = rate
|
||||
self._sample_rate_irregular = bool(
|
||||
abs(rate - self.design_sample_rate_hz) > 0.1 * self.design_sample_rate_hz
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- processing
|
||||
def process(
|
||||
self, raw, newton, *, raw_valid=None, newton_valid=None, signal_time_s=None
|
||||
):
|
||||
result = super().process(
|
||||
raw,
|
||||
newton,
|
||||
raw_valid=raw_valid,
|
||||
newton_valid=newton_valid,
|
||||
signal_time_s=signal_time_s,
|
||||
)
|
||||
self._warmup_elapsed_frames += 1
|
||||
return result
|
||||
|
||||
# ----------------------------------------------------------------- switching
|
||||
def switch(self, settings: OutputSettings) -> bool:
|
||||
"""Replace the settings at a caller-controlled frame boundary.
|
||||
|
||||
Returns ``False`` when the normalised settings are unchanged (no chain
|
||||
rebuild, no history loss, no warm-up restart). An explicit switch is the
|
||||
only way to change the design: the new group's ``sample_rate_hz`` becomes
|
||||
the new fixed design rate (the caller only switches when that is intended).
|
||||
"""
|
||||
resolved = settings.normalised()
|
||||
if resolved == self._settings:
|
||||
return False
|
||||
self.design_sample_rate_hz = float(resolved.sample_rate_hz)
|
||||
self._warmup_elapsed_frames = 0
|
||||
self._measured_sample_rate_hz = None
|
||||
self._sample_rate_irregular = False
|
||||
self.configure(resolved)
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
super().reset()
|
||||
self._warmup_elapsed_frames = 0
|
||||
self._measured_sample_rate_hz = None
|
||||
self._sample_rate_irregular = False
|
||||
@@ -31,10 +31,12 @@ mutated; outputs carry scalars/statuses/immutable progress, not internal arrays.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .force_readout import OutputSettings
|
||||
from .force_session import (
|
||||
WORKFLOW_VERSION,
|
||||
ForceOutput,
|
||||
@@ -44,8 +46,12 @@ from .force_session import (
|
||||
from .force_session import (
|
||||
FrameStamp as ForceFrameStamp,
|
||||
)
|
||||
from .force_stream_filter import (
|
||||
FIXED_RATE_ALGORITHM,
|
||||
FixedRateOutputFilter,
|
||||
)
|
||||
from .sensor import FrameMeasurement
|
||||
from .stream_contract import MeasurementFrame
|
||||
from .stream_contract import MeasurementFrame, StreamEvent
|
||||
|
||||
__all__ = ["ForceStreamOutput", "ForceStreamSession"]
|
||||
|
||||
@@ -78,6 +84,15 @@ class ForceStreamOutput:
|
||||
late: bool = False
|
||||
calibration_fingerprint: str = ""
|
||||
workflow_version: str = WORKFLOW_VERSION
|
||||
# T07: the output-filter identity used for this frame and its warm-up state.
|
||||
output_settings: OutputSettings | None = None
|
||||
output_settings_fingerprint: str = ""
|
||||
design_sample_rate_hz: float | None = None
|
||||
measured_sample_rate_hz: float | None = None
|
||||
sample_rate_irregular: bool = False
|
||||
warmup_required_frames: int = 0
|
||||
warmup_elapsed_frames: int = 0
|
||||
warming_up: bool = False
|
||||
|
||||
@property
|
||||
def reference_generation(self) -> int:
|
||||
@@ -140,6 +155,7 @@ class ForceStreamSession:
|
||||
context=None,
|
||||
calibration=None,
|
||||
settings=None,
|
||||
design_sample_rate_hz: float | None = None,
|
||||
) -> None:
|
||||
self._source = _PendingMeasurementSource()
|
||||
self._engine = ForceSession(
|
||||
@@ -150,6 +166,17 @@ class ForceStreamSession:
|
||||
calibration=calibration,
|
||||
settings=settings,
|
||||
)
|
||||
# T07: the session owns the output filter so the design rate is fixed once
|
||||
# and explicit switches can be applied at a frame boundary. The engine's
|
||||
# zero/calibration code keeps calling the same filter interface.
|
||||
self._filter = FixedRateOutputFilter(
|
||||
self._engine.output_settings,
|
||||
design_sample_rate_hz=design_sample_rate_hz,
|
||||
)
|
||||
self._engine._filter = self._filter
|
||||
self._pending_filter_settings: OutputSettings | None = None
|
||||
self._filter_lock = threading.Lock()
|
||||
self._events: list[StreamEvent] = []
|
||||
self._family = sensor_family
|
||||
self._device_id = device_id
|
||||
self._version: int | None = None
|
||||
@@ -190,10 +217,40 @@ class ForceStreamSession:
|
||||
def output_settings(self):
|
||||
return self._engine.output_settings
|
||||
|
||||
@property
|
||||
def design_sample_rate_hz(self) -> float:
|
||||
"""The fixed filter design rate chosen when this session was created."""
|
||||
return float(self._filter.design_sample_rate_hz)
|
||||
|
||||
@property
|
||||
def output_filter(self) -> FixedRateOutputFilter:
|
||||
"""The session-owned filter (read-only diagnostic access)."""
|
||||
return self._filter
|
||||
|
||||
@property
|
||||
def last_output(self) -> ForceStreamOutput | None:
|
||||
return self._last_output
|
||||
|
||||
def request_output_settings(self, settings: OutputSettings) -> None:
|
||||
"""Queue an explicit output-filter switch for the next frame boundary.
|
||||
|
||||
Safe to call from another thread: the request only records the latest
|
||||
setting group. The force thread applies it at the start of the next
|
||||
:meth:`process` call (never in the middle of a frame), rebuilds the
|
||||
filter chain, clears its history and emits one ``output_filter_changed``
|
||||
event. Requesting the currently active settings is a no-op.
|
||||
"""
|
||||
if not isinstance(settings, OutputSettings):
|
||||
raise TypeError("settings must be an OutputSettings")
|
||||
with self._filter_lock:
|
||||
self._pending_filter_settings = settings.normalised()
|
||||
|
||||
def drain_events(self) -> tuple[StreamEvent, ...]:
|
||||
"""Return the events emitted since the previous drain, in order."""
|
||||
events = tuple(self._events)
|
||||
self._events.clear()
|
||||
return events
|
||||
|
||||
@property
|
||||
def measurement_requirements(self) -> tuple[bool, bool]:
|
||||
"""``(want_field, want_contact)`` the installed calibration model asks for.
|
||||
@@ -259,6 +316,36 @@ class ForceStreamSession:
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- result rows
|
||||
def _apply_pending_output_filter(self, frame: MeasurementFrame) -> None:
|
||||
"""Apply a queued settings switch at this frame boundary (force thread)."""
|
||||
with self._filter_lock:
|
||||
pending = self._pending_filter_settings
|
||||
self._pending_filter_settings = None
|
||||
if pending is None:
|
||||
return
|
||||
if not self._filter.switch(pending):
|
||||
return
|
||||
settings = self._filter.settings
|
||||
self._events.append(
|
||||
StreamEvent(
|
||||
kind="output_filter_changed",
|
||||
stamp=frame.stamp,
|
||||
payload={
|
||||
"algorithm": FIXED_RATE_ALGORITHM,
|
||||
"settings": asdict(settings),
|
||||
"fingerprint": self._filter.settings_fingerprint,
|
||||
"design_sample_rate_hz": float(self._filter.design_sample_rate_hz),
|
||||
"effective_frame_id": int(frame.stamp.frame_id),
|
||||
"effective_source_frame_id": frame.stamp.source_frame_id,
|
||||
"effective_stamp": frame.stamp.to_dict(),
|
||||
"warmup": {
|
||||
"required_frames": int(self._filter.warmup_required_frames),
|
||||
"elapsed_frames": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _row(
|
||||
self,
|
||||
frame: MeasurementFrame,
|
||||
@@ -286,6 +373,14 @@ class ForceStreamSession:
|
||||
duplicate=duplicate,
|
||||
late=late,
|
||||
calibration_fingerprint=self._engine.calibration_fingerprint,
|
||||
output_settings=self._filter.settings,
|
||||
output_settings_fingerprint=self._filter.settings_fingerprint,
|
||||
design_sample_rate_hz=self._filter.design_sample_rate_hz,
|
||||
measured_sample_rate_hz=self._filter.measured_sample_rate_hz,
|
||||
sample_rate_irregular=self._filter.sample_rate_irregular,
|
||||
warmup_required_frames=self._filter.warmup_required_frames,
|
||||
warmup_elapsed_frames=self._filter.warmup_elapsed_frames,
|
||||
warming_up=self._filter.warming_up,
|
||||
)
|
||||
self._last_output = output
|
||||
return output
|
||||
@@ -398,12 +493,15 @@ class ForceStreamSession:
|
||||
zero window or readout filter, and without invalidating a completed zero.
|
||||
A reference-version or session change fails the old zero immediately (in
|
||||
any phase) and requires an explicit :meth:`request_zero` for the new one.
|
||||
An explicit output-filter switch queued by
|
||||
:meth:`request_output_settings` takes effect at this frame boundary.
|
||||
"""
|
||||
frame = getattr(measurement, "frame", None)
|
||||
if not isinstance(frame, MeasurementFrame):
|
||||
raise TypeError(
|
||||
"ForceStreamSession consumes an ImageMeasurement-like input"
|
||||
)
|
||||
self._apply_pending_output_filter(frame)
|
||||
identity = self._identity(frame)
|
||||
version = int(frame.reference_generation)
|
||||
session_id = str(frame.stamp.session_id)
|
||||
|
||||
Reference in New Issue
Block a user