Files
SDR-Rover/tests/test_lab050_video_stream.py
2026-09-08 14:15:36 +03:00

177 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Свежесть изображений и непрерывный 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