191 lines
10 KiB
Python
191 lines
10 KiB
Python
"""Полный независимый 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]
|