Files
SDR-Rover/experiments/lab049_crsf_elrs_budget.py
2026-09-08 13:26:06 +03:00

305 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()