Lab049: define ELRS control and SDR video architecture

This commit is contained in:
LittleSam129
2026-09-08 13:26:06 +03:00
parent ac6cc792b8
commit cdf5396e80
12 changed files with 3139 additions and 0 deletions

View 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"

View 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)

View 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"]