f5738375fd
T08b 补齐文件/debug 完整处理与背压、源帧号和时间基、同帧 field 快照,输入结束时未完成归零明确报错。独立验收见 docs/workflow/sdk-sensorstream-implementation-plan.zh.md 第 44 轮。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1013 lines
35 KiB
Python
1013 lines
35 KiB
Python
"""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.capture import CapturedFrame
|
|
from orisys.force_stream_session import ForceStreamSession
|
|
from orisys.image_pipeline import ImageMeasurement
|
|
from orisys.pipeline import StreamPipeline
|
|
from orisys.sensor import Sensor
|
|
from orisys.sensor_stream import SensorStream, SensorStreamError
|
|
from orisys.stream_contract import FrameStamp, MeasurementFrame
|
|
|
|
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"
|
|
|
|
|
|
# ------------------------------------------------- T08b: complete file/debug mode
|
|
def test_file_mode_processes_every_input_in_source_order() -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=2, nominal_fps=30.0)
|
|
stream.start()
|
|
try:
|
|
for index in range(8):
|
|
assert stream.push_frame(
|
|
_frame(40.0 + index),
|
|
source_frame_id=index,
|
|
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
|
)
|
|
stream.end_input()
|
|
rows = stream.wait_outputs(8, timeout=10.0)
|
|
assert len(rows) == 8, "complete mode must not drop frames"
|
|
assert [row.frame.stamp.source_frame_id for row in rows] == list(range(8))
|
|
assert [row.frame.stamp.frame_id for row in rows] == list(range(8))
|
|
assert stream.overwritten_frames == 0
|
|
finally:
|
|
stream.stop()
|
|
|
|
|
|
def test_file_mode_backpressure_is_bounded_and_observable() -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=2, nominal_fps=30.0)
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
original = stream.pipeline._on_frame
|
|
|
|
def gated(snapshot) -> None:
|
|
entered.set()
|
|
assert release.wait(timeout=5.0)
|
|
original(snapshot)
|
|
|
|
stream.pipeline._on_frame = gated
|
|
stream.start()
|
|
pushed: list[int] = []
|
|
|
|
def pusher() -> None:
|
|
for index in range(30):
|
|
stream.push_frame(
|
|
_frame(40.0 + index),
|
|
source_frame_id=index,
|
|
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
|
)
|
|
pushed.append(index)
|
|
|
|
worker = threading.Thread(target=pusher)
|
|
worker.start()
|
|
try:
|
|
assert entered.wait(timeout=5.0), "the HHD stage never reached the gate"
|
|
time.sleep(0.3)
|
|
assert stream.input_queue_maxsize == 2
|
|
assert stream.input_queue_depth <= 2
|
|
assert stream.backpressure_waits > 0, (
|
|
"the producer must wait on the bounded queue"
|
|
)
|
|
assert worker.is_alive(), "a full bounded queue must make the producer wait"
|
|
release.set()
|
|
worker.join(timeout=10.0)
|
|
assert not worker.is_alive()
|
|
assert len(pushed) == 30
|
|
stream.end_input()
|
|
rows = stream.wait_outputs(30, timeout=15.0)
|
|
assert len(rows) == 30
|
|
assert [row.frame.stamp.source_frame_id for row in rows] == list(range(30))
|
|
finally:
|
|
release.set()
|
|
stream.stop()
|
|
|
|
|
|
# ------------------------------------------------------- T08b: time provenance
|
|
def test_time_bases_fake_sources_and_same_pts_frames() -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=10.0)
|
|
stream.start()
|
|
try:
|
|
frames = [
|
|
dict(
|
|
source_pts=123456, capture_time_ns=1_000_000_000, time_basis="video_pts"
|
|
),
|
|
dict(
|
|
source_pts=123456, capture_time_ns=1_000_000_000, time_basis="video_pts"
|
|
),
|
|
dict(capture_time_ns=1_100_000_000, time_basis="recorded_signal"),
|
|
dict(time_basis="estimated_source", media_time_ms=12.5),
|
|
dict(time_basis=None),
|
|
]
|
|
source_ids = [0, 1, 2, 12, 13]
|
|
for index, (source_id, timing) in enumerate(zip(source_ids, frames)):
|
|
stream.push_frame(_frame(40.0 + index), source_frame_id=source_id, **timing)
|
|
stream.end_input()
|
|
rows = stream.wait_outputs(len(frames), timeout=10.0)
|
|
assert len(rows) == len(frames)
|
|
stamps = [row.frame.stamp for row in rows]
|
|
assert [stamp.capture_time_basis for stamp in stamps] == [
|
|
"video_pts",
|
|
"video_pts",
|
|
"recorded_signal",
|
|
"estimated_source",
|
|
"estimated_source",
|
|
]
|
|
# Same PTS, different source frames: two frames, not a duplicate.
|
|
assert stamps[0].source_pts == 123456
|
|
assert stamps[1].source_pts == 123456
|
|
assert stamps[0].source_frame_id != stamps[1].source_frame_id
|
|
# Estimated source time is frame id / nominal fps; POS_MSEC is diagnostics only.
|
|
assert stamps[3].capture_time_ns == round(12 / 10.0 * 1e9)
|
|
assert stamps[4].capture_time_ns == round(13 / 10.0 * 1e9)
|
|
diag3 = dict(rows[3].diagnostics)
|
|
diag4 = dict(rows[4].diagnostics)
|
|
assert diag3["pos_msec"] == 12.5
|
|
assert "pos_msec" not in diag4
|
|
assert diag3["source_time_estimated"] == "true"
|
|
assert dict(rows[0].diagnostics)["source_time_estimated"] == "false"
|
|
finally:
|
|
stream.stop()
|
|
|
|
|
|
def _fake_builtin_sensor(frames, *, media_times=None):
|
|
sensor = _sensor()
|
|
original = sensor.get_img
|
|
index = {"next": 0}
|
|
captures = []
|
|
|
|
def fake_get_img(
|
|
frame=None, already_processed=False, valid_mask=None, already_gray=False
|
|
):
|
|
if index["next"] >= len(frames):
|
|
return None
|
|
position = index["next"]
|
|
index["next"] += 1
|
|
captured = CapturedFrame(
|
|
frames[position],
|
|
position + 1,
|
|
1_000_000_000 + position * 33_000_000,
|
|
2_000_000_000 + position * 33_000_000,
|
|
None if media_times is None else media_times[position],
|
|
)
|
|
captures.append(captured)
|
|
sensor._last_captured_frame = captured
|
|
return original(
|
|
frame=frames[position], already_processed=True, already_gray=True
|
|
)
|
|
|
|
sensor.get_img = fake_get_img
|
|
return sensor, captures
|
|
|
|
|
|
def test_builtin_capture_uses_captured_frame_host_clocks_and_sequence() -> None:
|
|
frames = [_frame(40.0 + index) for index in range(5)]
|
|
sensor, captures = _fake_builtin_sensor(frames)
|
|
stream = _stream(sensor, realtime=True, external_input=False)
|
|
stream.start()
|
|
try:
|
|
rows = stream.wait_outputs(5, timeout=10.0)
|
|
assert len(rows) == 5
|
|
for row, captured in zip(rows, captures):
|
|
stamp = row.frame.stamp
|
|
assert stamp.capture_time_basis == "capture_monotonic"
|
|
assert stamp.capture_time_ns == captured.host_mono_ns
|
|
assert stamp.source_frame_id == captured.sequence
|
|
finally:
|
|
stream.stop()
|
|
|
|
|
|
def test_builtin_file_mode_estimates_time_and_reports_pos_msec() -> None:
|
|
frames = [_frame(40.0 + index) for index in range(5)]
|
|
media = [0.0, 33.0, 66.0, 99.0, 132.0]
|
|
sensor, captures = _fake_builtin_sensor(frames, media_times=media)
|
|
stream = _stream(
|
|
sensor,
|
|
realtime=False,
|
|
nominal_fps=30.0,
|
|
external_input=False,
|
|
)
|
|
stream.start()
|
|
try:
|
|
rows = stream.wait_outputs(5, timeout=10.0)
|
|
assert len(rows) == 5
|
|
for row, captured in zip(rows, captures):
|
|
stamp = row.frame.stamp
|
|
assert stamp.capture_time_basis == "estimated_source"
|
|
assert stamp.source_pts is None
|
|
assert stamp.source_frame_id == captured.sequence
|
|
assert stamp.capture_time_ns == round(captured.sequence / 30.0 * 1e9)
|
|
assert dict(row.diagnostics)["pos_msec"] == captured.media_time_ms
|
|
finally:
|
|
stream.stop()
|
|
|
|
|
|
# ------------------------------------------------- T08b: optional field include
|
|
def _field_equal(left, right) -> None:
|
|
assert left is not None and right is not None
|
|
assert set(left) == set(right)
|
|
for key in left:
|
|
a = left[key]
|
|
b = right[key]
|
|
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
|
|
assert np.array_equal(np.asarray(a), np.asarray(b), equal_nan=True), key
|
|
else:
|
|
assert a == b, key
|
|
|
|
|
|
def _run_pipeline_capture(include):
|
|
sensor = _sensor()
|
|
frames = [
|
|
np.tile((np.arange(SIZE, dtype=np.uint8) + index * 3)[None, :], (SIZE, 1))
|
|
for index in range(6)
|
|
]
|
|
index = {"next": 0}
|
|
records = []
|
|
|
|
def produce():
|
|
if index["next"] >= len(frames):
|
|
return None
|
|
position = index["next"]
|
|
index["next"] += 1
|
|
return sensor.get_img(
|
|
frame=frames[position], already_processed=True, already_gray=True
|
|
)
|
|
|
|
def on_frame(snapshot) -> None:
|
|
expected = sensor.current_field()
|
|
field_copy = snapshot.field
|
|
records.append((snapshot, field_copy, expected))
|
|
|
|
pipeline = StreamPipeline(sensor, produce, include=include, on_frame=on_frame)
|
|
pipeline.start()
|
|
deadline = time.perf_counter() + 10.0
|
|
while len(records) < len(frames) and time.perf_counter() < deadline:
|
|
time.sleep(0.01)
|
|
pipeline.stop(timeout=5.0)
|
|
return records
|
|
|
|
|
|
def test_pipeline_field_include_is_opt_in_and_same_frame() -> None:
|
|
default_records = _run_pipeline_capture(())
|
|
assert len(default_records) == 6
|
|
assert all(snapshot.field is None for snapshot, _, _ in default_records)
|
|
|
|
field_records = _run_pipeline_capture(("field",))
|
|
assert len(field_records) == 6
|
|
first_copy = None
|
|
for snapshot, recorded_field, expected in field_records:
|
|
assert snapshot.field is not None
|
|
_field_equal(snapshot.field, expected)
|
|
if first_copy is None:
|
|
first_copy = {
|
|
key: value.copy() if isinstance(value, np.ndarray) else value
|
|
for key, value in snapshot.field.items()
|
|
}
|
|
# Later frames must not rewrite the field payload published with frame 0.
|
|
_field_equal(field_records[0][1], first_copy)
|
|
|
|
|
|
# ------------------------------------------------- T08b: end-to-end and in-flight
|
|
def _feed_complete(stream, frames, *, start=0, sleep_s=0.002, stop=None):
|
|
def feeder() -> None:
|
|
for source_id in range(start, len(frames)):
|
|
if stop is not None and stop.is_set():
|
|
return
|
|
stream.push_frame(
|
|
frames[source_id],
|
|
source_frame_id=source_id,
|
|
capture_time_ns=1_000_000_000 + source_id * 33_333_333,
|
|
)
|
|
time.sleep(sleep_s)
|
|
|
|
thread = threading.Thread(target=feeder)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
def _wait_until(predicate, timeout: float = 20.0) -> None:
|
|
"""Wait for a state condition; never used to tune frame ordering by delay."""
|
|
deadline = time.perf_counter() + timeout
|
|
while time.perf_counter() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(0.005)
|
|
raise AssertionError("condition was not reached before the timeout")
|
|
|
|
|
|
def test_end_to_end_matches_manual_sensor_image_pipeline_session() -> None:
|
|
count = 90
|
|
initial = 10
|
|
frames = [
|
|
np.tile(
|
|
((np.arange(SIZE, dtype=np.uint16) + index * 3) % 256).astype(np.uint8)[
|
|
None, :
|
|
],
|
|
(SIZE, 1),
|
|
)
|
|
for index in range(count)
|
|
]
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
|
stream.start()
|
|
stop = threading.Event()
|
|
pressed_initial = threading.Event()
|
|
|
|
def feeder() -> None:
|
|
for source_id in range(count):
|
|
if stop.is_set():
|
|
return
|
|
stream.push_frame(
|
|
frames[source_id],
|
|
source_frame_id=source_id,
|
|
capture_time_ns=1_000_000_000 + source_id * 33_333_333,
|
|
)
|
|
if source_id + 1 == initial:
|
|
pressed_initial.set()
|
|
pressed_initial.set()
|
|
|
|
feeder_thread = threading.Thread(target=feeder)
|
|
feeder_thread.start()
|
|
try:
|
|
assert pressed_initial.wait(timeout=20.0)
|
|
# The reset is requested only after every initial frame was published, so
|
|
# the number of new-version frames left for the baseline is deterministic.
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= initial)
|
|
zero = stream.reset_and_zero(reference_id="eq", timeout=20.0)
|
|
assert zero.phase == "ready"
|
|
feeder_thread.join(timeout=20.0)
|
|
assert not feeder_thread.is_alive()
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= count)
|
|
rows = stream.drain_outputs()
|
|
finally:
|
|
stop.set()
|
|
stream.stop()
|
|
assert len(rows) == count
|
|
|
|
first_version = next(
|
|
row.reference_generation for row in rows if row.reference_generation > 0
|
|
)
|
|
first_source_id = next(
|
|
row.frame.stamp.source_frame_id
|
|
for row in rows
|
|
if row.reference_generation == first_version
|
|
)
|
|
|
|
manual = _sensor()
|
|
session = ForceStreamSession(
|
|
sensor_family="box",
|
|
calibration=manual.calibration,
|
|
device_id=None,
|
|
)
|
|
session.request_zero(
|
|
reference_id="eq",
|
|
declared_unloaded=True,
|
|
expected_reference_generation=first_version,
|
|
)
|
|
manual_outputs = {}
|
|
for source_id in range(first_source_id, count):
|
|
manual.get_img(
|
|
frame=frames[source_id], already_processed=True, already_gray=True
|
|
)
|
|
manual.compute_flow()
|
|
manual.compute_hhd()
|
|
stamp = FrameStamp(
|
|
sensor_id="BOX-1",
|
|
session_id="run-1",
|
|
frame_id=source_id,
|
|
source_frame_id=source_id,
|
|
capture_time_ns=1_000_000_000 + source_id * 33_333_333,
|
|
clock_id="host",
|
|
capture_time_basis="capture_monotonic",
|
|
)
|
|
measurement = MeasurementFrame(
|
|
stamp=stamp, source_dt_s=None, reference_generation=first_version
|
|
)
|
|
manual_outputs[source_id] = session.process(
|
|
ImageMeasurement(
|
|
frame=measurement,
|
|
force_raw=(
|
|
float(manual._force_raw[0]),
|
|
float(manual._force_raw[1]),
|
|
float(manual._force_raw[2]),
|
|
),
|
|
torque=float(manual.ftor),
|
|
flow=None,
|
|
)
|
|
)
|
|
compared = 0
|
|
for row in rows:
|
|
if row.reference_generation != first_version or row.zero.phase != "ready":
|
|
continue
|
|
expected = manual_outputs.get(row.frame.stamp.source_frame_id)
|
|
if expected is None:
|
|
continue
|
|
compared += 1
|
|
assert np.array_equal(
|
|
np.asarray(row.raw), np.asarray(expected.raw), equal_nan=True
|
|
)
|
|
assert np.array_equal(
|
|
np.asarray(row.newton), np.asarray(expected.newton), equal_nan=True
|
|
)
|
|
assert row.raw_status == expected.raw_status
|
|
assert row.newton_status == expected.newton_status
|
|
assert compared >= 5, "facade and manual chain were not compared frame by frame"
|
|
|
|
|
|
def test_inflight_old_version_frames_do_not_enter_the_new_baseline() -> None:
|
|
initial = 5
|
|
count = 50
|
|
frames = [
|
|
np.tile((np.arange(SIZE, dtype=np.uint8) + index * 2)[None, :], (SIZE, 1))
|
|
for index in range(count)
|
|
]
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
|
stream.start()
|
|
stop = threading.Event()
|
|
pressed_initial = threading.Event()
|
|
|
|
def feeder() -> None:
|
|
for source_id in range(count):
|
|
if stop.is_set():
|
|
return
|
|
stream.push_frame(
|
|
frames[source_id],
|
|
source_frame_id=source_id,
|
|
capture_time_ns=1_000_000_000 + source_id * 33_000_000,
|
|
)
|
|
if source_id + 1 == initial:
|
|
pressed_initial.set()
|
|
|
|
feeder_thread = threading.Thread(target=feeder)
|
|
feeder_thread.start()
|
|
try:
|
|
assert pressed_initial.wait(timeout=20.0)
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= initial)
|
|
zero = stream.reset_and_zero(reference_id="v1", timeout=20.0)
|
|
feeder_thread.join(timeout=20.0)
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= count)
|
|
rows = stream.drain_outputs()
|
|
finally:
|
|
stop.set()
|
|
stream.stop()
|
|
assert zero.phase == "ready"
|
|
assert zero.collected_frames == 30
|
|
old_rows = [row for row in rows if row.reference_generation == 0]
|
|
version_one = [row for row in rows if row.reference_generation == 1]
|
|
assert old_rows, "the test needs in-flight old-version frames"
|
|
assert version_one, "no new-version frame was processed"
|
|
assert all(row.zero.phase != "ready" for row in old_rows)
|
|
assert len(version_one) >= 31
|
|
assert all(row.zero.phase != "ready" for row in version_one[:30])
|
|
assert version_one[30].zero.phase == "ready"
|
|
|
|
|
|
def test_stage_error_is_visible_to_the_caller(monkeypatch) -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor)
|
|
stream.start()
|
|
try:
|
|
|
|
def fail(_measurement):
|
|
raise RuntimeError("force stage failure")
|
|
|
|
monkeypatch.setattr(stream.session, "process", fail)
|
|
stream.push_frame(_frame(), source_frame_id=0)
|
|
time.sleep(0.2)
|
|
with pytest.raises(SensorStreamError, match="force stage failure"):
|
|
stream.stop()
|
|
finally:
|
|
if stream.running:
|
|
stream.stop()
|
|
|
|
|
|
# --------------------------------------------- T08b: duplicate/late and diagnostics
|
|
def test_duplicate_and_late_inputs_are_counted_not_fed() -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
|
stream.start()
|
|
try:
|
|
assert stream.push_frame(
|
|
_frame(40.0), source_frame_id=0, capture_time_ns=1_000_000_000
|
|
)
|
|
assert not stream.push_frame(
|
|
_frame(41.0), source_frame_id=0, capture_time_ns=1_100_000_000
|
|
)
|
|
assert stream.push_frame(
|
|
_frame(42.0), source_frame_id=5, capture_time_ns=1_200_000_000
|
|
)
|
|
assert not stream.push_frame(
|
|
_frame(43.0), source_frame_id=3, capture_time_ns=1_300_000_000
|
|
)
|
|
assert stream.duplicate_inputs == 1
|
|
assert stream.late_inputs == 1
|
|
stream.end_input()
|
|
rows = stream.wait_outputs(2, timeout=10.0)
|
|
assert [row.frame.stamp.source_frame_id for row in rows] == [0, 5]
|
|
finally:
|
|
stream.stop()
|
|
|
|
|
|
def test_source_dt_and_long_gap_diagnostics() -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0, long_gap_s=0.5)
|
|
stream.start()
|
|
try:
|
|
times = [1_000_000_000, 2_000_000_000, 2_033_000_000]
|
|
for index, when in enumerate(times):
|
|
stream.push_frame(
|
|
_frame(40.0 + index), source_frame_id=index, capture_time_ns=when
|
|
)
|
|
stream.end_input()
|
|
rows = stream.wait_outputs(3, timeout=10.0)
|
|
assert dict(rows[0].diagnostics)["long_gap"] == "false"
|
|
assert dict(rows[1].diagnostics)["long_gap"] == "true"
|
|
assert dict(rows[1].diagnostics)["source_dt_s"] == 1.0
|
|
assert dict(rows[2].diagnostics)["long_gap"] == "false"
|
|
assert dict(rows[2].diagnostics)["source_dt_s"] == 0.033
|
|
assert stream.diagnostics()["last_long_gap"] is False
|
|
finally:
|
|
stream.stop()
|
|
|
|
|
|
def test_duplicate_after_ready_does_not_invalidate_the_zero() -> None:
|
|
initial = 40
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
|
stream.start()
|
|
stop = threading.Event()
|
|
pressed_initial = threading.Event()
|
|
|
|
def frame(source_id: int) -> np.ndarray:
|
|
return np.tile(
|
|
(np.arange(SIZE, dtype=np.uint8) + source_id * 2)[None, :], (SIZE, 1)
|
|
)
|
|
|
|
def feeder() -> None:
|
|
source_id = 0
|
|
while not stop.is_set():
|
|
stream.push_frame(
|
|
frame(source_id),
|
|
source_frame_id=source_id,
|
|
capture_time_ns=1_000_000_000 + source_id * 33_000_000,
|
|
)
|
|
source_id += 1
|
|
if source_id >= initial:
|
|
pressed_initial.set()
|
|
|
|
feeder_thread = threading.Thread(target=feeder)
|
|
feeder_thread.start()
|
|
try:
|
|
assert pressed_initial.wait(timeout=20.0)
|
|
# Every initial source id is published before the reset request, exactly
|
|
# the condition the reviewer's probe used to force the old flake.
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= initial)
|
|
zero = stream.reset_and_zero(reference_id="dup-zero", timeout=20.0)
|
|
stop.set()
|
|
feeder_thread.join(timeout=10.0)
|
|
assert not feeder_thread.is_alive()
|
|
assert (
|
|
stream.push_frame(
|
|
frame(0), source_frame_id=0, capture_time_ns=1_000_000_000
|
|
)
|
|
is False
|
|
)
|
|
assert stream.duplicate_inputs >= 1
|
|
assert stream.session.zero.phase == "ready"
|
|
assert stream.session.zero.reference_id == zero.reference_id
|
|
finally:
|
|
stop.set()
|
|
stream.stop()
|
|
|
|
|
|
def test_input_end_fails_an_incomplete_reset_and_zero() -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
|
stream.start()
|
|
for source_id in range(5):
|
|
stream.push_frame(
|
|
_frame(40.0 + source_id),
|
|
source_frame_id=source_id,
|
|
capture_time_ns=1_000_000_000 + source_id * 33_000_000,
|
|
)
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= 5)
|
|
outcome: dict[str, BaseException] = {}
|
|
|
|
def run_zero() -> None:
|
|
try:
|
|
stream.reset_and_zero(reference_id="short-input", timeout=10.0)
|
|
except BaseException as exc: # asserted below
|
|
outcome["error"] = exc
|
|
|
|
worker = threading.Thread(target=run_zero)
|
|
worker.start()
|
|
try:
|
|
_wait_until(
|
|
lambda: stream._pending_zero is not None or stream._active_zero is not None
|
|
)
|
|
stream.end_input()
|
|
worker.join(timeout=5.0)
|
|
assert not worker.is_alive(), "input end must complete the zero command"
|
|
error = outcome.get("error")
|
|
assert isinstance(error, SensorStreamError)
|
|
assert "input ended" in str(error)
|
|
finally:
|
|
if worker.is_alive():
|
|
stream.stop()
|
|
else:
|
|
stream.stop()
|
|
|
|
|
|
@pytest.mark.parametrize("extra", [31, 33, 36])
|
|
def test_input_end_after_enough_frames_still_completes_zero(extra: int) -> None:
|
|
sensor = _sensor()
|
|
stream = _stream(sensor, realtime=False, depth=4, nominal_fps=30.0)
|
|
stream.start()
|
|
|
|
def frame(index: int) -> np.ndarray:
|
|
return np.tile(
|
|
(np.arange(SIZE, dtype=np.uint8) + index * 2)[None, :], (SIZE, 1)
|
|
)
|
|
|
|
for index in range(5):
|
|
stream.push_frame(
|
|
frame(index),
|
|
source_frame_id=index,
|
|
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
|
)
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= 5)
|
|
outcome: dict[str, object] = {}
|
|
|
|
def run_zero() -> None:
|
|
try:
|
|
outcome["progress"] = stream.reset_and_zero(
|
|
reference_id="tight", timeout=20.0
|
|
)
|
|
except BaseException as exc: # asserted below
|
|
outcome["error"] = exc
|
|
|
|
worker = threading.Thread(target=run_zero)
|
|
worker.start()
|
|
try:
|
|
_wait_until(
|
|
lambda: stream._pending_zero is not None or stream._active_zero is not None
|
|
)
|
|
for index in range(5, 5 + extra):
|
|
stream.push_frame(
|
|
frame(index),
|
|
source_frame_id=index,
|
|
capture_time_ns=1_000_000_000 + index * 33_000_000,
|
|
)
|
|
stream.end_input()
|
|
worker.join(timeout=25.0)
|
|
assert not worker.is_alive()
|
|
assert "error" not in outcome, outcome.get("error")
|
|
progress = outcome.get("progress")
|
|
assert getattr(progress, "phase", None) == "ready"
|
|
assert stream.session.zero.phase == "ready"
|
|
assert stream.session.zero.reference_id == "tight"
|
|
finally:
|
|
if worker.is_alive():
|
|
stream.end_input()
|
|
stream.stop()
|
|
|
|
|
|
def test_builtin_input_end_after_enough_frames_completes_zero() -> None:
|
|
prepared = [
|
|
np.tile((np.arange(SIZE, dtype=np.uint8) + index * 2)[None, :], (SIZE, 1))
|
|
for index in range(5 + 31)
|
|
]
|
|
sensor = _sensor()
|
|
original = sensor.get_img
|
|
state = {"next": 0}
|
|
gate = threading.Event()
|
|
|
|
def fake_get_img(
|
|
frame=None, already_processed=False, valid_mask=None, already_gray=False
|
|
):
|
|
position = state["next"]
|
|
if position == 5:
|
|
assert gate.wait(timeout=20.0)
|
|
if position >= len(prepared):
|
|
return None
|
|
state["next"] += 1
|
|
captured = CapturedFrame(
|
|
prepared[position],
|
|
position + 1,
|
|
1_000_000_000 + position * 33_000_000,
|
|
2_000_000_000 + position * 33_000_000,
|
|
None,
|
|
)
|
|
sensor._last_captured_frame = captured
|
|
return original(
|
|
frame=prepared[position], already_processed=True, already_gray=True
|
|
)
|
|
|
|
sensor.get_img = fake_get_img
|
|
stream = _stream(sensor, realtime=False, nominal_fps=30.0, external_input=False)
|
|
stream.start()
|
|
outcome: dict[str, object] = {}
|
|
|
|
def run_zero() -> None:
|
|
try:
|
|
outcome["progress"] = stream.reset_and_zero(
|
|
reference_id="builtin-tight", timeout=20.0
|
|
)
|
|
except BaseException as exc: # asserted below
|
|
outcome["error"] = exc
|
|
|
|
try:
|
|
_wait_until(lambda: stream.diagnostics()["published"] >= 5)
|
|
worker = threading.Thread(target=run_zero)
|
|
worker.start()
|
|
_wait_until(
|
|
lambda: stream._pending_zero is not None or stream._active_zero is not None
|
|
)
|
|
gate.set()
|
|
worker.join(timeout=20.0)
|
|
assert not worker.is_alive()
|
|
assert "error" not in outcome, outcome.get("error")
|
|
assert getattr(outcome.get("progress"), "phase", None) == "ready"
|
|
assert stream.session.zero.phase == "ready"
|
|
finally:
|
|
gate.set()
|
|
stream.stop()
|