177 lines
8.3 KiB
Python
177 lines
8.3 KiB
Python
"""Свежесть изображений и непрерывный 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
|