"""Lab049: раздельные бюджеты штатного ELRS и SDR-видеоканала, без RF. Формулы описывают возможности слотов без потерь, а не полную задержку управления. Исходные интервалы зафиксированы по ExpressLRS 4.1.0. Никакие интерфейсы оборудования не открываются. """ from __future__ import annotations from dataclasses import asdict, dataclass import hashlib import json import math from pathlib import Path import sys if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) ROOT = Path(__file__).resolve().parents[1] ELRS_VERSION = "4.1.0" ELRS_COMMIT = "a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6" SOURCE_ROOT = f"https://github.com/ExpressLRS/ExpressLRS/blob/{ELRS_COMMIT}/src/" SOURCES = { "rates_and_link_burst": SOURCE_ROOT + "src/common.cpp", "ota_sizes": SOURCE_ROOT + "lib/OTA/OTA.h", "data_sizes": SOURCE_ROOT + "include/telemetry_protocol.h", "tx_scheduler": SOURCE_ROOT + "src/tx_main.cpp", "rx_scheduler": SOURCE_ROOT + "src/rx_main.cpp", "crsf": "https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md", "mavlink_estimates": "https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode", } TELEMETRY_DENOMINATORS = (128, 64, 32, 16, 8, 4, 2) JPEG_SHA256 = "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a" def _positive(value: float, name: str) -> None: if isinstance(value, bool) or not math.isfinite(value) or value <= 0: raise ValueError(f"{name}: требуется конечное положительное число") @dataclass(frozen=True) class ElrsMode: name: str interval_us: int ota_packet_bytes: int standard_telemetry_denominator: int repetitions: int = 1 def __post_init__(self) -> None: if type(self.interval_us) is not int or self.interval_us <= 0: raise ValueError("Интервал должен быть положительным целым числом микросекунд") if type(self.ota_packet_bytes) is not int or self.ota_packet_bytes not in (8, 13): raise ValueError("Поддерживаются OTA4=8 и OTA8=13 байт") if (type(self.standard_telemetry_denominator) is not int or self.standard_telemetry_denominator not in TELEMETRY_DENOMINATORS): raise ValueError("Неизвестное штатное отношение телеметрии") if type(self.repetitions) is not int or self.repetitions < 1: raise ValueError("Число повторов должно быть положительным целым") # Отдельные представительные режимы; наличие зависит от микросхемы и диапазона. ELRS_MODES = ( ElrsMode("900: 25", 40_000, 8, 8), ElrsMode("900/2.4: 50", 20_000, 8, 16), ElrsMode("900: 100", 10_000, 8, 32), ElrsMode("900/2.4: 100 Full", 10_000, 13, 32), ElrsMode("2.4: 150", 6_666, 8, 32), ElrsMode("900: 200", 5_000, 8, 64), ElrsMode("2.4 / LR1121 900: 250", 4_000, 8, 64), ElrsMode("2.4: 333 Full", 3_003, 13, 128), ElrsMode("2.4: 500", 2_000, 8, 128), ElrsMode("2.4: F1000 / K1000", 1_000, 8, 128), ElrsMode("LR1121 900: K1000 Full", 1_000, 13, 128), ) @dataclass(frozen=True) class ElrsBudget: packet_interval_ms: float rf_slot_rate_hz: float telemetry_slot_fraction: float telemetry_slots_per_second: float rc_opportunities_per_second_upper_bound: float next_rf_slot_wait_mean_ms: float next_rf_slot_wait_supremum_ms: float next_rc_opportunity_wait_mean_ms: float next_rc_opportunity_wait_supremum_ms: float data_bytes_per_full_telemetry_packet: int data_ceiling_bytes_per_second: float data_ceiling_with_link_stats_bytes_per_second: float data_burst_packets: int def elrs_budget(mode: ElrsMode, telemetry_denominator: int | None) -> ElrsBudget: """Один TLM-слот из D; None означает Off, а не отношение 1:1. Начальная фаза равномерна по времени. RC-слоты имеют интервалы T (D-2 раза) и 2T (один раз) в цикле DT. Не учитываются SYNC, DATA uplink, потери, LBT, мультиплексирование каналов и очередь UART. Повторные D/DK-режимы требуют иной модели и здесь отвергаются. """ if mode.repetitions != 1: raise ValueError("D/DK: частота RF-слотов не равна частоте новых команд") if telemetry_denominator is not None and ( type(telemetry_denominator) is not int or telemetry_denominator not in TELEMETRY_DENOMINATORS ): raise ValueError("Отношение должно быть Off или 1:128 ... 1:2") interval_ms = mode.interval_us / 1000 slot_rate = 1_000_000 / mode.interval_us fraction = 0.0 if telemetry_denominator is None else 1 / telemetry_denominator telemetry_rate = slot_rate * fraction data_bytes = 5 if mode.ota_packet_bytes == 8 else 10 burst = 0 with_stats = 0.0 if telemetry_denominator is not None: # Точное целочисленное вычисление updateTelemetryBurst/common.cpp. integer_rate = 1_000_000 // mode.interval_us burst = max(1, 512 * integer_rate // telemetry_denominator // 1000 - 1) # LINK содержит 4 байта статистики, оставляя DATA 1 либо 6 байт. with_stats = telemetry_rate * (data_bytes - 4 / (burst + 1)) return ElrsBudget( interval_ms, slot_rate, fraction, telemetry_rate, slot_rate * (1 - fraction), interval_ms / 2, interval_ms, interval_ms * (0.5 + fraction), interval_ms * (1 if telemetry_denominator is None else 2), data_bytes, telemetry_rate * data_bytes, with_stats, burst, ) @dataclass(frozen=True) class ImageAccounting: jpeg_bytes: int rf_frame_count: int fragment_header_bytes_total: int padding_bytes_total: int packet_bytes_total: int packet_overhead_bytes_total: int fec_coded_bits_total: int phy_header_bits_total: int preamble_bits_total: int transmitted_bpsk_bits_total: int guard_and_filter_intervals_total: int symbol_intervals_total: int def image_accounting(data: bytes) -> ImageAccounting: """Получить расходы из настоящих планов Lab046/Lab045, без формы IQ.""" if not data: raise ValueError("Объект не должен быть пустым") from experiments import lab046_bpsk_object_transport as lab046 frames = lab046.build_object_frames(data) plans = [frame.plan for frame in frames] payload_bytes = sum(len(plan.payload) for plan in plans) packet_bytes = sum(len(plan.packet) for plan in plans) transmitted_bits = sum(plan.frame_symbol_count for plan in plans) waveform_samples = sum(plan.waveform_sample_count for plan in plans) symbol_intervals, remainder = divmod(waveform_samples, lab046.lab045.SAMPLES_PER_SYMBOL) if remainder: raise ValueError("Форма не занимает целое число символьных интервалов") return ImageAccounting( len(data), len(frames), len(frames) * lab046.fragments.FRAGMENT_HEADER_SIZE, payload_bytes - sum(frame.encoded_payload_length for frame in frames), packet_bytes, packet_bytes - payload_bytes, sum(len(plan.coded_bits) for plan in plans), sum(len(plan.phy_header_bits) for plan in plans), sum(plan.frame_symbol_count - len(plan.post_preamble_bits) for plan in plans), transmitted_bits, symbol_intervals - transmitted_bits, symbol_intervals, ) def jpeg_payload_bitrate(jpeg_bytes: int, frames_per_second: float) -> float: if type(jpeg_bytes) is not int or jpeg_bytes <= 0: raise ValueError("Размер JPEG должен быть положительным целым") _positive(frames_per_second, "Частота изображений") return jpeg_bytes * 8 * frames_per_second def distance_per_frame(speed_kmh: float, frames_per_second: float) -> float: _positive(frames_per_second, "Частота изображений") if isinstance(speed_kmh, bool) or not math.isfinite(speed_kmh) or speed_kmh < 0: raise ValueError("Скорость должна быть конечной и неотрицательной") return speed_kmh / 3.6 / frames_per_second def video_requirement(accounting: ImageAccounting, frames_per_second: float) -> dict: """Нижние границы без потерь и объёмных данных; задержка — предложение.""" payload_bits = jpeg_payload_bitrate(accounting.jpeg_bytes, frames_per_second) return { "target_fps": frames_per_second, "jpeg_bytes": accounting.jpeg_bytes, "payload_bytes_per_second": payload_bits / 8, "payload_kbit_per_second": payload_bits / 1000, "transmitted_bpsk_kbit_per_second": accounting.transmitted_bpsk_bits_total * frames_per_second / 1000, "minimum_phy_symbol_rate_including_guards": accounting.symbol_intervals_total * frames_per_second, "rx_images_per_second_minimum": frames_per_second, "rx_rf_frames_per_second_minimum": accounting.rf_frame_count * frames_per_second, "rx_service_budget_ms_per_image": 1000 / frames_per_second, "proposed_e2e_image_latency_limit_ms": 1000 / frames_per_second, "e2e_limit_is_approved_or_measured": False, "distance_per_image_m": {str(speed): distance_per_frame(speed, frames_per_second) for speed in (10, 15, 25)}, } def sdr_capacity(accounting: ImageAccounting, symbol_rate: float, *, bulk_symbol_intervals_per_second: float = 0.0) -> dict: """Идеальная ёмкость видео; резерв только под явно заданные SDR-данные.""" _positive(symbol_rate, "Символьная скорость") if (isinstance(bulk_symbol_intervals_per_second, bool) or not math.isfinite(bulk_symbol_intervals_per_second) or not 0 <= bulk_symbol_intervals_per_second <= symbol_rate): raise ValueError("Резерв SDR-данных должен лежать между нулём и ёмкостью") available = symbol_rate - bulk_symbol_intervals_per_second fps = available / accounting.symbol_intervals_total return { "symbol_rate": symbol_rate, "jpeg_airtime_seconds": accounting.symbol_intervals_total / symbol_rate, "ideal_video_fps": fps, "ideal_payload_kbit_per_second": accounting.jpeg_bytes * 8 * fps / 1000, "sdr_rc_reserved_symbol_intervals_per_second": 0, "bulk_reserved_symbol_intervals_per_second": bulk_symbol_intervals_per_second, "is_measured_streaming_fps": False, } def run_functional_tests() -> None: assert elrs_budget(ELRS_MODES[1], 16).telemetry_slots_per_second == 3.125 assert jpeg_payload_bitrate(8724, 5) == 348_960 assert math.isclose(distance_per_frame(25, 5), 25 / 18) def main() -> None: run_functional_tests() jpeg_path = ROOT / "data/processed/lab047/tx_image.jpg" measurement_path = ROOT / "data/processed/lab049/independent/result.json" jpeg = jpeg_path.read_bytes() if len(jpeg) != 8724 or hashlib.sha256(jpeg).hexdigest() != JPEG_SHA256: raise ValueError("Контрольный JPEG отличается от Lab047") accounting = image_accounting(jpeg) measurement_bytes = measurement_path.read_bytes() measurements = json.loads(measurement_bytes) if measurements["jpeg"]["sha256"] != JPEG_SHA256: raise ValueError("Замеры относятся к другому JPEG") modes = [] for measured in measurements["modes"]: if measured["jpeg_rf_frames"] != accounting.rf_frame_count: raise ValueError("Число физических кадров изменилось") if not math.isclose(measured["jpeg_airtime_seconds"] * measured["symbol_rate"], accounting.symbol_intervals_total, rel_tol=1e-12): raise ValueError("Физический формат отличается от сохранённых замеров") row = sdr_capacity(accounting, measured["symbol_rate"]) row["reused_noisy_measurement"] = { key: measured["noisy"][key] for key in ( "receiver_processing_seconds", "capture_equivalent_seconds", "processing_to_capture_ratio", "valid_frames", "sha_match", "object_complete", ) } modes.append(row) result = { "stage": "Lab049 CRSF/ELRS architecture and separate SDR video budget", "source_checked_date": "2026-09-08", "elrs_version": ELRS_VERSION, "elrs_commit": ELRS_COMMIT, "sources": SOURCES, "jpeg_sha256": JPEG_SHA256, "accounting": asdict(accounting), "measurement_provenance": { "path": measurement_path.relative_to(ROOT).as_posix(), "sha256": hashlib.sha256(measurement_bytes).hexdigest(), "new_iq_measurement_performed": False, }, "assumptions": { "elrs_hardware_model_and_band_confirmed": False, "elrs_budgets_are_lossless_slot_opportunities_not_e2e_latency": True, "elrs_data_capacities_are_upper_bounds_not_application_goodput": True, "elrs_repetitions_in_calculator": 1, "sdr_control_telemetry_reservation": 0, "video_loss_retries_and_bulk_load": 0, "proposed_image_latency": "one image period; engineering proposal, not a measured or approved safety limit", }, "elrs_standard_ratio_modes": [ {"mode": asdict(mode), "budget": asdict(elrs_budget(mode, mode.standard_telemetry_denominator))} for mode in ELRS_MODES ], "elrs_100full_ratio_comparison": [ {"denominator": denominator, "budget": asdict(elrs_budget(ELRS_MODES[3], denominator))} for denominator in (*TELEMETRY_DENOMINATORS, None) ], "elrs_maximum_single_data_ceiling": asdict(elrs_budget(ELRS_MODES[-1], 2)), "video_requirements": [video_requirement(accounting, fps) for fps in (3, 5, 10)], "distance_per_image_m": {str(speed): {str(fps): distance_per_frame(speed, fps) for fps in (1, 3, 5, 10)} for speed in (10, 15, 25)}, "sdr_modes": modes, "rf_tx": 0, "hardware_access_performed": False, } output = ROOT / "data/processed/lab049/crsf_elrs_budget.json" output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(output.relative_to(ROOT).as_posix()) print(json.dumps({"accounting": asdict(accounting), "targets": result["video_requirements"], "sdr_modes": modes}, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()