Lab049: define ELRS control and SDR video architecture
This commit is contained in:
1172
experiments/lab049_bpsk_symbol_rate.py
Normal file
1172
experiments/lab049_bpsk_symbol_rate.py
Normal file
File diff suppressed because it is too large
Load Diff
304
experiments/lab049_crsf_elrs_budget.py
Normal file
304
experiments/lab049_crsf_elrs_budget.py
Normal 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()
|
||||
190
experiments/lab049_independent_iq.py
Normal file
190
experiments/lab049_independent_iq.py
Normal 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]
|
||||
Reference in New Issue
Block a user