Lab050: model fresh-frame SDR video stream

This commit is contained in:
LittleSam129
2026-09-08 14:15:36 +03:00
parent cdf5396e80
commit 60691a09f1
9 changed files with 1769 additions and 12 deletions

View File

@@ -0,0 +1,176 @@
"""Свежесть изображений и непрерывный IQ, без файлов эксперимента и RF."""
from __future__ import annotations
import numpy as np
import pytest
from experiments import lab049_bpsk_symbol_rate as phy
from experiments import lab050_video_stream as lab
from experiments.lab050_streaming_receiver import StreamingReceiver
from protocol.two_stage_failsafe import TwoStageFailsafe, ThresholdPolicy, DecelerationProfile, SafetyState
def sequence(size: int = 1740, count: int = 30) -> list[lab.EncodedImage]:
return [lab.EncodedImage(index+1, bytes([index % 256])*size, .001, .001, 0.0) for index in range(count)]
@pytest.mark.parametrize("rate,fps,maximum", [(500000,3,6832),(500000,5,4148),(500000,10,1952),
(800000,3,11224),(800000,5,6588),(800000,10,3172),
(1000000,3,13908),(1000000,5,8296),(1000000,10,4148)])
def test_maximum_jpeg_is_exact_at_fragment_boundary(rate, fps, maximum):
assert lab.maximum_jpeg_bytes(rate, fps) == maximum
assert lab.fragment_count(maximum) * 5766 * fps <= rate
assert lab.fragment_count(maximum+1) * 5766 * fps > rate
@pytest.mark.parametrize("fps", [3, 5, 10])
@pytest.mark.parametrize("size", [244, 245, 1740, 8724])
def test_newest_queue_is_bounded_and_accounting_conserves_inputs(fps, size):
result = lab.simulate_sequence(sequence(size), fps, 200000, 1.0)
assert result["maximum_waiting_frames"] <= 1
assert result["input_frames"] == result["transmitted_frames"] + result["dropped_before_tx"]
assert result["received_frames"] == result["transmitted_frames"]
assert result["displayed_frames"] == result["received_frames"]
assert result["image_age_max_seconds"] is None or result["image_age_max_seconds"] <= 1.0
def test_newer_unstarted_frame_replaces_old_without_damaging_active_image():
result = lab.simulate_sequence(sequence(), 10, 200000, 1.0)
assert result["drop_reasons"]["replaced_by_newest"] > 0
sent = [frame for frame in result["frames"] if frame["first_tx_timestamp"] is not None]
assert all(frame["rf_age_seconds"] == pytest.approx(8*5766/200000) for frame in sent)
assert all(frame["last_tx_timestamp"] <= next_frame["first_tx_timestamp"] for frame,next_frame in zip(sent,sent[1:]))
@pytest.mark.parametrize("limit", [.2,.333,.5,1.0])
def test_age_limit_predictively_drops_impossible_jpeg(limit):
result = lab.simulate_sequence(sequence(8724), 5, 200000, limit)
assert result["transmitted_frames"] == 0
assert result["dropped_before_tx"] == 30
assert result["fresh_display_fps"] == 0
def test_source_time_not_encoding_completion_defines_age():
images=[lab.EncodedImage(1,bytes(244),.3,.001,0)]
result = lab.simulate_sequence(images,5,1000000,.2)
assert result["transmitted_frames"] == 0
def test_late_old_encoding_does_not_replace_fresher_waiting_image():
images = [lab.EncodedImage(index+1,bytes(1740),delay,.001,0)
for index,delay in enumerate((.001,.12,.001))]
result=lab.simulate_sequence(images,10,200000,1.0)
shown=[row["image_id"] for row in result["frames"] if row["display_timestamp"] is not None]
assert shown==[1,3]
assert result["drop_reasons"]["older_capture_arrived_late"]==1
def test_late_old_encoding_does_not_regress_display_after_queue_drains():
images = [lab.EncodedImage(index+1,bytes(244),delay,.001,0)
for index,delay in enumerate((.5,.001,.001))]
result=lab.simulate_sequence(images,5,1000000,1.0)
shown=[row["image_id"] for row in result["frames"] if row["display_timestamp"] is not None]
assert shown==[2,3]
assert result["drop_reasons"]["older_capture_arrived_late"]==1
def test_five_fresh_images_per_second_and_all_timestamps():
result = lab.simulate_sequence(sequence(),5,500000,.2)
assert result["fresh_display_fps"] == 5
assert result["raw_display_fps"] == 5
assert result["image_age_max_seconds"] == pytest.approx(.001+8*5766/500000+.001)
for row in result["frames"]:
names = ["capture_timestamp","encode_complete_timestamp","queue_enter_timestamp","first_tx_timestamp",
"first_rx_timestamp","last_tx_timestamp","object_complete_timestamp","decode_complete_timestamp","display_timestamp"]
values = [row[name] for name in names]
assert values == sorted(values)
def test_slow_rx_drops_before_cpu_and_does_not_claim_reception():
result = lab.simulate_sequence(sequence(),5,500000,.2,rx_seconds_per_packet=.1)
assert result["rf_delivered_objects"] == 0
assert result["received_frames"] == result["displayed_frames"] == 0
assert result["dropped_before_tx"] == 30
@pytest.mark.parametrize("fps,cost", [(5,.016),(10,.012)])
def test_conditional_receiver_speed_meets_freshness_and_rate(fps,cost):
result=lab.simulate_sequence(sequence(),fps,500000,.2,rx_seconds_per_packet=cost,
receiver_lookahead_seconds=.052608)
assert result["fresh_display_fps"] == (5 if fps==5 else 29/3)
assert result["steady_fresh_display_fps"] == pytest.approx(fps)
assert result["displayed_frames"] == 30
assert result["drain_displayed_frames"] == (0 if fps==5 else 1)
assert result["image_age_max_seconds"] < .2
def test_sufficient_cpu_throughput_alone_does_not_guarantee_age():
# 8*.02=.16 с/изображение укладывается в период .2 с, но сквозной возраст
# с накоплением нужного хвоста IQ уже превышает .2 с.
result=lab.simulate_sequence(sequence(),5,500000,.2,rx_seconds_per_packet=.02,
receiver_lookahead_seconds=.052608)
assert result["transmitted_frames"] == 0
def test_elrs_loss_is_external_to_video_queue():
before=lab.simulate_sequence(sequence(count=3),5,500000,.2)
safety=TwoStageFailsafe(ThresholdPolicy("test",.1,.2),DecelerationProfile("test",1,2))
assert safety.apply_watchdog(.2) is SafetyState.STAGE2_BRAKING
after=lab.simulate_sequence(sequence(count=3),5,500000,.2)
assert before==after
assert not after["elrs_control_in_video_queue"]
@pytest.mark.parametrize("rate", [200000,400000,500000,800000,1000000])
def test_stream_crosses_blocks_and_emits_before_finish(rate):
payload=bytes(range(256))
plan=phy.lab045.build_transmission_plan(payload)
receiver=StreamingReceiver(rate)
waveform=phy.build_parameterized_tx_complex(plan,phy.PhyMode(rate)).astype(np.complex64)
signal=np.concatenate((np.zeros(63217,np.complex64),waveform,
np.zeros(receiver.lookahead+2*receiver.core_samples,np.complex64)))
outputs=[]
chunk=32749
for start in range(0,len(signal),chunk):
outputs.extend(receiver.push_iq_block(signal[start:start+chunk]))
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert [item.payload for item in outputs] == [payload]
assert outputs[0].start_sample == pytest.approx(63217,abs=100)
assert not receiver.finish()
assert receiver.buffer.size == 0
def test_memory_bound_for_long_session_without_retained_history():
receiver=StreamingReceiver(1000000)
block=np.zeros(receiver.block_samples,np.complex64)
limits=[]
for _ in range(100):
assert receiver.push_iq_block(block)==[]
limits.append(receiver.buffer.size)
assert max(limits) <= receiver.buffer_limit_samples
assert receiver.total_samples > 20*receiver.buffer_limit_samples
assert receiver.packet_count == 0
assert not receiver.finish()
def test_truncated_stream_does_not_accept_partial_frame():
plan=phy.lab045.build_transmission_plan(bytes(256))
receiver=StreamingReceiver(1000000)
waveform=phy.build_parameterized_tx_complex(plan,phy.PhyMode(1000000)).astype(np.complex64)
receiver.push_iq_block(waveform[:len(waveform)//2])
assert receiver.finish()==[]
with pytest.raises(RuntimeError): receiver.push_iq_block(np.zeros(1,np.complex64))
def test_input_limit_and_nonfinite_rejected_without_advancing_stream():
receiver=StreamingReceiver(1000000)
for block in [np.zeros(receiver.block_samples+1,np.complex64),np.array([complex(float("nan"),0)])]:
with pytest.raises(ValueError): receiver.push_iq_block(block)
assert receiver.total_samples==0
def test_fixed_formats_match_actual_plan():
frame=lab.transport.build_object_frames(bytes(1740))[0]
assert frame.plan.frame_symbol_count+42==lab.FRAME_INTERVALS
assert lab.transport.FRAGMENT_DATA_SIZE==244