"""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()