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
+50 -4
View File
@@ -1,4 +1,4 @@
# 新力侧会话(T06a/T06b、T07 输出滤波)
# 新力侧会话与单路入口(T06a/T06b、T07 输出滤波、T08a)
本文件是单路 SensorStream 计划 T06a/T06b/T07 的 SDK 合同:新的力侧会话直接消费 T05 的
`ImageMeasurement`(T03 `MeasurementFrame` 头 + 同帧 raw/field),参考版本取帧上的
@@ -73,6 +73,52 @@ assert session.output_settings.median_window == 3, "switch waits for a frame bou
assert session.design_sample_rate_hz == 30.0
```
## SensorStream 入口(T08a)
`SensorStream` 把 T04–T07 串成一个运行中的单路入口:
- **所有权。** `SensorStream.open(source, ...)` 自己打开 Sensor 并在 `stop()` 关闭;调用方传入
已打开的 Sensor 或外部喂帧时只借用,`stop()` 不关闭借用源。运行中只有一个采集所有者;
流在 `start()` 启用 measurement session,`stop()`/启动失败时恢复借用 Sensor 原来的模式
(原本已启用的保持启用)。
- **实时 latest。** `push_frame(...)` 的未领取候选帧会被最新输入覆盖并计入
`overwritten_frames`;已经交给流水线的帧照常完成。`wait_output()` 是 latest-wins 结果流,
`drain_events()` 取会话事件。
- **线程/版本。** 复用 `StreamPipeline` 的线程与原子 `request_reset()` 版本;力侧只在 HHD
线程的 `on_frame` 中调用 `ForceStreamSession`,因此 T07 的 `drain_events` 线程约束由门面满足,
没有第二套版本或复位记账。
- **组合归零。** `reset_and_zero(reference_id=...)` 执行“图像复位确认 → 同版本 30 帧无载基线 →
力 ready”;用户不管理版本号。在途旧版本帧不会进入新基线(T06a 的规则)。每次调用只等待
本条命令的结果:力线程把该命令的 `ZeroProgress`/错误写回命令对象并通知,调用方线程不读取
`session.zero`,因此连续两次调用时第二次不会复用第一次的零点。
- **T08a 边界。** 实时模式、无标定/标量会话、逐帧结果最新视图已实现;field/contact 的
`include`、文件/debug 完整逐帧、有界背压、时间来源与 PTS 标注、`source_dt_s`/长间隔诊断和
完整生命周期诊断属于 **T08b**。
- **时间基准(T08b 实现约定)。** 实时相机 `capture_monotonic`;Orisys 自己录像(文件内保存
信号时间)`recorded_signal`;外部调用方提供可证明的容器 PTS 才 `video_pts`;内置 OpenCV
读取普通视频文件用 `estimated_source`,`capture_time_ns` 按 `source_frame_id ÷ 名义帧率` 推算,
POS_MSEC 只进 diagnostics。T08a 的外部帧默认 `capture_monotonic`。
```python
import numpy as np
from orisys.sensor import Sensor
from orisys.sensor_stream import SensorStream
sensor = Sensor("external", config_name="box", verbose=False, backend="cpu")
stream = SensorStream(sensor, sensor_id="BOX-001", session_id="run-1")
stream.start()
try:
stream.push_frame(
np.full((365, 365), 100, dtype=np.uint8),
source_frame_id=0,
capture_time_ns=1_000_000_000,
)
output = stream.wait_output(timeout=5.0)
assert output is not None and output.frame.stamp.sensor_id == "BOX-001"
finally:
stream.stop()
```
## 示例
```python
@@ -98,8 +144,8 @@ assert row.zero.phase in ("resetting", "collecting")
## 后续
T06b 已交付标量/field/FN-only 三类标定与旧 ForceSession 的完整数值等价;T07 已交付固定
设计帧率、显式切换事件与历史/直通合同。spatial_gain/接触映射模型不在 T06/T07 验收范围,
需要时另立任务。
T08 接单路流入口;T09/T10 迁移 App 后删除旧 `ForceSession`。
设计帧率、显式切换事件与历史/直通合同;T08a 已交付 SensorStream 实时入口、所有权与组合归零。
spatial_gain/接触映射模型不在 T06/T07 验收范围,需要时另立任务。
T08b 补齐文件/debug 完整逐帧、有界背压、时间来源/PTS 标注与完整诊断后再进入 T09/T10(App 迁移)。
另一项后续任务:删除旧 `ForceSession` 之前,先把零点、标定、读数引擎从旧类中抽出来,
避免新会话依赖待删除的类型(T09 之前)。
+556
View File
@@ -0,0 +1,556 @@
"""Single-stream entry point: SensorStream (T08a).
``SensorStream`` ties the accepted T04–T07 blocks into one place:
* one capture owner per running stream: either it creates the ``Sensor``
(:meth:`SensorStream.open`, closes it on stop) or the caller lends an opened
Sensor/feeds prepared frames (:meth:`push_frame`, the frame source is the
caller's and stays open);
* ``StreamPipeline`` remains the threading/reset engine (bounded producer queue,
atomic ``request_reset`` version, flow/hhd ownership), so no second version or
reset bookkeeping is introduced;
* the HHD stage's ``on_frame`` callback is the single force-side thread. It
builds the T03 ``MeasurementFrame`` for the finished snapshot and calls
``ForceStreamSession.process`` exactly once per published snapshot. The
facade also applies queued ``reset_and_zero`` / output-filter commands there,
so the force session is never touched from a consumer thread;
* real-time mode keeps only the newest candidate input (a new push overwrites an
unclaimed candidate and counts the overwrite); a frame already handed to the
pipeline finishes normally;
* ``reset_and_zero`` combines the image reset (via ``StreamPipeline.request_reset``
atomic version) with the unloaded-zero request (via the force thread), and
returns once the session reports ready. Users do not manage version numbers.
T08a deliberately stops at real-time mode, a single force output stream and the
combined zero. File/debug complete processing, bounded backpressure, source-time
provenance (real PTS vs estimated source time) and the full lifecycle diagnostics
are the T08b scope. Field/contact calibration inputs are not wired yet either
(``include="field"`` is T08b); the current facade covers uncalibrated and scalar
sessions.
One instance belongs to one running stream; one thread owns the force session.
"""
from __future__ import annotations
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from .force_readout import OutputSettings
from .force_session import ZeroProgress
from .force_stream_session import ForceStreamOutput, ForceStreamSession
from .pipeline import ProducedFrame, StreamPipeline
from .sensor import Sensor
from .stream_contract import FrameStamp, MeasurementFrame, StreamEvent
__all__ = ["SensorStream", "SensorStreamError"]
#: How long reset_and_zero waits for the force session to report ready.
DEFAULT_ZERO_TIMEOUT_S = 30.0
class SensorStreamError(RuntimeError):
"""A stream stage, command or zero operation failed; the caller sees it."""
@dataclass(frozen=True)
class _QueuedFrame:
image: np.ndarray
source_frame_id: int
capture_time_ns: int
time_basis: str
clock_id: str
source_pts: int | float | None = None
@dataclass
class _ZeroCommand:
reference_id: str
declared_unloaded: bool
max_gap_s: float | None
max_window_s: float | None
target_reference_generation: int
done: threading.Event = field(default_factory=threading.Event)
result: ZeroProgress | None = None
error: BaseException | None = None
class SensorStream:
"""One single-sensor stream: capture → ImagePipeline → ForceStreamSession."""
def __init__(
self,
sensor: Sensor,
*,
sensor_family: str | None = None,
calibration=None,
context=None,
settings: OutputSettings | None = None,
design_sample_rate_hz: float | None = None,
sensor_id: str | None = None,
session_id: str = "stream",
realtime: bool = True,
depth: int = 1,
owns_sensor: bool = False,
verbose: bool = False,
) -> None:
if not realtime:
raise NotImplementedError(
"file/debug complete processing is the T08b scope"
)
self.sensor = sensor
self._owns_sensor = bool(owns_sensor)
self._verbose = bool(verbose)
self._external_input = bool(getattr(sensor, "_external_only", False))
if not self._external_input:
# Built-in capture owns the candidate inside the Sensor's capture
# source; external prepared frames arrive through push_frame().
self._external_input = False
self._session_id = str(session_id)
self._sensor_id = (
str(sensor_id)
if sensor_id is not None
else getattr(getattr(sensor, "context", None), "sensor_id", None)
)
if not self._sensor_id:
raise ValueError(
"SensorStream needs an explicit sensor_id or a SensorContext with a bound SN"
)
if calibration is None:
calibration = getattr(sensor, "calibration", None)
family = sensor_family or self._derive_family(sensor)
self.session = ForceStreamSession(
sensor_family=family,
device_id=getattr(sensor, "device_id", None),
context=context
if context is not None
else getattr(sensor, "context", None),
calibration=calibration,
settings=settings,
design_sample_rate_hz=design_sample_rate_hz,
)
enable = getattr(sensor, "enable_measurement_session", None)
self._has_measurement_session = callable(enable) and hasattr(
sensor, "disable_measurement_session"
)
#: The borrowed Sensor's published measurement-session mode before we
#: touched it; start() records it, stop()/failed start restores it.
self._sensor_session_was_enabled = bool(
getattr(
sensor,
"measurement_session",
getattr(sensor, "_measurement_session", False),
)
)
self._sensor_session_enabled_by_stream = False
self._cond = threading.Condition()
self._candidate: _QueuedFrame | None = None
self._overwritten = 0
self._input_counter = 0
self._stop = threading.Event()
self._latest_output: ForceStreamOutput | None = None
self._outputs_seen = 0
self._delivered = 0
self._events: list[StreamEvent] = []
self._pending_zero: _ZeroCommand | None = None
self._active_zero: _ZeroCommand | None = None
self._last_source_time_ns: int | None = None
self._pipeline = StreamPipeline(
sensor,
self._produce,
depth=max(1, int(depth)),
include=(),
on_frame=self._on_snapshot,
verbose=self._verbose,
)
# ------------------------------------------------------------------ ctors
@classmethod
def open(
cls,
source,
*,
config_name: str = "box",
calibration=None,
context=None,
settings: OutputSettings | None = None,
design_sample_rate_hz: float | None = None,
sensor_id: str | None = None,
session_id: str = "stream",
realtime: bool = True,
depth: int = 1,
verbose: bool = False,
sensor_options: Mapping[str, Any] | None = None,
) -> SensorStream:
"""Open a built-in capture source; the stream owns and closes the Sensor."""
sensor = Sensor(
source,
config_name=config_name,
verbose=verbose,
**(dict(sensor_options or {})),
)
return cls(
sensor,
sensor_family=None,
calibration=calibration,
context=context,
settings=settings,
design_sample_rate_hz=design_sample_rate_hz,
sensor_id=sensor_id,
session_id=session_id,
realtime=realtime,
depth=depth,
owns_sensor=True,
verbose=verbose,
)
@staticmethod
def _derive_family(sensor: Sensor) -> str:
context = getattr(sensor, "context", None)
family = getattr(context, "product_family", None)
if family:
return str(family)
text = str(getattr(sensor, "config_name", "") or "").replace("\\", "/")
for name in ("box", "mini", "finger"):
if name in text:
return name
return "box"
# --------------------------------------------------------------- lifecycle
def start(self) -> SensorStream:
if self._pipeline.running:
return self
self._stop.clear()
if self._has_measurement_session:
self._sensor_session_was_enabled = bool(
getattr(
self.sensor,
"measurement_session",
getattr(self.sensor, "_measurement_session", False),
)
)
if not self._sensor_session_was_enabled:
# The new session owns zero/calibration/filter; the published
# automatic path must not run in parallel on the hhd thread.
self.sensor.enable_measurement_session()
self._sensor_session_enabled_by_stream = True
try:
self._pipeline.start()
except BaseException:
self._restore_sensor_session()
raise
return self
def _restore_sensor_session(self) -> None:
"""Give a borrowed Sensor its pre-stream measurement-session mode back."""
if not self._sensor_session_enabled_by_stream:
return
self._sensor_session_enabled_by_stream = False
disable = getattr(self.sensor, "disable_measurement_session", None)
if callable(disable):
disable()
def stop(self, timeout: float = 5.0) -> None:
"""Stop all stages; the borrowed source stays open, the owned one closes."""
self._stop.set()
with self._cond:
self._candidate = None
for command in (self._pending_zero, self._active_zero):
if command is not None and not command.done.is_set():
command.error = SensorStreamError(
"stream stopped before zero completed"
)
command.done.set()
self._pending_zero = None
self._active_zero = None
self._cond.notify_all()
try:
self._pipeline.stop(timeout=timeout)
finally:
self._restore_sensor_session()
if self._owns_sensor:
self.sensor.disconnect()
error = self._pipeline.error
if error is not None:
raise SensorStreamError(f"stream stage failed: {error}") from error
@property
def running(self) -> bool:
return self._pipeline.running
@property
def error(self) -> BaseException | None:
return self._pipeline.error
@property
def overwritten_frames(self) -> int:
"""Candidate frames replaced before they were handed to the pipeline."""
return int(self._overwritten)
@property
def latest_output(self) -> ForceStreamOutput | None:
return self._latest_output
@property
def pipeline(self) -> StreamPipeline:
return self._pipeline
# ------------------------------------------------------------ external input
def push_frame(
self,
frame: np.ndarray,
*,
source_frame_id: int | None = None,
capture_time_ns: int | None = None,
time_basis: str = "capture_monotonic",
clock_id: str = "host",
source_pts: int | float | None = None,
) -> None:
"""Offer one already-prepared frame from a borrowed source.
Real-time candidate semantics: if the producer has not claimed the
previous candidate yet, this frame replaces it and increments
:attr:`overwritten_frames`; a claimed frame is never interrupted.
"""
if not self._external_input:
raise RuntimeError(
"push_frame() is for external-frame streams; built-in capture produces frames itself"
)
if not isinstance(frame, np.ndarray) or frame.size == 0:
raise ValueError("frame must be a non-empty numpy array")
with self._cond:
if self._candidate is not None:
self._overwritten += 1
self._input_counter += 1
if source_frame_id is None:
source_frame_id = self._input_counter - 1
if capture_time_ns is None:
capture_time_ns = time.perf_counter_ns()
self._candidate = _QueuedFrame(
image=frame,
source_frame_id=int(source_frame_id),
capture_time_ns=int(capture_time_ns),
time_basis=str(time_basis),
clock_id=str(clock_id),
source_pts=source_pts,
)
self._cond.notify_all()
# ----------------------------------------------------------------- producer
def _produce(self):
if self._external_input:
with self._cond:
while self._candidate is None and not self._stop.is_set():
self._cond.wait(timeout=0.05)
if self._stop.is_set() or self._candidate is None:
return None
item = self._candidate
self._candidate = None
prepared = self.sensor.get_img(
frame=item.image,
already_processed=True,
already_gray=item.image.ndim == 2,
)
if prepared is None:
return None
return ProducedFrame(prepared, metadata=item)
# Built-in capture: the Sensor owns the source; the frame is produced by
# its normal get_img path and the capture source keeps only its latest.
self._input_counter += 1
image = self.sensor.get_img()
if image is None:
return None
item = _QueuedFrame(
image=image,
source_frame_id=self._input_counter - 1,
capture_time_ns=time.perf_counter_ns(),
time_basis="capture_monotonic",
clock_id="host",
)
return ProducedFrame(image, metadata=item)
# --------------------------------------------------------------- force stage
def _on_snapshot(self, snapshot) -> None:
"""Runs on the pipeline's HHD thread: the only place touching the session."""
metadata = snapshot.source_metadata
if not isinstance(metadata, _QueuedFrame):
self._pipeline._error = SensorStreamError(
"snapshot has no source metadata frame contract"
)
return
command_to_start: _ZeroCommand | None = None
with self._cond:
if self._active_zero is None and self._pending_zero is not None:
pending = self._pending_zero
version = int(snapshot.tracker_reset_seq)
if version >= pending.target_reference_generation:
self._pending_zero = None
if version > pending.target_reference_generation:
pending.error = SensorStreamError(
f"reference generation {version} advanced past the "
f"requested zero target {pending.target_reference_generation}"
)
pending.done.set()
self._cond.notify_all()
else:
self._active_zero = pending
command_to_start = pending
if command_to_start is not None:
try:
self.session.request_zero(
reference_id=command_to_start.reference_id,
declared_unloaded=command_to_start.declared_unloaded,
expected_reference_generation=command_to_start.target_reference_generation,
max_gap_s=command_to_start.max_gap_s,
max_window_s=command_to_start.max_window_s,
)
except BaseException as exc: # surfaced through this command only
with self._cond:
command_to_start.error = exc
command_to_start.done.set()
self._active_zero = None
self._cond.notify_all()
source_dt_s = None
if self._last_source_time_ns is not None:
source_dt_s = (metadata.capture_time_ns - self._last_source_time_ns) / 1e9
stamp = FrameStamp(
sensor_id=self._sensor_id,
session_id=self._session_id,
frame_id=int(snapshot.seq),
source_frame_id=metadata.source_frame_id,
capture_time_ns=metadata.capture_time_ns,
clock_id=metadata.clock_id,
capture_time_basis=metadata.time_basis,
source_pts=metadata.source_pts,
)
frame = MeasurementFrame(
stamp=stamp,
source_dt_s=source_dt_s,
reference_generation=int(snapshot.tracker_reset_seq),
)
from .image_pipeline import ImageMeasurement
measurement = ImageMeasurement(
frame=frame,
force_raw=(
float(snapshot.normal_force_raw),
float(snapshot.shear_x_raw),
float(snapshot.shear_y_raw),
),
torque=float(snapshot.torque),
flow=None,
field=None,
valid_mask=None,
)
try:
output = self.session.process(measurement)
events = self.session.drain_events()
except BaseException as exc: # make the stage error visible through error/stop
with self._cond:
if self._active_zero is not None:
self._active_zero.error = exc
self._active_zero.done.set()
self._active_zero = None
self._cond.notify_all()
self._pipeline._error = exc
return
self._last_source_time_ns = metadata.capture_time_ns
with self._cond:
self._latest_output = output
self._outputs_seen += 1
self._events.extend(events)
command = self._active_zero
if command is not None:
# Only the force thread reads session.zero; the caller waits on
# its own command object instead.
command.result = self.session.zero
if command.result.phase in ("ready", "failed"):
command.done.set()
self._active_zero = None
self._cond.notify_all()
# ------------------------------------------------------------------ outputs
def wait_output(self, timeout: float | None = None) -> ForceStreamOutput | None:
"""Latest-wins output view: wait for the next-published row, return newest."""
deadline = None if timeout is None else time.perf_counter() + timeout
with self._cond:
start = self._outputs_seen
while self._outputs_seen == start:
remaining = None
if deadline is not None:
remaining = deadline - time.perf_counter()
if remaining <= 0:
return None
self._cond.wait(timeout=remaining)
self._delivered = self._outputs_seen
return self._latest_output
def drain_events(self) -> tuple[StreamEvent, ...]:
"""Session events emitted by the force thread since the previous drain."""
with self._cond:
events = tuple(self._events)
self._events.clear()
return events
# ------------------------------------------------------------ combined zero
def reset_and_zero(
self,
*,
reference_id: str,
declared_unloaded: bool = True,
timeout: float = DEFAULT_ZERO_TIMEOUT_S,
max_gap_s: float | None = None,
max_window_s: float | None = None,
):
"""Image reset → same-version unloaded baseline → force ready (K8.5).
The image version is claimed atomically by StreamPipeline; the force
session receives it on its own thread. The caller only names the zero,
never a version number. Each call owns one command object: the force
thread writes that command's ZeroProgress/error and signals it, so a
second call can never observe the previous zero's state. The caller
thread never reads ``session.zero``.
"""
if not self._pipeline.running:
raise SensorStreamError("start the stream before reset_and_zero()")
with self._cond:
if self._pending_zero is not None or self._active_zero is not None:
raise SensorStreamError("another reset_and_zero is already in progress")
target = int(self._pipeline.request_reset())
command = _ZeroCommand(
reference_id=str(reference_id),
declared_unloaded=bool(declared_unloaded),
max_gap_s=max_gap_s,
max_window_s=max_window_s,
target_reference_generation=target,
)
self._pending_zero = command
if not command.done.wait(float(timeout)):
with self._cond:
if self._pending_zero is command:
self._pending_zero = None
raise TimeoutError(
f"reset_and_zero({reference_id!r}) did not finish within {timeout}s "
f"(target={target})"
)
if command.error is not None:
raise SensorStreamError(str(command.error)) from command.error
progress = command.result
if progress is None:
raise SensorStreamError(
f"reset_and_zero({reference_id!r}) finished without progress"
)
if progress.phase == "failed":
raise SensorStreamError(f"unloaded zero failed: {progress.failure_reason}")
if progress.phase != "ready":
raise SensorStreamError(
f"reset_and_zero({reference_id!r}) finished in phase "
f"{progress.phase!r}, not ready"
)
return progress
+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"