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