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

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,190 @@
"""Полный независимый IQ-проход Lab049 штатным приёмником Lab046B.
Адаптация старых лабораторий выполняется только в последовательном программном
процессе. Контекст возвращает константы и очищает зависящие от них кэши даже
при исключении. Аппаратные функции здесь не вызываются.
"""
from __future__ import annotations
from contextlib import contextmanager
from io import BytesIO
from pathlib import Path
import tempfile
import time
from types import SimpleNamespace
import numpy as np
from PIL import Image
from experiments import lab046b_continuous_iq as receiver
from experiments import lab046c_offline_recovery as offline
from experiments import lab049_bpsk_symbol_rate as lab
@contextmanager
def receiver_mode(mode: lab.PhyMode):
"""Временно задать только Rs и Fs/Rs для последовательного офлайн RX."""
modules = (lab.lab045, lab.lab045.lab044, lab.lab045.lab044.lab043)
originals = [(module, module.SYMBOL_RATE, module.SAMPLES_PER_SYMBOL)
for module in modules]
caches = (lab.lab045.lab044._preamble_reference_samples,
lab.lab045.lab044.lab043._matched_filter_taps,
lab.lab045.lab044.lab043._carrier_downconversion_vector)
try:
for module in modules:
module.SYMBOL_RATE = mode.symbol_rate
module.SAMPLES_PER_SYMBOL = mode.samples_per_symbol
for cache in caches:
cache.cache_clear()
yield
finally:
for module, rate, samples_per_symbol in originals:
module.SYMBOL_RATE = rate
module.SAMPLES_PER_SYMBOL = samples_per_symbol
for cache in caches:
cache.cache_clear()
def receive_stream(iq_samples: np.ndarray, mode: lab.PhyMode):
"""Поиск, CFO, PHY, FEC и CRC без границ TX или числа кадров."""
with receiver_mode(mode):
# У синтетического канала после добавления AWGN формат complex64.
# Подменяется только чтение IQ; блочный поиск и строгий декодер Lab048
# выполняются без изменений. Повторное CS8-квантование отсекло бы шум.
original_validate = offline._validate_capture
original_window = offline.lab046c._cs8_window
samples = np.asarray(iq_samples, dtype=np.complex64)
if samples.ndim != 1 or not samples.size or not np.all(np.isfinite(samples)):
raise ValueError("Нужен конечный одномерный IQ-поток")
try:
offline._validate_capture = lambda unused: (samples.view(np.float32), samples.size)
offline.lab046c._cs8_window = (
lambda raw, start, end: raw[2 * start:2 * end].view(np.complex64)
)
coarse, strict = offline.stream_process_capture(
Path("synthetic_iq_in_memory"),
offline.CandidateRule("FIXED_LAB047", 0.999,
lab.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD),
max_candidates=100_000, max_valid_frames=4096,
)
finally:
offline._validate_capture = original_validate
offline.lab046c._cs8_window = original_window
counters = strict.counters
return SimpleNamespace(
termination_reason=("END_OF_STREAM" if strict.termination_reason == "END_OF_CAPTURE"
else strict.termination_reason),
accepted_frames=strict.accepted_frames,
crc_pass_count=counters.crc_pass, crc_fail_count=counters.crc_fail,
phy_header_pass_count=counters.phy_header_pass,
fec_pass_count=counters.fec_pass,
preamble_candidates=coarse.candidate_count,
processed_candidates=counters.candidates_processed,
rejected_candidates=counters.candidates_processed - counters.valid_frames,
rejected_reasons=tuple(outcome.reason for outcome in strict.outcomes if not outcome.accepted),
)
def run_roundtrips(jpeg: bytes, mode: lab.PhyMode) -> tuple[dict, dict]:
"""Сформировать целый поток, завершить RX, затем открыть TX-диагностику."""
frames = tuple(lab.lab046.build_object_frames(jpeg, lab.lab047.OBJECT_ID))
frame_sizes = [lab.frame_waveform_sample_count(frame.plan, mode) for frame in frames]
# Не кратные символу интервалы известны только генератору канала.
gaps = [12007 + (index * 7919) % 10009 for index in range(len(frames) + 1)]
sample_count = sum(frame_sizes) + sum(gaps)
results = []
with tempfile.TemporaryDirectory(prefix="lab049_iq_") as temporary:
for noisy in (False, True):
path = Path(temporary) / "stream.c64"
iq_stream = np.memmap(path, dtype=np.complex64, mode="w+", shape=(sample_count,))
sigmas = []
cursor = gaps[0]
for index, frame in enumerate(frames):
iq = lab.lab045.lab044.signed_iq_bytes_to_complex64(
lab.build_parameterized_tx_cs8(frame.plan, mode)
)
if noisy:
# Одинаковые остаточный CFO, фаза и чередование полярности.
indexes = np.arange(iq.size, dtype=np.float64)
iq *= np.exp(1j * (0.73 + (index % 2) * np.pi
+ 2 * np.pi * 10.0 * indexes / mode.sample_rate_hz)).astype(np.complex64)
iq, sigma = lab.add_coded_bit_ebn0_noise(
iq, frame.plan.frame_symbol_count,
lab.NOISY_CODED_BIT_EBN0_DB, lab.NOISE_BASE_SEED + index,
)
sigmas.append(sigma)
random_generator = np.random.default_rng(149000 + index)
gap_start = cursor - gaps[index]
gap = sigma * (random_generator.standard_normal(gaps[index])
+ 1j * random_generator.standard_normal(gaps[index]))
iq_stream[gap_start:cursor] = gap.astype(np.complex64)
iq_stream[cursor:cursor + iq.size] = iq
cursor += iq.size + gaps[index + 1]
if noisy:
random_generator = np.random.default_rng(249000)
iq_stream[-gaps[-1]:] = (sigmas[-1] * (
random_generator.standard_normal(gaps[-1])
+ 1j * random_generator.standard_normal(gaps[-1])
)).astype(np.complex64)
iq_stream.flush()
started = time.perf_counter()
received = receive_stream(iq_stream, mode)
assembler = lab.lab046.ObjectAssembler()
for item in received.accepted_frames:
payload = item.reception.payload_bytes
if payload is not None:
assembler.offer(lab.lab046.recover_fragment_from_payload(payload))
recovered = assembler.recovered_object()
jpeg_decode = False
if recovered is not None:
with Image.open(BytesIO(recovered)) as image:
image.load()
jpeg_decode = (image.format, image.size, image.mode) == (
"JPEG", (320, 240), "RGB")
elapsed = time.perf_counter() - started
# Всё ниже находится после независимого решения и сборки.
accumulator = lab._RoundtripAccumulator(assembler, processing_seconds=elapsed,
phy_header_pass=received.phy_header_pass_count,
phy_header_fail=received.processed_candidates - received.phy_header_pass_count,
fec_pass=received.fec_pass_count, crc_pass=received.crc_pass_count,
fec_fail=received.phy_header_pass_count - received.fec_pass_count,
crc_not_reached=(received.processed_candidates
- received.crc_pass_count - received.crc_fail_count),
crc_fail=received.crc_fail_count, noise_sigmas=sigmas)
diagnosed = 0
by_index = {frame.fragment_index: frame for frame in frames}
for item in received.accepted_frames:
fragment = lab.lab046.recover_fragment_from_payload(item.reception.payload_bytes)
if fragment is None or fragment.fragment_index not in by_index:
continue
diagnostic = lab.lab045.diagnostic_pre_and_post_fec(
item.reception, item.post_preamble_soft, by_index[fragment.fragment_index].plan)
accumulator.pre_fec_errors += int(diagnostic["pre_fec_coded"]["bit_errors"])
accumulator.post_fec_errors += int(diagnostic["post_fec_packet"]["bit_errors"])
diagnosed += 1
summary = lab._roundtrip_summary(
accumulator, jpeg, len(frames), sample_count / mode.sample_rate_hz)
summary.update({
"receiver": "lab046c_offline_recovery.stream_process_capture (Lab048)",
"candidate_threshold": lab.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
"frame_aligned_iq": False, "tx_offsets_provided": False,
"residual_cfo_hz": 10.0 if noisy else 0.0,
"phase_rad": 0.73 if noisy else 0.0,
"polarity_alternates": noisy,
"jpeg_decode": jpeg_decode,
"preamble_candidates": received.preamble_candidates,
"processed_candidates": received.processed_candidates,
"rejected_candidates": received.rejected_candidates,
"rejected_reasons": list(received.rejected_reasons),
"termination_reason": received.termination_reason,
"diagnosed_frames": diagnosed,
"error_counts_cover_complete_object": diagnosed == len(frames),
"estimated_cfo_hz": [item.estimated_cfo_hz for item in received.accepted_frames],
"payload_airtime_seconds": sum(frame_sizes) / mode.sample_rate_hz,
"rf_tx": 0,
})
results.append(summary)
del iq_stream
path.unlink()
return results[0], results[1]