fix: 按复位请求目标确认参考版本并隔离复位状态

防止在途旧帧提前触发归零,并保留旧 reset 用法。SDK 101 项针对性回归与 lint 通过。
This commit is contained in:
ykcheungab
2026-10-20 20:33:23 +08:00
parent ee830d8505
commit c256870ee2
8 changed files with 477 additions and 60 deletions
+63
View File
@@ -155,6 +155,33 @@ def collect_baseline(session, source, *, start=1, count=30, reference_id="ref-1"
]
def test_external_zero_waits_for_the_requested_target_reference_version():
source = FakeMeasurementSource(raw_for=lambda _: (10.0, 20.0, 30.0))
session = make_session(source)
session.request_zero_external_reset(
reference_id="second-reset", declared_unloaded=True, target_reference_version=2
)
outputs = []
for frame_id, version in enumerate([0, 1, 1, 2, 2]):
row = source.read_measurement_frame(None, frame_id=frame_id)
outputs.append(session.process_measurement(row, stamp(frame_id), tracker_reset_seq=version))
assert [o.measurement.zero.phase for o in outputs] == [
"resetting", "resetting", "resetting", "collecting", "collecting"
]
assert outputs[-1].measurement.zero.collected_frames == 2
assert outputs[-1].measurement.zero.first_frame_id == 3
@pytest.mark.parametrize("target", [0, -1, True])
def test_external_zero_rejects_invalid_target_reference_version(target):
session = make_session(FakeMeasurementSource())
with pytest.raises(ValueError, match="target_reference_version"):
session.request_zero_external_reset(
reference_id="invalid-target", declared_unloaded=True,
target_reference_version=target,
)
# ---------------------------------------------------------------------------
# T1: executable fake chain, one model call, cached reads, missing is not zero
# ---------------------------------------------------------------------------
@@ -1210,3 +1237,39 @@ def test_old_readout_entry_keeps_filter_then_convert_order() -> None:
# is exactly the recorded historical behaviour.
assert outputs[-1] == pytest.approx(4.0)
assert readout.raw_unfiltered == (250.0, 0.0, 0.0)
def test_readout_reset_halves_clear_only_their_own_state() -> None:
"""The flow EMA is image-side state; the readout history is force-side state."""
from orisys.force_readout import ForceReadout
flow = np.zeros((2, 2, 2), dtype=np.float32)
readout = ForceReadout({"flow_ema_alpha": 0.5, "force_median_window": 3},
config_fps=30.0)
readout.apply_flow_ema(flow)
readout.apply((10.0, 1.0, 2.0))
filtered = readout.filtered_raw
raw_unfiltered = readout.raw_unfiltered
unfiltered_newton = readout.unfiltered_newton
readout.reset_flow_ema()
assert readout._ema_flow is None
# The force-side history survives a reset that only rebases the image stage.
assert readout.filtered_raw == filtered
assert readout.raw_unfiltered == raw_unfiltered
assert readout.unfiltered_newton == unfiltered_newton
readout = ForceReadout({"flow_ema_alpha": 0.5, "force_median_window": 3},
config_fps=30.0)
readout.apply_flow_ema(flow)
readout.apply((10.0, 1.0, 2.0))
readout.reset_readout()
assert readout._ema_flow is not None
assert readout.filtered_raw is None
assert readout.raw_unfiltered is None
assert readout.unfiltered_newton is None
readout.reset()
assert readout._ema_flow is None
assert readout.filtered_raw is None
+222
View File
@@ -429,3 +429,225 @@ def test_snapshot_raw_capture_size_is_none_when_unreported():
time.sleep(0.001)
pipe.stop()
assert frames and frames[0].image_size_raw is None
class SplitResetFakeSensor(FakeSensor):
"""A fake with the split reset seam and event-gated flow/HHD stages.
The two halves of a reset belong to different pipeline threads, and the
version a frame is stamped with depends on which half has already run. The
events pin both stages to a known frame so the regression orders the race
instead of hoping for it: the flow stage parks at the end of its second
frame and the HHD stage parks inside its first decomposition.
"""
FLOW_GATE_CALL = 2
def __init__(self) -> None:
super().__init__()
self.flow_gate_reached = threading.Event()
self.release_flow_gate = threading.Event()
self.hhd_gate_reached = threading.Event()
self.release_hhd_gate = threading.Event()
self.hhd_calls = 0
self.image_reset_threads: list[str] = []
self.force_reset_threads: list[str] = []
self.force_reset_at_hhd_calls: list[int] = []
def compute_flow(self, img=None, **kwargs) -> None:
super().compute_flow(img, **kwargs)
# Park after frame 2's flow. The next frame that reaches the flow stage
# is then provably the first one that can observe a reset queued now.
if self._flow_calls == self.FLOW_GATE_CALL:
self.flow_gate_reached.set()
self.release_flow_gate.wait(timeout=5.0)
def compute_hhd(self, flow=None, **kwargs) -> None:
self.hhd_calls += 1
if self.hhd_calls == 1:
self.hhd_gate_reached.set()
self.release_hhd_gate.wait(timeout=5.0)
super().compute_hhd(flow, **kwargs)
def reset_image_state(self) -> None:
self.image_reset_threads.append(threading.current_thread().name)
def reset_force_state(self) -> None:
self.force_reset_threads.append(threading.current_thread().name)
self.force_reset_at_hhd_calls.append(self.hhd_calls)
def reset(self) -> None:
# Mirrors Sensor.reset(): both halves, immediately, on the caller's thread.
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
self.reset_image_state()
self.reset_force_state()
class LegacyResetFakeSensor(FakeSensor):
"""A stand-in with only the historical ``reset()``, like a third-party sensor."""
def __init__(self) -> None:
super().__init__()
self.reset_threads: list[str] = []
def reset(self) -> None:
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
self.reset_threads.append(threading.current_thread().name)
def test_reset_request_target_is_shared_until_flow_claims_it():
sensor = FakeSensor()
frames = []
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
assert pipe.request_reset() == 1
assert pipe.reset() is None # The published entry keeps its return contract.
assert pipe.request_reset() == 1
assert pipe.tracker_reset_seq == 0
pipe.start()
try:
assert _wait_until(lambda: len(frames) == 2)
assert [snap.tracker_reset_seq for snap in frames] == [1, 1]
assert sensor.reset_calls == 1
assert pipe.request_reset() == 2
finally:
pipe.stop()
def test_reset_requested_while_previous_reset_is_running_gets_a_new_target():
entered = threading.Event()
release = threading.Event()
class Sensor(LegacyResetFakeSensor):
def reset(self):
super().reset()
if self.reset_calls == 1:
entered.set()
assert release.wait(timeout=5.0)
sensor = Sensor()
frames = []
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
assert pipe.request_reset() == 1
pipe.start()
try:
assert entered.wait(timeout=5.0)
# Flow has claimed request 1, but has not finished applying it. The live
# counter is still 0; request 2 must not be merged into request 1.
assert pipe.tracker_reset_seq == 0
assert pipe.request_reset() == 2
assert pipe.request_reset() == 2
release.set()
assert _wait_until(lambda: len(frames) == 2)
assert [snap.tracker_reset_seq for snap in frames] == [1, 2]
assert sensor.reset_calls == 2
finally:
release.set()
pipe.stop()
def _wait_until(predicate, timeout: float = 5.0) -> bool:
"""Poll a predicate; the events, not this poll, order the race."""
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
if predicate():
return True
time.sleep(0.001)
return predicate()
def _run_gated_reset(sensor: SplitResetFakeSensor, frames: list, count: int = 3) -> None:
"""Drive two pre-reset frames and one post-reset frame through the real loops."""
pipe = StreamPipeline(sensor, make_producer(sensor, count),
on_frame=frames.append, depth=2)
pipe.start()
try:
assert sensor.flow_gate_reached.wait(timeout=5.0), "flow stage never reached its gate"
assert sensor.hhd_gate_reached.wait(timeout=5.0), "HHD stage never reached its gate"
# Both stages are provably parked: no frame has been published, and the
# frame whose flow would observe this reset has not been computed yet.
pipe.reset()
sensor.release_flow_gate.set()
assert _wait_until(lambda: sensor._flow_calls >= 3), (
"flow stage did not compute the first post-reset frame"
)
sensor.release_hhd_gate.set()
deadline = time.perf_counter() + 5.0
while len(frames) < count and time.perf_counter() < deadline:
time.sleep(0.001)
finally:
sensor.release_flow_gate.set()
sensor.release_hhd_gate.set()
pipe.stop()
assert len(frames) == count, f"expected {count} published frames, got {len(frames)}"
def test_frames_keep_the_reference_version_they_were_measured_with():
"""A reset queued after a frame's flow must not relabel that frame.
Frames 0 and 1 are measured while the HHD stage is held on the first
snapshot. The caller then queues a reset, and frame 2 is measured with the
new version. Each snapshot must carry the version its own flow was computed
with; reading the live counter at publish time would stamp the two older
frames with the newer version.
"""
sensor = SplitResetFakeSensor()
frames: list[FrameSnapshot] = []
_run_gated_reset(sensor, frames)
versions = [snap.tracker_reset_seq for snap in frames]
assert versions == [0, 0, 1], f"expected per-frame versions [0, 0, 1], got {versions}"
def test_reset_halves_run_on_the_threads_that_own_their_state():
"""Image state resets on the flow stage, force state on the HHD stage.
The force half must land on the HHD stage before it decomposes the first
frame of the new version. Resetting it from the flow thread - the old
behaviour - mutates model/zero state that the HHD stage is using.
"""
sensor = SplitResetFakeSensor()
frames: list[FrameSnapshot] = []
_run_gated_reset(sensor, frames)
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
# Two decompositions (frames 0 and 1) had completed when the force reset ran,
# so it preceded the third one - the first frame of the new version.
assert sensor.force_reset_at_hhd_calls == [2]
assert sensor.hhd_calls == 3
assert [snap.tracker_reset_seq for snap in frames] == [0, 0, 1]
def test_legacy_sensor_with_only_reset_is_reset_on_the_flow_stage():
"""A sensor without the split methods keeps the historical fallback."""
sensor = LegacyResetFakeSensor()
ready = threading.Event()
release = threading.Event()
state = {"produced": 0}
def gated_produce():
ready.set()
release.wait(timeout=5.0)
if state["produced"]:
return None
state["produced"] = 1
return sensor.make_frame(0)
frames: list = []
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
pipe.start()
try:
assert ready.wait(timeout=5.0)
pipe.reset()
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
release.set()
deadline = time.perf_counter() + 5.0
while not frames and time.perf_counter() < deadline:
time.sleep(0.001)
finally:
release.set()
pipe.stop()
assert len(frames) == 1
assert sensor.reset_calls == 1
assert sensor.reset_threads == ["orisys-pipeline-flow"]
assert sensor.reset_at_frame == [0], "the fallback reset must precede compute_flow"