fix: 按复位请求目标确认参考版本并隔离复位状态
防止在途旧帧提前触发归零,并保留旧 reset 用法。SDK 101 项针对性回归与 lint 通过。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user