305 lines
15 KiB
Python
305 lines
15 KiB
Python
"""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()
|