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:
ykcheungab
2026-10-21 15:01:38 +08:00
parent e3b60f3d6d
commit 14e73d743c
3 changed files with 1038 additions and 0 deletions
+546
View File
@@ -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,
)