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:
@@ -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 之前)。
|
||||
|
||||
@@ -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
|
||||
@@ -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