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,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]