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,304 @@
"""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()