Lab049: define ELRS control and SDR video architecture
This commit is contained in:
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