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