Lab049: define ELRS control and SDR video architecture
This commit is contained in:
211
tests/test_lab049_bpsk_symbol_rate.py
Normal file
211
tests/test_lab049_bpsk_symbol_rate.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
import inspect
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from experiments import lab045_hackrf_bpsk_fec as lab045
|
||||
from experiments import lab049_bpsk_symbol_rate as lab049
|
||||
from protocol.control_messages import ControlStateMessage, EmergencyStop, decode_message
|
||||
from protocol.link_packet import TrafficClass, decode_link_packet
|
||||
from protocol.priority_scheduler import QueuedPacket
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _plan() -> lab045.TransmissionPlan:
|
||||
payload = bytes((index * 49 + 7) & 0xFF for index in range(256))
|
||||
return lab045.build_transmission_plan(payload)
|
||||
|
||||
|
||||
def _frame_duration(symbol_rate: int) -> float:
|
||||
mode = lab049.phy_mode(symbol_rate)
|
||||
return lab049.frame_waveform_sample_count(_plan(), mode) / mode.sample_rate_hz
|
||||
|
||||
|
||||
def test_parameterized_symbol_rates_have_exact_samples_per_symbol() -> None:
|
||||
assert {
|
||||
rate: lab049.phy_mode(rate).samples_per_symbol
|
||||
for rate in lab049.TESTED_SYMBOL_RATES
|
||||
} == {20_000: 400, 50_000: 160, 100_000: 80, 200_000: 40}
|
||||
|
||||
|
||||
def test_non_integer_samples_per_symbol_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="целым"):
|
||||
lab049.phy_mode(30_000)
|
||||
|
||||
|
||||
def test_20ksymbol_waveform_is_byte_exact_lab045_result() -> None:
|
||||
assert lab049.build_parameterized_tx_cs8(
|
||||
_plan(),
|
||||
lab049.phy_mode(20_000),
|
||||
) == lab045.build_tx_cs8(_plan())
|
||||
|
||||
|
||||
def test_20ksymbol_frame_duration_preserves_lab047_baseline() -> None:
|
||||
mode = lab049.phy_mode(20_000)
|
||||
assert lab049.frame_waveform_sample_count(_plan(), mode) == _plan().waveform_sample_count
|
||||
assert _frame_duration(20_000) == _plan().airtime_seconds
|
||||
|
||||
|
||||
def test_50ksymbol_iq_roundtrip() -> None:
|
||||
result = lab049.single_frame_roundtrip(50_000)
|
||||
assert result == {
|
||||
"phy_header_pass": True,
|
||||
"fec_pass": True,
|
||||
"crc": lab045.CRC_PASS,
|
||||
"payload_match": True,
|
||||
}
|
||||
|
||||
|
||||
def test_100ksymbol_iq_roundtrip() -> None:
|
||||
result = lab049.single_frame_roundtrip(100_000)
|
||||
assert result == {
|
||||
"phy_header_pass": True,
|
||||
"fec_pass": True,
|
||||
"crc": lab045.CRC_PASS,
|
||||
"payload_match": True,
|
||||
}
|
||||
|
||||
|
||||
def test_scheduler_priority_order_is_reused() -> None:
|
||||
result = lab049.priority_probe(_frame_duration(100_000))
|
||||
assert result["priority_pass"]
|
||||
assert result["transmission_order"] == [
|
||||
"VIDEO",
|
||||
"EMERGENCY",
|
||||
"CONTROL",
|
||||
"TELEMETRY",
|
||||
"VIDEO",
|
||||
]
|
||||
|
||||
|
||||
def test_video_is_not_interrupted_but_control_enters_at_next_boundary() -> None:
|
||||
duration = _frame_duration(100_000)
|
||||
result = lab049.priority_probe(duration)
|
||||
assert result["first_video_not_preempted"]
|
||||
assert result["control_max_video_blocking_ms"] == duration * 1000.0
|
||||
|
||||
|
||||
def test_emergency_has_highest_priority_after_active_frame() -> None:
|
||||
result = lab049.priority_probe(_frame_duration(100_000))
|
||||
assert result["transmission_order"][1] == "EMERGENCY"
|
||||
assert not result["emergency_deadline_met_in_worst_phase"]
|
||||
assert lab049.priority_probe(_frame_duration(200_000))[
|
||||
"emergency_deadline_met_in_worst_phase"
|
||||
]
|
||||
|
||||
|
||||
def test_loaded_scheduler_reports_all_latency_classes() -> None:
|
||||
result = lab049.loaded_scheduler_scenario(_frame_duration(100_000))
|
||||
assert result["video_queue_saturated"]
|
||||
assert set(result["latency"]) == {
|
||||
"EMERGENCY",
|
||||
"CONTROL",
|
||||
"TELEMETRY",
|
||||
"VIDEO",
|
||||
}
|
||||
for statistics in result["latency"].values():
|
||||
assert statistics["count"] > 0
|
||||
assert 0.0 <= statistics["min_ms"] <= statistics["median_ms"] <= statistics["max_ms"]
|
||||
|
||||
|
||||
def test_scheduler_preserves_valid_control_and_emergency_payloads() -> None:
|
||||
control_payload = lab049._control_payload(0)
|
||||
emergency_payload = lab049._emergency_payload(0)
|
||||
control = decode_message(control_payload)
|
||||
emergency = decode_message(emergency_payload)
|
||||
assert isinstance(control, ControlStateMessage)
|
||||
assert isinstance(emergency, EmergencyStop)
|
||||
assert len(control_payload) < lab049.lab046.PACKET_PAYLOAD_SIZE
|
||||
assert len(emergency_payload) < lab049.lab046.PACKET_PAYLOAD_SIZE
|
||||
queued = (
|
||||
QueuedPacket(
|
||||
lab049._link_packet(TrafficClass.CONTROL, 0, 0.0, control_payload),
|
||||
0,
|
||||
),
|
||||
QueuedPacket(
|
||||
lab049._link_packet(TrafficClass.EMERGENCY, 0, 0.0, emergency_payload),
|
||||
1,
|
||||
),
|
||||
)
|
||||
schedule = lab049._schedule_fixed_physical_frames(
|
||||
queued,
|
||||
_frame_duration(100_000),
|
||||
)
|
||||
restored_payloads = {
|
||||
item.queued.packet.traffic_class: decode_link_packet(item.wire_packet).payload
|
||||
for item in schedule.transmitted
|
||||
}
|
||||
assert restored_payloads[TrafficClass.CONTROL] == control_payload
|
||||
assert restored_payloads[TrafficClass.EMERGENCY] == emergency_payload
|
||||
result = lab049.loaded_scheduler_scenario(_frame_duration(100_000))
|
||||
assert not result["application_payloads_padded"]
|
||||
assert result["fixed_physical_frame_duration_model"]
|
||||
|
||||
|
||||
def test_scheduler_reports_expired_control_without_claiming_it_was_dropped() -> None:
|
||||
result = lab049.loaded_scheduler_scenario(_frame_duration(50_000))
|
||||
assert result["deadline_misses"]["CONTROL"] >= 1
|
||||
assert not result["scheduler_drops_expired_packets"]
|
||||
|
||||
|
||||
def test_channel_budget_uses_control_telemetry_before_video() -> None:
|
||||
slow = lab049.channel_budget(
|
||||
_frame_duration(20_000),
|
||||
lab049.EXPECTED_JPEG_BYTES,
|
||||
36,
|
||||
)
|
||||
fast = lab049.channel_budget(
|
||||
_frame_duration(200_000),
|
||||
lab049.EXPECTED_JPEG_BYTES,
|
||||
36,
|
||||
)
|
||||
middle = lab049.channel_budget(
|
||||
_frame_duration(100_000),
|
||||
lab049.EXPECTED_JPEG_BYTES,
|
||||
36,
|
||||
)
|
||||
assert slow["video_available_bitrate_bps"] == 0.0
|
||||
assert middle["video_available_bitrate_bps"] == 0.0
|
||||
assert fast["video_available_bitrate_bps"] > 0.0
|
||||
assert middle["control_reserved_link_bps"] == pytest.approx(46_080.0)
|
||||
assert middle["telemetry_load_link_bps"] == pytest.approx(23_040.0)
|
||||
assert middle["fixed_physical_frame_per_packet"]
|
||||
assert middle["service_requested_airtime_fraction"] > 1.0
|
||||
assert fast["service_requested_airtime_fraction"] < 1.0
|
||||
assert fast["video_available_frames_per_second"] == pytest.approx(
|
||||
fast["physical_frame_capacity_per_second"] - 30.0
|
||||
)
|
||||
assert fast["remaining_link_bps"] == pytest.approx(
|
||||
fast["scheduler_link_bitrate_bps"]
|
||||
- fast["control_reserved_link_bps"]
|
||||
- fast["telemetry_load_link_bps"]
|
||||
)
|
||||
|
||||
|
||||
def test_receiver_real_time_ratio_calculation() -> None:
|
||||
assert lab049.processing_to_capture_ratio(0.5, 1.0) == 0.5
|
||||
with pytest.raises(ValueError):
|
||||
lab049.processing_to_capture_ratio(1.0, 0.0)
|
||||
|
||||
|
||||
def test_noise_is_normalized_by_transmitted_bit_energy() -> None:
|
||||
long = np.ones(400, dtype=np.complex64)
|
||||
short = np.ones(160, dtype=np.complex64)
|
||||
_, long_sigma = lab049.add_coded_bit_ebn0_noise(long, 1, 7.0, 49)
|
||||
_, short_sigma = lab049.add_coded_bit_ebn0_noise(short, 1, 7.0, 49)
|
||||
assert long_sigma / short_sigma == pytest.approx(np.sqrt(400 / 160))
|
||||
|
||||
|
||||
def test_receiver_api_has_no_tx_plan_or_payload_oracle() -> None:
|
||||
parameters = inspect.signature(lab049.receive_iq_frame).parameters
|
||||
assert tuple(parameters) == ("iq_samples", "mode")
|
||||
|
||||
|
||||
def test_mode_classification_requires_all_latency_capacity_and_speed_gates() -> None:
|
||||
assert lab049.classify_mode(True, True, True, True, False, False) == "PARTIAL"
|
||||
assert lab049.classify_mode(True, True, False, True, True, True) == "PARTIAL"
|
||||
assert lab049.classify_mode(True, True, True, True, True, True) == "PASS"
|
||||
assert lab049.classify_mode(False, True, True, True, True, True) == "FAIL"
|
||||
119
tests/test_lab049_crsf_elrs_budget.py
Normal file
119
tests/test_lab049_crsf_elrs_budget.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Проверки расчётов Lab049 без файлов эксперимента и оборудования."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from experiments import lab049_crsf_elrs_budget as budget
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def accounting() -> budget.ImageAccounting:
|
||||
return budget.image_accounting(bytes(8724))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interval_us, expected_ms", [(20_000, 20), (10_000, 10), (6666, 6.666), (3003, 3.003)])
|
||||
def test_source_interval_preserves_integer_microseconds(interval_us, expected_ms):
|
||||
mode = budget.ElrsMode("test", interval_us, 8, 16)
|
||||
result = budget.elrs_budget(mode, None)
|
||||
assert result.packet_interval_ms == expected_ms
|
||||
assert result.rf_slot_rate_hz == pytest.approx(1_000_000 / interval_us)
|
||||
assert result.next_rc_opportunity_wait_mean_ms == pytest.approx(expected_ms / 2)
|
||||
assert result.next_rc_opportunity_wait_supremum_ms == expected_ms
|
||||
|
||||
|
||||
@pytest.mark.parametrize("denominator", budget.TELEMETRY_DENOMINATORS)
|
||||
def test_telemetry_and_waits_against_enumerated_cycle(denominator):
|
||||
result = budget.elrs_budget(budget.ELRS_MODES[3], denominator)
|
||||
# Вторая независимая форма: перечислить моменты RC и интегрировать
|
||||
# треугольники ожидания между ними за полный период.
|
||||
rc_times = [slot * 10 for slot in range(denominator) if slot != denominator - 1]
|
||||
gaps = [right - left for left, right in zip(rc_times, rc_times[1:] + [denominator * 10])]
|
||||
assert result.next_rc_opportunity_wait_mean_ms == pytest.approx(sum(gap**2 / 2 for gap in gaps) / (denominator * 10))
|
||||
assert result.next_rc_opportunity_wait_supremum_ms == max(gaps)
|
||||
assert result.telemetry_slot_fraction == 1 / denominator
|
||||
assert result.telemetry_slots_per_second == 100 / denominator
|
||||
assert result.rc_opportunities_per_second_upper_bound == len(rc_times) / (denominator / 100)
|
||||
|
||||
|
||||
def test_off_is_zero_telemetry_and_not_ratio_one():
|
||||
result = budget.elrs_budget(budget.ELRS_MODES[3], None)
|
||||
assert result.telemetry_slots_per_second == result.data_ceiling_bytes_per_second == 0
|
||||
assert result.data_ceiling_with_link_stats_bytes_per_second == 0
|
||||
assert result.rc_opportunities_per_second_upper_bound == 100
|
||||
with pytest.raises(ValueError):
|
||||
budget.elrs_budget(budget.ELRS_MODES[3], 1)
|
||||
|
||||
|
||||
def test_data_capacity_uses_data_not_whole_ota_packet():
|
||||
result = budget.elrs_budget(budget.ELRS_MODES[3], 2)
|
||||
assert result.data_ceiling_bytes_per_second == 500
|
||||
assert result.data_burst_packets == 24
|
||||
assert result.data_ceiling_with_link_stats_bytes_per_second == 492
|
||||
maximum = budget.elrs_budget(budget.ELRS_MODES[-1], 2)
|
||||
assert maximum.data_ceiling_bytes_per_second == 5000
|
||||
assert 2 * maximum.data_ceiling_bytes_per_second < 8724 * 3
|
||||
|
||||
|
||||
def test_repeated_packets_are_not_counted_as_new_commands():
|
||||
with pytest.raises(ValueError, match="D/DK"):
|
||||
budget.elrs_budget(replace(budget.ELRS_MODES[-1], repetitions=4), 128)
|
||||
|
||||
|
||||
def test_actual_transport_overhead(accounting):
|
||||
assert accounting.rf_frame_count == 36
|
||||
assert accounting.fragment_header_bytes_total == 432
|
||||
assert accounting.padding_bytes_total == 60
|
||||
assert accounting.packet_bytes_total == 9648
|
||||
assert accounting.packet_overhead_bytes_total == 432
|
||||
assert accounting.fec_coded_bits_total == 154800
|
||||
assert accounting.phy_header_bits_total == 5184
|
||||
assert accounting.preamble_bits_total == 46080
|
||||
assert accounting.transmitted_bpsk_bits_total == 206064
|
||||
assert accounting.guard_and_filter_intervals_total == 1512
|
||||
assert accounting.symbol_intervals_total == 207576
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fps,payload_bits,symbols", [(3, 209376, 622728), (5, 348960, 1037880), (10, 697920, 2075760)])
|
||||
def test_targets(accounting, fps, payload_bits, symbols):
|
||||
result = budget.video_requirement(accounting, fps)
|
||||
assert budget.jpeg_payload_bitrate(8724, fps) == payload_bits
|
||||
assert result["payload_kbit_per_second"] == payload_bits / 1000
|
||||
assert result["minimum_phy_symbol_rate_including_guards"] == symbols
|
||||
assert result["rx_rf_frames_per_second_minimum"] == 36 * fps
|
||||
assert result["rx_service_budget_ms_per_image"] == 1000 / fps
|
||||
assert not result["e2e_limit_is_approved_or_measured"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("speed,fps,distance", [(10, 3, 25/27), (15, 5, 5/6), (25, 10, 25/36), (0, 1, 0)])
|
||||
def test_distance(speed, fps, distance):
|
||||
assert budget.distance_per_frame(speed, fps) == pytest.approx(distance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("symbol_rate,airtime", [(20000, 10.3788), (50000, 4.15152), (100000, 2.07576), (200000, 1.03788)])
|
||||
def test_sdr_capacity_reserves_no_elrs_traffic(accounting, symbol_rate, airtime):
|
||||
result = budget.sdr_capacity(accounting, symbol_rate)
|
||||
assert result["jpeg_airtime_seconds"] == pytest.approx(airtime)
|
||||
assert result["ideal_video_fps"] == pytest.approx(1 / airtime)
|
||||
assert result["ideal_payload_kbit_per_second"] == pytest.approx(8724 * 8 / airtime / 1000)
|
||||
assert result["sdr_rc_reserved_symbol_intervals_per_second"] == 0
|
||||
half = budget.sdr_capacity(accounting, symbol_rate, bulk_symbol_intervals_per_second=symbol_rate/2)
|
||||
assert half["ideal_video_fps"] == pytest.approx(result["ideal_video_fps"] / 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, float("inf"), float("nan"), True])
|
||||
def test_invalid_rates_rejected(accounting, bad):
|
||||
with pytest.raises(ValueError):
|
||||
budget.jpeg_payload_bitrate(8724, bad)
|
||||
with pytest.raises(ValueError):
|
||||
budget.distance_per_frame(25, bad)
|
||||
with pytest.raises(ValueError):
|
||||
budget.sdr_capacity(accounting, bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [True, 16.0, 3, 0])
|
||||
def test_invalid_telemetry_ratios_rejected(bad):
|
||||
with pytest.raises(ValueError):
|
||||
budget.elrs_budget(budget.ELRS_MODES[1], bad)
|
||||
66
tests/test_lab049_independent_iq.py
Normal file
66
tests/test_lab049_independent_iq.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Регрессии неизвестной границы, CFO и полной задержки доставки Lab049."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from experiments import lab049_bpsk_symbol_rate as lab
|
||||
from experiments import lab049_independent_iq as independent
|
||||
|
||||
|
||||
def test_fractional_symbol_rate_is_not_silently_rounded() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
lab.phy_mode(20000.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rate", (50_000, 100_000))
|
||||
def test_unknown_frame_start_and_residual_cfo(rate: int) -> None:
|
||||
mode = lab.phy_mode(rate)
|
||||
payload = bytes(range(256))
|
||||
plan = lab.lab045.build_transmission_plan(payload)
|
||||
iq = lab.lab045.lab044.signed_iq_bytes_to_complex64(
|
||||
lab.build_parameterized_tx_cs8(plan, mode))
|
||||
iq *= np.exp(1j * (2.7 + 2 * np.pi * 10 * np.arange(iq.size)
|
||||
/ mode.sample_rate_hz)).astype(np.complex64)
|
||||
stream = np.pad(iq, (12007, 12003))
|
||||
result = independent.receive_stream(stream, mode)
|
||||
assert result.termination_reason == "END_OF_STREAM"
|
||||
assert result.crc_pass_count == 1
|
||||
assert result.accepted_frames[0].reception.payload_bytes == payload
|
||||
assert result.accepted_frames[0].estimated_cfo_hz == pytest.approx(10, abs=1)
|
||||
assert lab.lab045.SYMBOL_RATE == 20_000
|
||||
assert lab.lab045.lab044.lab043.SAMPLES_PER_SYMBOL == 400
|
||||
|
||||
|
||||
def test_receiver_mode_restores_constants_on_exception() -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
with independent.receiver_mode(lab.phy_mode(100_000)):
|
||||
raise RuntimeError("контроль выхода")
|
||||
assert lab.lab045.SAMPLES_PER_SYMBOL == 400
|
||||
assert lab.lab045.lab044.SAMPLES_PER_SYMBOL == 400
|
||||
|
||||
|
||||
def test_noise_only_does_not_produce_a_packet() -> None:
|
||||
generator = np.random.default_rng(49)
|
||||
noise = (generator.standard_normal(180000)
|
||||
+ 1j * generator.standard_normal(180000)).astype(np.complex64)
|
||||
result = independent.receive_stream(noise, lab.phy_mode(100_000))
|
||||
assert result.crc_pass_count == 0
|
||||
|
||||
|
||||
def test_emergency_delivery_includes_its_own_frame() -> None:
|
||||
probe = lab.priority_probe(0.02883)
|
||||
assert probe["emergency_max_video_blocking_ms"] == pytest.approx(28.83)
|
||||
assert probe["emergency_worst_delivery_ms"] == pytest.approx(57.66)
|
||||
assert not probe["emergency_delivery_deadline_met_in_worst_phase"]
|
||||
|
||||
|
||||
def test_deadlines_use_complete_delivery_and_drain_is_separate() -> None:
|
||||
result = lab.loaded_scheduler_scenario(0.05766)
|
||||
assert result["deadline_misses"]["CONTROL"] == 6
|
||||
assert result["deadline_misses"]["EMERGENCY"] == 1
|
||||
assert result["active_window_delivered_packets"]["TELEMETRY"] == 0
|
||||
assert result["active_window_delivery_latency"]["TELEMETRY"]["count"] == 0
|
||||
assert result["delivery_latency_until_drain_end"]["CONTROL"]["max_ms"] == pytest.approx(105.64)
|
||||
assert result["drain_end_seconds"] > result["source_duration_seconds"]
|
||||
Reference in New Issue
Block a user