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:
@@ -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"
|
||||
Reference in New Issue
Block a user