feat: ForceStreamSession 使用固定设计帧率的输出滤波(T07)

新增 FixedRateOutputFilter:设计帧率在会话创建时固定,实测帧率漂移和长间隔只报告、不重新设计也不清历史;request_output_settings 可跨线程排队,在力线程帧边界切换,只重建滤波链、清空历史,不改零点和参考版本,并发出 output_filter_changed StreamEvent(设置、指纹、生效帧、预热)。结果行带滤波设置身份与预热状态。已发布 ForceReadout 和旧 ForceSession/ForceOutputFilter 默认行为不变;设计帧率下与旧会话逐位一致。回归 391 项、lint 通过。
This commit is contained in:
ykcheungab
2026-10-21 16:27:44 +08:00
parent 11f8dc93e5
commit 26382b359f
4 changed files with 706 additions and 10 deletions
+396
View File
@@ -0,0 +1,396 @@
"""T07: fixed-rate output filtering, explicit switching and events.
The new session's filter is designed once (``design_sample_rate_hz``), reports
measured-rate drift without re-tuning, applies a switch only at a frame boundary,
emits exactly one ``output_filter_changed`` StreamEvent per change, and keeps the
published ``ForceOutputFilter``/old session defaults untouched.
"""
from __future__ import annotations
import importlib.util
import json
import threading
from pathlib import Path
import numpy as np
import pytest
from orisys.force_readout import ForceOutputFilter, OutputSettings
from orisys.force_stream_filter import (
FixedRateOutputFilter,
output_settings_fingerprint,
warmup_frames_for,
)
from orisys.force_stream_session import ForceStreamOutput, ForceStreamSession
from orisys.image_pipeline import ImageMeasurement
from orisys.stream_contract import FrameStamp, MeasurementFrame, StreamEvent
FRAME_NS_30 = 33_333_333
def _load_helpers():
path = Path(__file__).with_name("test_force_session.py")
spec = importlib.util.spec_from_file_location("_t07_helpers", path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
helpers = _load_helpers()
def _settings(**overrides) -> OutputSettings:
values = {
"sample_rate_hz": 30.0,
"median_window": 3,
"lowpass_hz": 5.0,
"lowpass_order": 2,
}
values.update(overrides)
return OutputSettings(**values)
def _measurement(
frame_id: int,
raw: tuple[float, float, float],
*,
version: int = 0,
time_ns: int | None = None,
) -> ImageMeasurement:
stamp = FrameStamp(
sensor_id="TEST-1",
session_id="s1",
frame_id=frame_id,
source_frame_id=frame_id,
capture_time_ns=(frame_id * FRAME_NS_30 if time_ns is None else int(time_ns)),
clock_id="host",
capture_time_basis="recorded_signal",
)
frame = MeasurementFrame(
stamp=stamp,
source_dt_s=None,
reference_generation=version,
)
return ImageMeasurement(frame=frame, force_raw=raw, torque=0.0, flow=None)
def _raw(frame_id: int) -> tuple[float, float, float]:
return (1000.0 + frame_id, 2000.0 + 0.5 * frame_id, 3000.0 - 0.25 * frame_id)
def _session(settings: OutputSettings | None = None, **kwargs) -> ForceStreamSession:
return ForceStreamSession(
sensor_family="box",
settings=settings if settings is not None else _settings(),
**kwargs,
)
def _ready(session: ForceStreamSession, *, count: int = 31) -> ForceStreamOutput:
session.request_zero(
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
)
results = [
session.process(_measurement(frame_id, _raw(frame_id)))
for frame_id in range(1, count + 1)
]
assert results[-1].zero.phase == "ready"
return results[-1]
# --------------------------------------------------------------- fixed design rate
def test_fixed_rate_reports_drift_without_redesigning_the_chain() -> None:
settings = _settings(median_window=1, lowpass_hz=5.0)
session = _session(settings, design_sample_rate_hz=30.0)
reference = FixedRateOutputFilter(settings, design_sample_rate_hz=30.0)
actual_hz = 20.0 # -33% from the design rate
old = ForceOutputFilter(settings)
for frame_id in range(1, 26):
time_ns = int(round(frame_id * 1e9 / actual_hz))
raw = (1000.0 + frame_id, 2000.0, 3000.0)
newton = (1.0, 2.0, 3.0)
new_out = session.process(_measurement(frame_id, raw, time_ns=time_ns))
expected_raw, _ = reference.process(
raw,
newton,
raw_valid=(True, True, True),
newton_valid=(True, True, True),
signal_time_s=time_ns / 1e9,
)
old.process(
raw,
newton,
raw_valid=(True, True, True),
newton_valid=(True, True, True),
signal_time_s=time_ns / 1e9,
)
assert new_out.raw == pytest.approx(expected_raw), frame_id
filter_ = session.output_filter
assert session.design_sample_rate_hz == 30.0
assert filter_.settings.sample_rate_hz == 30.0, "fixed design rate must not change"
assert filter_.measured_sample_rate_hz == pytest.approx(actual_hz, rel=0.02)
assert filter_.sample_rate_irregular is True
assert new_out.design_sample_rate_hz == 30.0
assert new_out.measured_sample_rate_hz == pytest.approx(actual_hz, rel=0.02)
assert new_out.sample_rate_irregular is True
# The published filter keeps its adaptive behaviour: this is what the new
# fixed-rate contract deliberately does not do.
assert old.settings.sample_rate_hz == pytest.approx(actual_hz, rel=0.02)
assert old.settings.sample_rate_hz != pytest.approx(30.0, rel=0.01)
def test_long_gap_does_not_clear_history_or_retune() -> None:
session = _session(
_settings(median_window=3, lowpass_hz=5.0), design_sample_rate_hz=30.0
)
reference = FixedRateOutputFilter(
_settings(median_window=3, lowpass_hz=5.0), design_sample_rate_hz=30.0
)
outputs = []
for frame_id in range(1, 16):
# Regular frames, then a 0.5 s gap at frame 10.
time_ns = frame_id * FRAME_NS_30 + (500_000_000 if frame_id >= 10 else 0)
out = session.process(_measurement(frame_id, _raw(frame_id), time_ns=time_ns))
expected, _ = reference.process(
_raw(frame_id),
(0.0, 0.0, 0.0),
raw_valid=(True, True, True),
newton_valid=(False, False, False),
signal_time_s=time_ns / 1e9,
)
assert out.raw == pytest.approx(expected), frame_id
outputs.append(out.raw)
assert session.output_filter.settings.sample_rate_hz == 30.0
assert session.output_filter.measured_sample_rate_hz is not None
assert any(a != b for a, b in zip(outputs[:9], outputs[9:])), (
"filter history was used"
)
# -------------------------------------------------------------- equality at design
def test_matches_old_session_filter_at_the_design_rate() -> None:
settings = _settings(median_window=3, lowpass_hz=5.0)
source = helpers.FakeMeasurementSource(_raw)
old = helpers.ForceSession(
source=source,
sensor_family="box",
settings=settings,
)
new = _session(settings)
old.request_zero(reference_id="ref", declared_unloaded=True)
new.request_zero(
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
)
old_raw, new_raw = [], []
for frame_id in range(1, 41):
old_out = helpers.process(old, source, frame_id)
new_out = new.process(_measurement(frame_id, _raw(frame_id)))
old_raw.append(np.asarray(old_out.raw))
new_raw.append(np.asarray(new_out.raw))
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
# --------------------------------------------------------------- explicit switching
def test_switch_at_frame_boundary_keeps_zero_and_emits_one_event() -> None:
session = _session(_settings(median_window=3, lowpass_hz=5.0))
_ready(session)
before_zero = session.zero
before_version = session.reference_generation
before_settings = session.output_settings
session.request_output_settings(_settings(median_window=5, lowpass_hz=10.0))
assert session.output_settings == before_settings, "not applied before a frame"
assert session.drain_events() == (), "no event before the boundary frame"
frame = session.process(_measurement(40, _raw(40)))
events = session.drain_events()
assert len(events) == 1
event = events[0]
assert event.kind == "output_filter_changed"
assert isinstance(event, StreamEvent)
assert session.zero.phase == "ready", "a filter switch must not invalidate the zero"
assert session.reference_generation == before_version
assert session.zero == before_zero
assert frame.output_settings == _settings(median_window=5, lowpass_hz=10.0)
assert frame.output_settings_fingerprint == output_settings_fingerprint(
frame.output_settings
)
assert event.payload["fingerprint"] == frame.output_settings_fingerprint
assert event.payload["effective_frame_id"] == 40
assert event.payload["warmup"]["required_frames"] == warmup_frames_for(
frame.output_settings
)
assert event.payload["warmup"]["elapsed_frames"] == 0
assert event.stamp.frame_id == 40
# The event is a T03 StreamEvent and survives JSON round-trip (T14 input).
encoded = json.loads(json.dumps(event.to_dict()))
assert StreamEvent.from_dict(encoded) == event
# History starts from the effective frame: the first post-switch outputs match
# a fresh chain fed only the post-switch raw sequence (frame 40 included).
reference = FixedRateOutputFilter(
_settings(median_window=5, lowpass_hz=10.0), design_sample_rate_hz=30.0
)
expected, _ = reference.process(
_raw(40),
(0.0, 0.0, 0.0),
raw_valid=(True, True, True),
newton_valid=(False, False, False),
signal_time_s=40 * FRAME_NS_30 / 1e9,
)
assert frame.raw == pytest.approx(expected)
for frame_id in range(41, 46):
out = session.process(_measurement(frame_id, _raw(frame_id)))
expected, _ = reference.process(
_raw(frame_id),
(0.0, 0.0, 0.0),
raw_valid=(True, True, True),
newton_valid=(False, False, False),
signal_time_s=frame_id * FRAME_NS_30 / 1e9,
)
assert out.raw == pytest.approx(expected), (
"post-switch history must start from the effective frame"
)
def test_requesting_the_same_settings_is_a_no_op() -> None:
session = _session(_settings(median_window=3, lowpass_hz=5.0))
ready = _ready(session)
same = session.output_settings
session.request_output_settings(same)
frame = session.process(_measurement(40, _raw(40)))
assert session.drain_events() == ()
assert frame.output_settings == same
# Zero completion already restarted the filter history; a no-op request must
# not restart it again: the warm-up counter simply continues.
assert frame.warmup_elapsed_frames == ready.warmup_elapsed_frames + 1
def test_switch_coalesces_to_the_latest_request_before_the_boundary() -> None:
session = _session(_settings(median_window=3, lowpass_hz=5.0))
_ready(session)
first = _settings(median_window=5, lowpass_hz=10.0)
latest = _settings(median_window=5, lowpass_hz=12.0)
session.request_output_settings(first)
session.request_output_settings(latest)
frame = session.process(_measurement(40, _raw(40)))
events = session.drain_events()
assert len(events) == 1
assert frame.output_settings == latest
assert events[0].payload["settings"] == latest.__dict__
def test_warmup_counts_frames_after_a_switch() -> None:
session = _session(_settings(median_window=3, lowpass_hz=5.0))
_ready(session)
new_settings = _settings(median_window=5, lowpass_hz=10.0)
required = warmup_frames_for(new_settings)
session.request_output_settings(new_settings)
rows = [
session.process(_measurement(40 + index, _raw(40 + index)))
for index in range(required + 2)
]
assert rows[0].warmup_elapsed_frames == 1
assert rows[0].warming_up is True
assert rows[0].warmup_required_frames == required
assert rows[required - 1].warming_up is False
assert rows[required - 1].warmup_elapsed_frames == required
assert rows[-1].warming_up is False
# -------------------------------------------------------------- thread boundary
class _GatedSession(ForceStreamSession):
"""Hold one frame inside the engine to prove switching waits for a boundary."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.entered = threading.Event()
self.release = threading.Event()
self.gate_frame = 40
def _feed_engine(self, measurement, frame):
if int(frame.stamp.frame_id) == self.gate_frame:
self.entered.set()
assert self.release.wait(timeout=5.0)
return super()._feed_engine(measurement, frame)
def test_threaded_switch_waits_for_the_next_frame_boundary() -> None:
session = _GatedSession(
sensor_family="box", settings=_settings(median_window=3, lowpass_hz=5.0)
)
_ready(session)
old_settings = session.output_settings
new_settings = _settings(median_window=5, lowpass_hz=10.0)
results: list[ForceStreamOutput] = []
def run_frame() -> None:
results.append(session.process(_measurement(40, _raw(40))))
worker = threading.Thread(target=run_frame)
worker.start()
try:
assert session.entered.wait(timeout=5.0), "frame never reached the engine gate"
# The frame is provably in flight: the switch request must not touch it.
session.request_output_settings(new_settings)
assert session.output_settings == old_settings, "no mid-frame reconfiguration"
assert session.drain_events() == ()
finally:
session.release.set()
worker.join(timeout=5.0)
assert not worker.is_alive()
assert results[0].output_settings == old_settings
next_frame = session.process(_measurement(41, _raw(41)))
events = session.drain_events()
assert len(events) == 1
assert next_frame.output_settings == new_settings
assert events[0].payload["effective_frame_id"] == 41
# ---------------------------------------------------------- pass-through and history
def test_all_stages_off_is_an_exact_passthrough_including_nan_and_signed_zero() -> None:
passthrough = OutputSettings(sample_rate_hz=30.0, median_window=1, lowpass_hz=0.0)
filter_ = FixedRateOutputFilter(passthrough, design_sample_rate_hz=30.0)
raw = (0.0, -0.0, float("nan"))
newton = (-0.0, 1.5, float("nan"))
out_raw, out_newton = filter_.process(
raw,
newton,
raw_valid=(True, True, True),
newton_valid=(True, True, True),
signal_time_s=1.0,
)
assert out_raw[0] == raw[0] and out_raw[1] == raw[1]
assert np.isnan(out_raw[2])
assert bool(np.signbit(out_raw[1])) is True
assert out_newton[0] == newton[0] and out_newton[1] == newton[1]
assert np.isnan(out_newton[2])
assert bool(np.signbit(out_newton[0])) is True
session = _session(passthrough)
_ready(session)
out = session.process(_measurement(40, (0.0, -0.0, float("nan"))))
assert out.raw[0] == 0.0 and bool(np.signbit(out.raw[1])) is True
assert np.isnan(out.raw[2])
def test_published_force_output_filter_default_behaviour_is_unchanged() -> None:
"""The old filter still re-designs on rate drift; the new one is opt-in."""
settings = _settings(median_window=1, lowpass_hz=5.0)
old = ForceOutputFilter(settings)
for frame_id in range(1, 15):
old.process(
(float(frame_id), 0.0, 0.0),
(0.0, 0.0, 0.0),
raw_valid=(True, True, True),
newton_valid=(False, False, False),
signal_time_s=frame_id * 0.05,
)
assert old.settings.sample_rate_hz == pytest.approx(20.0, rel=0.02)
assert old.settings.sample_rate_hz != settings.sample_rate_hz