feat: 新增消费 MeasurementFrame 的力侧会话 ForceStreamSession(T06a)
新会话直接消费 ImageMeasurement:参考版本或 session 改变时,在 resetting、collecting、ready 任一阶段都作废旧零点;请求的目标版本到达时采用并开始同版本基线采样,目标前的在途帧标为 zero_pending;重复、迟到、同时间戳的帧只给诊断;max_gap_s/max_window_s 超限给出原因;每帧输出带帧头和参考版本的结果行。不提供旧的外部复位接口;旧 ForceSession 原样保留给 App。标量与无标定场景与旧会话逐位一致。回归 374 项、lint 通过。
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
"""New force-side session for the single-stream contract (T06a core).
|
||||
|
||||
The stage-1 architecture moves the force side from "the pipeline performs the
|
||||
reset" to "the image block stamps each frame with ``reference_generation``, and
|
||||
the force session invalidates the old zero whenever that version (or the source
|
||||
session) changes". :class:`ForceStreamSession` is that new entry:
|
||||
|
||||
* it consumes the T05 :class:`~orisys.image_pipeline.ImageMeasurement` - a T03
|
||||
``MeasurementFrame`` header plus the same-frame raw HHD feature/field;
|
||||
* it invalidates a completed or in-flight unloaded zero on a reference-version or
|
||||
session change **in every phase** (the stage-0 P3-2 gap is fixed: sampling
|
||||
started does not freeze the version check);
|
||||
* it collects the unloaded baseline only from frames of the requested version,
|
||||
with optional ``max_gap_s`` / ``max_window_s`` window limits;
|
||||
* duplicate / late / out-of-order frames produce diagnostics-only results that
|
||||
advance no zero or filter state and never invalidate a zero;
|
||||
* every input frame produces one :class:`ForceStreamOutput`, including duplicate,
|
||||
late and invalid frames, carrying the T03 frame identity and reference version.
|
||||
|
||||
The new session deliberately does **not** offer the old external-reset
|
||||
book-keeping (``process_measurement`` / ``request_zero_external_reset`` /
|
||||
``tracker_reset_seq``): those belong to the stage-0 class, which still serves the
|
||||
App until T09/T10. For T06a the zero/window/calibration/readout algorithms are
|
||||
reused through the published :class:`~orisys.force_session.ForceSession` engine
|
||||
behind this class's own state machine; T06b extends the calibration coverage
|
||||
(field/FN-only models) and the full numeric-equivalence suite.
|
||||
|
||||
One instance belongs to one force-side thread. Input arrays are read, never
|
||||
mutated; outputs carry scalars/statuses/immutable progress, not internal arrays.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .force_session import (
|
||||
WORKFLOW_VERSION,
|
||||
ForceOutput,
|
||||
ForceSession,
|
||||
ZeroProgress,
|
||||
)
|
||||
from .force_session import (
|
||||
FrameStamp as ForceFrameStamp,
|
||||
)
|
||||
from .sensor import FrameMeasurement
|
||||
from .stream_contract import MeasurementFrame
|
||||
|
||||
__all__ = ["ForceStreamOutput", "ForceStreamSession"]
|
||||
|
||||
|
||||
def _status_triple(status: str) -> tuple[str, str, str]:
|
||||
return (status, status, status)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForceStreamOutput:
|
||||
"""One result-stream row: T03 header/version plus this frame's force result.
|
||||
|
||||
``quality`` is the frame's place in the contract, not a filter setting:
|
||||
``ok`` / ``uncalibrated`` / ``zero_pending`` / ``duplicate`` / ``late`` /
|
||||
``session_changed`` / ``version_changed`` / ``window_failed`` / ``invalid``.
|
||||
Rejected frames still carry their raw feature and the T03 frame header, so a
|
||||
recorder can keep late invalidity visible instead of dropping it.
|
||||
"""
|
||||
|
||||
frame: MeasurementFrame
|
||||
raw: tuple[float, float, float]
|
||||
raw_status: tuple[str, str, str]
|
||||
newton: tuple[float, float, float]
|
||||
newton_status: tuple[str, str, str]
|
||||
zero: ZeroProgress
|
||||
quality: str
|
||||
diagnostics: tuple[tuple[str, str | float], ...] = ()
|
||||
accepted: bool = True
|
||||
duplicate: bool = False
|
||||
late: bool = False
|
||||
calibration_fingerprint: str = ""
|
||||
workflow_version: str = WORKFLOW_VERSION
|
||||
|
||||
@property
|
||||
def reference_generation(self) -> int:
|
||||
return int(self.frame.reference_generation)
|
||||
|
||||
|
||||
class _PendingMeasurementSource:
|
||||
"""The reused ForceSession engine asks for the frame this session just set."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: FrameMeasurement | None = None
|
||||
self.session_enabled = False
|
||||
self.reset_count = 0
|
||||
|
||||
def enable_measurement_session(self) -> None:
|
||||
self.session_enabled = True
|
||||
|
||||
def disable_measurement_session(self) -> None:
|
||||
self.session_enabled = False
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
# The image side owns the tracker/reference reset; the session receives
|
||||
# already-measured frames and must not reshape image state.
|
||||
self.reset_count += 1
|
||||
|
||||
def read_measurement_frame(
|
||||
self,
|
||||
source_frame,
|
||||
*,
|
||||
frame_id: int,
|
||||
timestamp_s: float | None = None,
|
||||
want_field: bool = False,
|
||||
want_contact: bool = False,
|
||||
) -> FrameMeasurement:
|
||||
del source_frame, frame_id, timestamp_s, want_field, want_contact
|
||||
row = self._pending
|
||||
if row is None:
|
||||
raise RuntimeError("ForceStreamSession has no pending measurement")
|
||||
self._pending = None
|
||||
return row
|
||||
|
||||
|
||||
def _finite_triple(values) -> tuple[bool, bool, bool]:
|
||||
return tuple(bool(np.isfinite(float(value))) for value in values) # type: ignore[return-value]
|
||||
|
||||
|
||||
class ForceStreamSession:
|
||||
"""Force-side session consuming T05 measurements (T06a core).
|
||||
|
||||
See the module docstring for the contract. Calibration/readout reuse the
|
||||
published engine; this class owns the reference-version zero/window and the
|
||||
result stream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sensor_family: str,
|
||||
device_id: str | None = None,
|
||||
context=None,
|
||||
calibration=None,
|
||||
settings=None,
|
||||
) -> None:
|
||||
self._source = _PendingMeasurementSource()
|
||||
self._engine = ForceSession(
|
||||
source=self._source,
|
||||
sensor_family=sensor_family,
|
||||
device_id=device_id,
|
||||
context=context,
|
||||
calibration=calibration,
|
||||
settings=settings,
|
||||
)
|
||||
self._family = sensor_family
|
||||
self._device_id = device_id
|
||||
self._version: int | None = None
|
||||
self._session_id: str | None = None
|
||||
self._last_time_s: float | None = None
|
||||
self._seen: dict[tuple[str, str, int], None] = {}
|
||||
self._seen_limit = 4096
|
||||
# Active unloaded-zero request (None when no reset is being confirmed).
|
||||
self._zero_target: int | None = None
|
||||
self._max_gap_s: float | None = None
|
||||
self._max_window_s: float | None = None
|
||||
self._window_first_s: float | None = None
|
||||
self._window_last_s: float | None = None
|
||||
self._last_output: ForceStreamOutput | None = None
|
||||
|
||||
# ------------------------------------------------------------- properties
|
||||
@property
|
||||
def workflow_version(self) -> str:
|
||||
return WORKFLOW_VERSION
|
||||
|
||||
@property
|
||||
def sensor_family(self) -> str:
|
||||
return self._family
|
||||
|
||||
@property
|
||||
def calibration(self):
|
||||
return self._engine.calibration
|
||||
|
||||
@property
|
||||
def calibration_fingerprint(self) -> str:
|
||||
return self._engine.calibration_fingerprint
|
||||
|
||||
@property
|
||||
def zero(self) -> ZeroProgress:
|
||||
return self._engine.zero
|
||||
|
||||
@property
|
||||
def output_settings(self):
|
||||
return self._engine.output_settings
|
||||
|
||||
@property
|
||||
def last_output(self) -> ForceStreamOutput | None:
|
||||
return self._last_output
|
||||
|
||||
@property
|
||||
def reference_generation(self) -> int | None:
|
||||
"""The reference generation this session currently accepts frames for."""
|
||||
return self._version
|
||||
|
||||
# ------------------------------------------------------------ zero request
|
||||
def request_zero(
|
||||
self,
|
||||
*,
|
||||
reference_id: str,
|
||||
declared_unloaded: bool,
|
||||
expected_reference_generation: int,
|
||||
max_gap_s: float | None = None,
|
||||
max_window_s: float | None = None,
|
||||
) -> ZeroProgress:
|
||||
"""Confirm the unloaded state for exactly ``expected_reference_generation``.
|
||||
|
||||
A reset confirmation names the version it belongs to (K8.3: image reset
|
||||
confirmation, then same-version unloaded frames). Frames of the still-active
|
||||
version that arrive while the image reset is in flight are ``zero_pending``
|
||||
diagnostics, never baseline frames. The requested version is adopted when
|
||||
its first frame arrives (the request is not invalidated by its own target);
|
||||
only a version beyond the request fails it.
|
||||
"""
|
||||
if not declared_unloaded:
|
||||
raise ValueError(
|
||||
"unloaded zero requires the operator to confirm there is no load"
|
||||
)
|
||||
if isinstance(expected_reference_generation, bool) or not isinstance(
|
||||
expected_reference_generation, int
|
||||
):
|
||||
raise TypeError("expected_reference_generation must be an integer")
|
||||
if expected_reference_generation < 0:
|
||||
raise ValueError("expected_reference_generation must be >= 0")
|
||||
if self._version is not None and expected_reference_generation < self._version:
|
||||
raise ValueError(
|
||||
"expected_reference_generation is older than the active version "
|
||||
f"{self._version}"
|
||||
)
|
||||
for name, value in (("max_gap_s", max_gap_s), ("max_window_s", max_window_s)):
|
||||
if value is not None:
|
||||
value = float(value)
|
||||
if not np.isfinite(value) or value <= 0.0:
|
||||
raise ValueError(f"{name} must be a positive finite number or None")
|
||||
self._zero_target = int(expected_reference_generation)
|
||||
self._max_gap_s = None if max_gap_s is None else float(max_gap_s)
|
||||
self._max_window_s = None if max_window_s is None else float(max_window_s)
|
||||
self._window_first_s = None
|
||||
self._window_last_s = None
|
||||
return self._engine.request_zero(
|
||||
reference_id=reference_id, declared_unloaded=True
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- result rows
|
||||
def _row(
|
||||
self,
|
||||
frame: MeasurementFrame,
|
||||
*,
|
||||
raw: tuple[float, float, float],
|
||||
raw_status: tuple[str, str, str],
|
||||
newton: tuple[float, float, float],
|
||||
newton_status: tuple[str, str, str],
|
||||
quality: str,
|
||||
diagnostics: tuple[tuple[str, str | float], ...] = (),
|
||||
accepted: bool = True,
|
||||
duplicate: bool = False,
|
||||
late: bool = False,
|
||||
) -> ForceStreamOutput:
|
||||
output = ForceStreamOutput(
|
||||
frame=frame,
|
||||
raw=raw,
|
||||
raw_status=raw_status,
|
||||
newton=newton,
|
||||
newton_status=newton_status,
|
||||
zero=self._engine.zero,
|
||||
quality=quality,
|
||||
diagnostics=diagnostics,
|
||||
accepted=accepted,
|
||||
duplicate=duplicate,
|
||||
late=late,
|
||||
calibration_fingerprint=self._engine.calibration_fingerprint,
|
||||
)
|
||||
self._last_output = output
|
||||
return output
|
||||
|
||||
def _diagnostic_row(
|
||||
self,
|
||||
frame: MeasurementFrame,
|
||||
measurement,
|
||||
*,
|
||||
quality: str,
|
||||
reason: str,
|
||||
duplicate: bool = False,
|
||||
late: bool = False,
|
||||
) -> ForceStreamOutput:
|
||||
raw = tuple(float(value) for value in measurement.force_raw)
|
||||
return self._row(
|
||||
frame,
|
||||
raw=raw,
|
||||
raw_status=tuple(
|
||||
"ok" if finite else "unavailable" for finite in _finite_triple(raw)
|
||||
),
|
||||
newton=(float("nan"),) * 3,
|
||||
newton_status=_status_triple(quality),
|
||||
quality=quality,
|
||||
diagnostics=(("reason", reason),),
|
||||
accepted=False,
|
||||
duplicate=duplicate,
|
||||
late=late,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _identity(frame: MeasurementFrame) -> tuple[str, str, int]:
|
||||
stamp = frame.stamp
|
||||
source = (
|
||||
stamp.source_frame_id
|
||||
if stamp.source_frame_id is not None
|
||||
else stamp.frame_id
|
||||
)
|
||||
return (str(stamp.sensor_id), str(stamp.session_id), int(source))
|
||||
|
||||
def _source_time_s(self, frame: MeasurementFrame) -> float:
|
||||
return float(frame.stamp.capture_time_ns) / 1e9
|
||||
|
||||
def _invalidate(self, reason: str) -> None:
|
||||
self._engine.invalidate(reason=reason)
|
||||
self._zero_target = None
|
||||
self._window_first_s = None
|
||||
self._window_last_s = None
|
||||
|
||||
def _check_window_limits(self, frame: MeasurementFrame) -> str | None:
|
||||
"""Return a failure reason when the next same-version frame would exceed a limit."""
|
||||
if self._engine.zero.phase != "collecting":
|
||||
return None
|
||||
now = self._source_time_s(frame)
|
||||
if self._max_gap_s is not None and self._window_last_s is not None:
|
||||
gap = now - self._window_last_s
|
||||
if gap > self._max_gap_s:
|
||||
return (
|
||||
f"zero baseline gap {gap:.6f}s exceeds max_gap_s="
|
||||
f"{self._max_gap_s:.6f}s"
|
||||
)
|
||||
if self._max_window_s is not None and self._window_first_s is not None:
|
||||
span = now - self._window_first_s
|
||||
if span > self._max_window_s:
|
||||
return (
|
||||
f"zero baseline window {span:.6f}s exceeds max_window_s="
|
||||
f"{self._max_window_s:.6f}s"
|
||||
)
|
||||
return None
|
||||
|
||||
def _feed_engine(self, measurement, frame: MeasurementFrame) -> ForceOutput:
|
||||
row = FrameMeasurement(
|
||||
raw=tuple(float(value) for value in measurement.force_raw),
|
||||
raw_valid=_finite_triple(measurement.force_raw),
|
||||
field=measurement.field,
|
||||
output_valid_mask=measurement.valid_mask,
|
||||
diagnostics=(("frame_id", int(frame.stamp.frame_id)),),
|
||||
)
|
||||
self._source._pending = row
|
||||
stamp = ForceFrameStamp(
|
||||
session_id=str(frame.stamp.session_id),
|
||||
frame_id=int(frame.stamp.frame_id),
|
||||
source_frame_id=frame.stamp.source_frame_id,
|
||||
signal_time_s=self._source_time_s(frame),
|
||||
time_basis=frame.stamp.capture_time_basis,
|
||||
)
|
||||
# process_frame applies a pending zero reset before measuring this frame,
|
||||
# so the first frame after request_zero() is baseline frame 1 (same order
|
||||
# as the published session). The adapter hands it the already-measured row.
|
||||
return self._engine.process_frame(None, stamp)
|
||||
|
||||
def _quality_for(self, output: ForceOutput) -> str:
|
||||
zero_phase = self._engine.zero.phase
|
||||
if zero_phase in ("resetting", "collecting"):
|
||||
return "zero_pending"
|
||||
if zero_phase == "failed":
|
||||
return "invalid"
|
||||
if self._engine.calibration is None:
|
||||
return "uncalibrated"
|
||||
if all(status == "uncalibrated" for status in output.measurement.newton_status):
|
||||
return "uncalibrated"
|
||||
if any(status == "ok" for status in output.measurement.newton_status):
|
||||
return "ok"
|
||||
return "invalid"
|
||||
|
||||
def process(self, measurement) -> ForceStreamOutput:
|
||||
"""Consume one T05 ``ImageMeasurement`` and return this frame's result row.
|
||||
|
||||
A duplicate / late / out-of-order frame is reported without advancing the
|
||||
zero window or readout filter, and without invalidating a completed zero.
|
||||
A reference-version or session change fails the old zero immediately (in
|
||||
any phase) and requires an explicit :meth:`request_zero` for the new one.
|
||||
"""
|
||||
frame = getattr(measurement, "frame", None)
|
||||
if not isinstance(frame, MeasurementFrame):
|
||||
raise TypeError(
|
||||
"ForceStreamSession consumes an ImageMeasurement-like input"
|
||||
)
|
||||
identity = self._identity(frame)
|
||||
version = int(frame.reference_generation)
|
||||
session_id = str(frame.stamp.session_id)
|
||||
signal_time = self._source_time_s(frame)
|
||||
target = self._zero_target
|
||||
|
||||
# 1) Duplicates: same source frame identity, diagnostics only.
|
||||
if identity in self._seen:
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="duplicate",
|
||||
reason="duplicate source frame identity",
|
||||
duplicate=True,
|
||||
)
|
||||
|
||||
# 2) Session change fails any zero (all phases), then this frame is a raw row.
|
||||
if self._session_id is not None and session_id != self._session_id:
|
||||
self._invalidate(
|
||||
f"source session changed {self._session_id!r} -> {session_id!r}"
|
||||
)
|
||||
self._session_id = session_id
|
||||
self._version = version
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="session_changed")
|
||||
|
||||
# 3) The active version changed. The requested zero target is the one
|
||||
# version that may be adopted without invalidating the request: the
|
||||
# normal order is v0 frames in flight -> request_zero(v1) -> v1 arrives.
|
||||
if self._version is not None and version != self._version:
|
||||
if version < self._version:
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="late",
|
||||
reason=(
|
||||
f"reference generation {version} is older than the active "
|
||||
f"version {self._version}"
|
||||
),
|
||||
late=True,
|
||||
)
|
||||
if target is not None and version == target:
|
||||
# Adopt the requested version: start/continue the same-version
|
||||
# baseline window; the request must not be invalidated by its own
|
||||
# first frame.
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
self._track_window(signal_time)
|
||||
return self._accepted_row(
|
||||
frame, output, quality=self._quality_for(output)
|
||||
)
|
||||
self._invalidate(
|
||||
f"reference generation changed {self._version} -> {version}"
|
||||
)
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="version_changed")
|
||||
|
||||
# 4) Reset confirmation: only the requested version may fill the window.
|
||||
if target is not None:
|
||||
if version < target:
|
||||
# Frames of the still-active version that arrive while the image
|
||||
# reset is in flight: waiting for the target, not late. They must
|
||||
# not enter the baseline window.
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="zero_pending",
|
||||
reason=(
|
||||
f"waiting for reference generation {target}; frame carries "
|
||||
f"the still-active version {version}"
|
||||
),
|
||||
)
|
||||
if version > target:
|
||||
self._invalidate(
|
||||
f"reference generation {version} advanced past the requested "
|
||||
f"zero version {target}"
|
||||
)
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="version_changed")
|
||||
|
||||
# 5) Same timestamp / out-of-order: diagnostics only, never invalidates.
|
||||
if self._last_time_s is not None and signal_time <= self._last_time_s:
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="late",
|
||||
reason="frame signal time is not after the last accepted frame",
|
||||
late=True,
|
||||
)
|
||||
|
||||
# 6) Baseline-window limits fail the request before the frame enters it.
|
||||
window_problem = self._check_window_limits(frame)
|
||||
if window_problem is not None:
|
||||
self._invalidate(window_problem)
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="window_failed")
|
||||
|
||||
# Accepted: this frame is measured once by the reused engine.
|
||||
self._version = version if self._version is None else self._version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
self._track_window(signal_time)
|
||||
return self._accepted_row(frame, output, quality=self._quality_for(output))
|
||||
|
||||
def _remember(self, identity: tuple[str, str, int]) -> None:
|
||||
"""Register every frame handed to the engine (duplicate detection)."""
|
||||
self._seen[identity] = None
|
||||
if len(self._seen) > self._seen_limit:
|
||||
self._seen.clear()
|
||||
self._seen[identity] = None
|
||||
|
||||
def _track_window(self, signal_time: float) -> None:
|
||||
if self._engine.zero.phase == "collecting":
|
||||
if self._window_first_s is None:
|
||||
self._window_first_s = signal_time
|
||||
self._window_last_s = signal_time
|
||||
|
||||
def _accepted_row(
|
||||
self, frame: MeasurementFrame, output: ForceOutput, *, quality: str
|
||||
) -> ForceStreamOutput:
|
||||
return self._row(
|
||||
frame,
|
||||
raw=tuple(float(value) for value in output.raw),
|
||||
raw_status=tuple(str(value) for value in output.measurement.sdk.raw_status),
|
||||
newton=tuple(float(value) for value in output.newton),
|
||||
newton_status=tuple(
|
||||
str(value) for value in output.measurement.newton_status
|
||||
),
|
||||
quality=quality,
|
||||
diagnostics=tuple(output.measurement.diagnostics),
|
||||
accepted=True,
|
||||
)
|
||||
Reference in New Issue
Block a user