feat: 新增单路流入口 SensorStream(T08a)

SensorStream 串起 Sensor/SensorContext、ImagePipeline 与 ForceStreamSession:实时模式只保留最新候选并计数覆盖;StreamPipeline 负责线程与原子复位版本,力侧只在 HHD 线程的 on_frame 中运行;reset_and_zero 原子领取目标版本,在力线程帧边界发起同版本空载归零,每次调用只等待自己的命令结果;借用的 Sensor 不被关闭,stop 后恢复原来的 measurement session 状态,拥有的 Sensor 在 stop 时关闭。回归 402 项、lint 通过。
This commit is contained in:
ykcheungab
2026-10-21 17:52:24 +08:00
parent 26382b359f
commit d31503330d
3 changed files with 889 additions and 4 deletions
+283
View File
@@ -0,0 +1,283 @@
"""T08a: single-stream entry — real-time latest, ownership, combined zero.
The facade reuses StreamPipeline for threading/version resets and the accepted
ForceStreamSession for force processing. These tests cover the T08a scope only:
real-time candidate overwrite semantics, borrowed vs owned source lifetime, and
the combined "image reset → same-version unloaded baseline → force ready" entry.
File/debug complete processing and source-time provenance are T08b.
"""
from __future__ import annotations
import threading
import time
import numpy as np
import pytest
from orisys.sensor import Sensor
from orisys.sensor_stream import SensorStream, SensorStreamError
SIZE = 365
def _sensor() -> Sensor:
return Sensor(
"external",
config_name="box",
verbose=False,
backend="cpu",
cuda=False,
mirror=False,
)
def _frame(value: float = 40.0) -> np.ndarray:
return np.full((SIZE, SIZE), value, dtype=np.uint8)
def _stream(sensor: Sensor, **kwargs) -> SensorStream:
options = {"sensor_id": "BOX-1", "session_id": "run-1"}
options.update(kwargs)
return SensorStream(sensor, **options)
def test_realtime_candidate_is_latest_and_overwrites_are_counted() -> None:
sensor = _sensor()
stream = _stream(sensor)
try:
for index in range(10):
stream.push_frame(
_frame(40.0 + index),
source_frame_id=index,
capture_time_ns=1_000_000_000 + index * 33_000_000,
)
assert stream.overwritten_frames == 9, "unclaimed candidates are replaced"
stream.start()
output = stream.wait_output(timeout=5.0)
assert output is not None
# The producer claimed the newest candidate, not the first pushed frame.
assert output.frame.stamp.source_frame_id == 9
assert output.frame.stamp.frame_id == 0, "output frame ids stay contiguous"
assert output.frame.reference_generation == 0
finally:
stream.stop()
def test_borrowed_sensor_is_not_closed_by_stop() -> None:
sensor = _sensor()
disconnected = []
original = sensor.disconnect
def tracked_disconnect() -> None:
disconnected.append(True)
original()
sensor.disconnect = tracked_disconnect # type: ignore[method-assign]
stream = _stream(sensor)
stream.start()
stream.push_frame(_frame(), source_frame_id=0)
assert stream.wait_output(timeout=5.0) is not None
stream.stop()
assert disconnected == [], "a borrowed source belongs to the caller"
assert stream.running is False
def test_owned_sensor_is_closed_by_stop_and_threads_finish() -> None:
sensor = _sensor()
disconnected = []
original = sensor.disconnect
def tracked_disconnect() -> None:
disconnected.append(True)
original()
sensor.disconnect = tracked_disconnect # type: ignore[method-assign]
stream = _stream(sensor, owns_sensor=True)
stream.start()
stream.push_frame(_frame(), source_frame_id=0)
assert stream.wait_output(timeout=5.0) is not None
threads = list(stream.pipeline._threads)
stream.stop()
assert disconnected == [True], "the open() owner closes its own Sensor"
assert all(not thread.is_alive() for thread in threads)
def test_reset_and_zero_combines_image_reset_and_same_version_baseline() -> None:
sensor = _sensor()
stream = _stream(sensor)
stream.start()
stop = threading.Event()
def feeder() -> None:
frame_id = 0
while not stop.is_set() and frame_id < 400:
stream.push_frame(
_frame(40.0 + (frame_id % 20)),
source_frame_id=frame_id,
capture_time_ns=1_000_000_000 + frame_id * 33_000_000,
)
frame_id += 1
time.sleep(0.001)
feeder_thread = threading.Thread(target=feeder)
feeder_thread.start()
try:
time.sleep(0.1) # let some pre-reset frames flow
zero = stream.reset_and_zero(reference_id="zero-1", timeout=20.0)
assert zero.phase == "ready"
assert zero.collected_frames == 30
output = stream.wait_output(timeout=5.0)
assert output is not None
assert output.zero.phase == "ready"
assert output.reference_generation == stream.pipeline.tracker_reset_seq
assert output.frame.stamp.session_id == "run-1"
assert all(np.isfinite(value) or np.isnan(value) for value in output.newton)
finally:
stop.set()
feeder_thread.join(timeout=5.0)
stream.stop()
def test_reset_and_zero_requires_a_running_stream() -> None:
sensor = _sensor()
stream = _stream(sensor)
with pytest.raises(SensorStreamError, match="start the stream"):
stream.reset_and_zero(reference_id="zero-1")
def test_frames_already_started_are_not_interrupted_by_newer_input() -> None:
sensor = _sensor()
stream = _stream(sensor)
try:
stream.start()
stream.push_frame(
_frame(50.0), source_frame_id=0, capture_time_ns=1_000_000_000
)
first = stream.wait_output(timeout=5.0)
assert first is not None and first.frame.stamp.source_frame_id == 0
stream.push_frame(
_frame(80.0), source_frame_id=1, capture_time_ns=1_033_000_000
)
second = stream.wait_output(timeout=5.0)
assert second is not None and second.frame.stamp.source_frame_id == 1
assert second.frame.stamp.frame_id == first.frame.stamp.frame_id + 1
# Both finished frames are present; a claimed frame was not replaced.
assert second.frame.stamp.frame_id == 1
finally:
stream.stop()
# --------------------------------------------- Z1: one command, its own result
def _start_feeder(stream: SensorStream, stop: threading.Event) -> threading.Thread:
def feeder() -> None:
frame_id = 0
while not stop.is_set():
stream.push_frame(
_frame(40.0 + (frame_id % 20)),
source_frame_id=frame_id,
capture_time_ns=1_000_000_000 + frame_id * 33_000_000,
)
frame_id += 1
time.sleep(0.002)
thread = threading.Thread(target=feeder, daemon=True)
thread.start()
return thread
def test_two_reset_and_zero_calls_each_return_their_own_result() -> None:
sensor = _sensor()
stream = _stream(sensor)
stream.start()
stop = threading.Event()
feeder = _start_feeder(stream, stop)
try:
first = stream.reset_and_zero(reference_id="first", timeout=20.0)
assert first.phase == "ready" and first.reference_id == "first"
first_version = stream.session.reference_generation
second = stream.reset_and_zero(reference_id="second", timeout=20.0)
assert second.phase == "ready", "the second call must wait for its own zero"
assert second.reference_id == "second"
assert stream.session.zero.reference_id == "second"
assert stream.session.reference_generation > first_version
# A frame published after the second command carries its target version.
stream.push_frame(
_frame(70.0), source_frame_id=10_000, capture_time_ns=9_000_000_000
)
output = stream.wait_output(timeout=5.0)
assert output is not None
assert output.reference_generation == stream.session.reference_generation
assert output.zero.phase == "ready"
assert output.zero.reference_id == "second"
finally:
stop.set()
feeder.join(timeout=5.0)
stream.stop()
def test_second_reset_and_zero_succeeds_after_a_failed_first() -> None:
sensor = _sensor()
stream = _stream(sensor)
stream.start()
stop = threading.Event()
feeder = _start_feeder(stream, stop)
try:
with pytest.raises(SensorStreamError):
stream.reset_and_zero(reference_id="bad", timeout=10.0, max_window_s=0.001)
second = stream.reset_and_zero(reference_id="second", timeout=20.0)
assert second.phase == "ready"
assert second.reference_id == "second"
finally:
stop.set()
feeder.join(timeout=5.0)
stream.stop()
# ------------------------------------- Z2: borrowed Sensor session mode restored
def test_borrowed_sensor_measurement_session_is_restored_on_stop() -> None:
sensor = _sensor()
stream = _stream(sensor)
try:
assert sensor._measurement_session is False
stream.start()
assert sensor._measurement_session is True, (
"the stream enables the session seam"
)
stream.stop()
assert sensor._measurement_session is False, (
"borrowed Sensor mode must be restored"
)
finally:
if stream.running:
stream.stop()
def test_borrowed_sensor_already_in_session_mode_stays_enabled() -> None:
sensor = _sensor()
sensor.enable_measurement_session()
stream = _stream(sensor)
try:
stream.start()
stream.stop()
assert sensor._measurement_session is True, "the pre-stream mode is preserved"
finally:
sensor.disable_measurement_session()
if stream.running:
stream.stop()
def test_failed_start_restores_the_borrowed_sensor_session(monkeypatch) -> None:
sensor = _sensor()
stream = _stream(sensor)
assert sensor._measurement_session is False
def broken_start() -> None:
raise RuntimeError("pipeline start failed")
monkeypatch.setattr(stream.pipeline, "start", broken_start)
with pytest.raises(RuntimeError, match="pipeline start failed"):
stream.start()
assert sensor._measurement_session is False, "a failed start must restore the mode"