"""Lab045: битовая FEC (свёрточный код R=1/2, K=7) поверх BPSK PHY. Настоящее помехоустойчивое кодирование исправляет неизвестные битовые ошибки канала, при которых незащищённый пакет Lab044 не дошёл до CRC (BER около 0,5-0,6 процента). Передающий тракт: произвольный payload -> protocol packet (0xAA55 + неизменённый CRC32) -> MSB-first bits -> convolutional encode (R=1/2, K=7, +6 tail bits) -> детерминированный блочный interleaver -> BPSK. Состав радиокадра: [PRBS7 preamble (1280 бит, Lab043)] [защищённый PHY header: каждый бит magic+version+length+checksum повторён 3x] [перемежённый свёрточно закодированный protocol packet] Приём (soft-decision, TX data не используются до независимого CRC-решения): независимая синхронизация по преамбуле (Lab044) -> soft BPSK values (normalized.real) -> majority-decode PHY header -> packet length -> deinterleave soft -> soft Viterbi -> packet bits -> parse_packet -> CRC -> payload. Физика RF-тракта Lab044 не меняется. Свёрточный код и перемежитель находятся в ``protocol/convolutional_fec.py`` и ``protocol/bit_interleaver.py``. Аппаратный путь в этой сессии заблокирован; проверка только синтетическая. RF TX = 0. """ from __future__ import annotations import argparse import hashlib import json import secrets import struct import subprocess import sys import time import zlib from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Callable if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np from experiments import lab044_hackrf_bpsk_packet as lab044 from experiments import lab043_pluto_to_rtlsdr as lab043_pluto from protocol import bit_interleaver as interleaver from protocol import bpsk_radio as radio from protocol import convolutional_fec as conv from protocol.packet import ( CRC_SIZE, HEADER_SIZE, CRCError, PacketError, build_packet, parse_packet, ) PAYLOAD_SIZE_BYTES = lab044.PAYLOAD_SIZE_BYTES # 256 MESSAGE_TYPE = lab044.MESSAGE_TYPE SEQUENCE_NUMBER = lab044.SEQUENCE_NUMBER MAX_PACKET_BYTES = lab044.MAX_PACKET_BYTES # 1024 MIN_PACKET_BYTES = HEADER_SIZE + CRC_SIZE PHY_MAGIC = lab044.PHY_SYNC # 0xD391 PHY_HEADER_VERSION = 1 PHY_HEADER_FIELDS_FORMAT = ">HBHB" # magic u16, version u8, length u16, checksum u8 PHY_HEADER_FIELDS_BYTES = struct.calcsize(PHY_HEADER_FIELDS_FORMAT) # 6 PHY_HEADER_FIELDS_BITS = PHY_HEADER_FIELDS_BYTES * 8 # 48 HEADER_REPETITION = 3 PHY_HEADER_BIT_COUNT = PHY_HEADER_FIELDS_BITS * HEADER_REPETITION # 144 INTERLEAVER_COLUMNS = 64 PREAMBLE_BIT_COUNT = lab044.PREAMBLE_BIT_COUNT SAMPLE_RATE_HZ = lab044.SAMPLE_RATE_HZ SYMBOL_RATE = lab044.SYMBOL_RATE SAMPLES_PER_SYMBOL = lab044.SAMPLES_PER_SYMBOL GUARD_SYMBOL_COUNT = lab043_pluto.LAB042_GUARD_SYMBOL_COUNT RRC_SPAN_SYMBOLS = lab043_pluto.LAB042_RRC_SPAN_SYMBOLS RF_HZ = lab044.RF_HZ CARRIER_OFFSET_HZ = lab044.CARRIER_OFFSET_HZ TX_VGA_DB = lab044.TX_VGA_DB RX_LNA_DB = lab044.RX_LNA_DB RX_VGA_DB = lab044.RX_VGA_DB CAPTURE_SAMPLE_COUNT = lab044.CAPTURE_SAMPLE_COUNT RX_LEAD_SECONDS = lab044.RX_LEAD_SECONDS RX_WAIT_TIMEOUT_SECONDS = lab044.RX_WAIT_TIMEOUT_SECONDS CRC_PASS = "PASS" CRC_FAIL = "FAIL" CRC_NOT_REACHED = "NOT_REACHED" def _phy_header_checksum(magic: int, version: int, length: int) -> int: """8-bit checksum of the header fields (crc32 truncated to one byte).""" body = struct.pack(">HBH", magic, version, length) return zlib.crc32(body) & 0xFF def encode_phy_header(packet_length_bytes: int) -> np.ndarray: """Защитить PHY header, повторив каждый отдельный бит как ``b,b,b``.""" if not MIN_PACKET_BYTES <= packet_length_bytes <= MAX_PACKET_BYTES: raise ValueError("protocol packet length outside Lab045 bounds") checksum = _phy_header_checksum(PHY_MAGIC, PHY_HEADER_VERSION, packet_length_bytes) fields = struct.pack( PHY_HEADER_FIELDS_FORMAT, PHY_MAGIC, PHY_HEADER_VERSION, packet_length_bytes, checksum, ) block_bits = lab044.bytes_to_bits(fields) return np.repeat(block_bits, HEADER_REPETITION) def decode_phy_header_soft(header_soft: np.ndarray) -> dict: """Majority-decode the PHY header from soft BPSK values, fail-closed. The three soft copies of each bit are summed (soft majority); the sign of the sum is the bit. Then strict magic/version/checksum/length validation. TX data is never used. """ try: values = np.asarray(header_soft, dtype=np.float64) except (TypeError, ValueError): return {"passed": False, "reason": "MALFORMED", "packet_bytes": None} if values.ndim != 1 or len(values) < PHY_HEADER_BIT_COUNT: return {"passed": False, "reason": "TRUNCATED", "packet_bytes": None} if not np.all(np.isfinite(values)): return {"passed": False, "reason": "NON_FINITE", "packet_bytes": None} bit_groups = values[:PHY_HEADER_BIT_COUNT].reshape( PHY_HEADER_FIELDS_BITS, HEADER_REPETITION, ) voted_soft = np.sum(bit_groups, axis=1) bits = (voted_soft >= 0.0).astype(np.uint8) try: fields = lab044.bits_to_bytes(bits) magic, version, length, checksum = struct.unpack(PHY_HEADER_FIELDS_FORMAT, fields) except (ValueError, struct.error): return {"passed": False, "reason": "DECODE_ERROR", "packet_bytes": None} if magic != PHY_MAGIC: return {"passed": False, "reason": "MAGIC_MISMATCH", "packet_bytes": None} if version != PHY_HEADER_VERSION: return {"passed": False, "reason": "VERSION_MISMATCH", "packet_bytes": None} if checksum != _phy_header_checksum(magic, version, length): return {"passed": False, "reason": "CHECKSUM_MISMATCH", "packet_bytes": None} if not MIN_PACKET_BYTES <= length <= MAX_PACKET_BYTES: return {"passed": False, "reason": "LENGTH_OUT_OF_RANGE", "packet_bytes": length} return {"passed": True, "reason": None, "packet_bytes": length} @dataclass(frozen=True) class TransmissionPlan: payload: bytes packet: bytes information_bits: np.ndarray coded_bits: np.ndarray interleaver_permutation: np.ndarray interleaved_bits: np.ndarray phy_header_bits: np.ndarray post_preamble_bits: np.ndarray frame_bits: np.ndarray @property def frame_symbol_count(self) -> int: return int(len(self.frame_bits)) @property def waveform_sample_count(self) -> int: """Точная длина формы Lab044 с двумя guard и полным RRC-хвостом.""" return ( (self.frame_symbol_count + 2 * GUARD_SYMBOL_COUNT) * SAMPLES_PER_SYMBOL + RRC_SPAN_SYMBOLS * SAMPLES_PER_SYMBOL ) @property def airtime_seconds(self) -> float: return self.waveform_sample_count / SAMPLE_RATE_HZ def build_transmission_plan(payload: bytes) -> TransmissionPlan: """Собрать полный кадр Lab045 вокруг обычного protocol packet.""" if not isinstance(payload, (bytes, bytearray)): raise TypeError("payload должен иметь тип bytes") payload = bytes(payload) if len(payload) != PAYLOAD_SIZE_BYTES: raise ValueError(f"Lab045 requires exactly {PAYLOAD_SIZE_BYTES} payload bytes") packet = build_packet(payload, MESSAGE_TYPE, SEQUENCE_NUMBER) information_bits = lab044.bytes_to_bits(packet) coded_bits = conv.encode(information_bits) permutation = interleaver.build_permutation(len(coded_bits), INTERLEAVER_COLUMNS) interleaved_bits = interleaver.interleave(coded_bits, permutation) phy_header_bits = encode_phy_header(len(packet)) post_preamble_bits = np.concatenate((phy_header_bits, interleaved_bits)) frame_bits = np.concatenate((lab044.preamble_bits(), post_preamble_bits)) plan = TransmissionPlan( payload=payload, packet=packet, information_bits=information_bits, coded_bits=coded_bits, interleaver_permutation=permutation, interleaved_bits=interleaved_bits, phy_header_bits=phy_header_bits, post_preamble_bits=post_preamble_bits, frame_bits=frame_bits, ) _validate_transmission_plan(plan) return plan def _validate_transmission_plan(plan: TransmissionPlan) -> None: expected_packet = HEADER_SIZE + PAYLOAD_SIZE_BYTES + CRC_SIZE if len(plan.packet) != expected_packet: raise ValueError("неверный размер protocol packet") if len(plan.coded_bits) != conv.encoded_bit_count(len(plan.information_bits)): raise ValueError("неверная длина coded stream") if len(plan.phy_header_bits) != PHY_HEADER_BIT_COUNT: raise ValueError("неверная длина PHY header") if len(plan.interleaved_bits) != len(plan.coded_bits): raise ValueError("interleaver изменил длину") if not np.array_equal( interleaver.deinterleave(plan.interleaved_bits, plan.interleaver_permutation), plan.coded_bits, ): raise ValueError("round trip перемежителя не совпал") decoded_header = decode_phy_header_soft(modulate_bits_to_ideal_soft(plan.phy_header_bits)) if not decoded_header["passed"] or decoded_header["packet_bytes"] != len(plan.packet): raise ValueError("round trip защищённого PHY header не совпал") def modulate_bits_to_ideal_soft(bits: np.ndarray) -> np.ndarray: """Идеальные soft BPSK: бит 0 -> -1, бит 1 -> +1.""" raw_bits = np.asarray(bits) if raw_bits.ndim != 1: raise ValueError("bits должен быть одномерным") if not np.all((raw_bits == 0) | (raw_bits == 1)): raise ValueError("bits должен содержать только 0 и 1") return 2.0 * raw_bits.astype(np.float64) - 1.0 def build_tx_cs8(plan: TransmissionPlan) -> bytes: """Собрать cs8-форму через тот же проверенный формирователь, что и Lab044.""" waveform = lab044._scale_and_carrier(radio.bpsk_modulate(plan.frame_bits)) tx_cs8 = lab044.lab043.to_cs8_bytes(waveform) if len(tx_cs8) != 2 * plan.waveform_sample_count: raise ValueError("длина cs8-формы не совпала с расчётом") return tx_cs8 @dataclass(frozen=True) class IndependentReception: analysis: dict packet_bytes: bytes | None payload_bytes: bytes | None Analyzer = Callable[[np.ndarray], IndependentReception] @dataclass(frozen=True) class SoftSymbolExtraction: """Soft BPSK после независимых sync/timing/CFO/phase, без TX payload.""" passed: bool normalized_real: np.ndarray diagnostics: dict def extract_soft_symbols_from_capture(samples: np.ndarray) -> SoftSymbolExtraction: """Переиспользовать проверенную синхронизацию Lab044 до hard slicing. Функция принимает только IQ-запись. Из Lab044 используются независимый детектор преамбулы, timing, CFO и phase/polarity recovery. Результат — именно ``normalized.real`` для всей найденной последовательности символов. Переданный пакет или payload этой функции неизвестны. """ try: samples = np.asarray(samples, dtype=np.complex64) except (TypeError, ValueError): return SoftSymbolExtraction( False, np.empty(0), {"reason": "MALFORMED_CAPTURE"}, ) if samples.ndim != 1 or samples.size == 0 or not np.all(np.isfinite(samples)): return SoftSymbolExtraction( False, np.empty(0), {"reason": "INVALID_CAPTURE"}, ) raw = lab044.lab043.raw_statistics(samples) detection = lab044._detect_preamble(samples) base_diagnostics = {"raw": raw, "detection": detection} if not detection["preamble_detected"]: return SoftSymbolExtraction(False, np.empty(0), base_diagnostics) maximum_coded_bits = conv.encoded_bit_count(MAX_PACKET_BYTES * 8) maximum_frame_symbols = ( PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + maximum_coded_bits ) maximum_waveform_samples = ( (maximum_frame_symbols + 2 * GUARD_SYMBOL_COUNT) * SAMPLES_PER_SYMBOL + RRC_SPAN_SYMBOLS * SAMPLES_PER_SYMBOL ) matched, nominal = lab044.lab043._matched_waveform( samples, detection["refined_start_sample"], maximum_waveform_samples, ) expected_preamble_symbols = radio.bpsk_modulate(lab044.preamble_bits()) timing, _ = lab044.lab043._timing_offsets( matched, nominal, expected_preamble_symbols, ) carrier = lab044.lab043._evaluate_symbols( matched, nominal, timing, lab044.preamble_bits(), PREAMBLE_BIT_COUNT, ) diagnostics = { **base_diagnostics, "timing_offset_samples": timing, "carrier": carrier, } if carrier["abs_h"] == 0.0: return SoftSymbolExtraction(False, np.empty(0), diagnostics) available_symbols = matched[ nominal + timing : nominal + timing + maximum_frame_symbols * SAMPLES_PER_SYMBOL : SAMPLES_PER_SYMBOL ].astype(np.complex128) symbol_time = (np.arange(len(available_symbols)) + 0.5) / SYMBOL_RATE normalized = ( available_symbols * np.exp(-2j * np.pi * carrier["cfo_hz"] * symbol_time) / (carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])) ) normalized_real = normalized.real.astype(np.float64, copy=False) diagnostics["available_symbol_count"] = int(len(normalized_real)) diagnostics["preamble_hard_errors"] = int( np.count_nonzero( (normalized_real[:PREAMBLE_BIT_COUNT] >= 0.0).astype(np.uint8) != lab044.preamble_bits()[: len(normalized_real)] ) ) return SoftSymbolExtraction(True, normalized_real, diagnostics) def analyze_capture_independently(samples: np.ndarray) -> IndependentReception: """Принять Lab045 из IQ без доступа к переданному пакету или payload.""" extraction = extract_soft_symbols_from_capture(samples) if not extraction.passed or len(extraction.normalized_real) < PREAMBLE_BIT_COUNT: analysis = { "phy_sync_pass": False, "phy_header_pass": False, "fec_decode_pass": False, "packet_recovered": False, "protocol_header_pass": False, "crc": CRC_NOT_REACHED, "classification": "D", "soft_extraction": extraction.diagnostics, "capture_intervals": { "before": None, "during": None, "after": None, "frame_start_sample": None, "frame_end_sample": None, "note": "DURING не определён: независимый preamble не найден", "whole_capture": extraction.diagnostics.get("raw"), }, } return IndependentReception(analysis, None, None) reception = analyze_post_preamble_soft( extraction.normalized_real[PREAMBLE_BIT_COUNT:] ) packet_bytes_hint = reception.analysis.get("phy_header", {}).get("packet_bytes") classification = reception.analysis["classification"] if not reception.analysis["phy_header_pass"]: classification = "D" analysis = { **reception.analysis, "phy_sync_pass": True, "classification": classification, "soft_extraction": extraction.diagnostics, "capture_intervals": _capture_diagnostics( samples, extraction.diagnostics["detection"], packet_bytes_hint, ), } return IndependentReception( analysis, reception.packet_bytes, reception.payload_bytes, ) def _capture_diagnostics( samples: np.ndarray, detection: dict, packet_bytes_hint: int | None, ) -> dict: """BEFORE/DURING/AFTER по независимому start и принятой PHY length.""" if not detection.get("preamble_detected"): return { "before": None, "during": None, "after": None, "frame_start_sample": None, "frame_end_sample": None, "note": "DURING не определён: независимый preamble не найден", "whole_capture": lab044.lab043.raw_statistics(samples), } if ( packet_bytes_hint is not None and MIN_PACKET_BYTES <= packet_bytes_hint <= MAX_PACKET_BYTES ): coded_bits = conv.encoded_bit_count(packet_bytes_hint * 8) frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + coded_bits note = "DURING построен по preamble start и принятой PHY length" else: coded_bits = conv.encoded_bit_count(MAX_PACKET_BYTES * 8) frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + coded_bits note = "DURING построен по preamble start и максимальной длине Lab045" frame_length_samples = ( (frame_symbols + 2 * GUARD_SYMBOL_COUNT) * SAMPLES_PER_SYMBOL + RRC_SPAN_SYMBOLS * SAMPLES_PER_SYMBOL ) frame_start = int(detection["refined_start_sample"]) intervals = lab044.lab043._capture_intervals( samples, frame_start, frame_length_samples, ) bounds = { "before": (0, intervals["frame_start_sample"]), "during": (intervals["frame_start_sample"], intervals["frame_end_sample"]), "after": (intervals["frame_end_sample"], len(samples)), } for key, (start, end) in bounds.items(): if intervals[key] is not None: intervals[key]["sample_range"] = [int(start), int(end)] intervals[key]["duration_seconds"] = (end - start) / SAMPLE_RATE_HZ intervals["note"] = note return intervals def analyze_post_preamble_soft(post_preamble_soft: np.ndarray) -> IndependentReception: """Независимо декодировать soft values после преамбулы, без TX data. Строгий порядок: PHY header (majority) -> deinterleave -> soft Viterbi -> protocol packet -> CRC. Protocol parser видит только результат FEC. """ try: soft = np.asarray(post_preamble_soft, dtype=np.float64) except (TypeError, ValueError): soft = np.empty((0, 0), dtype=np.float64) if soft.ndim != 1: analysis = { "phy_header_pass": False, "fec_decode_pass": False, "packet_recovered": False, "protocol_header_pass": False, "crc": CRC_NOT_REACHED, "classification": "C", "phy_header": {"passed": False, "reason": "MALFORMED_SHAPE", "packet_bytes": None}, "packet_bytes": None, } return IndependentReception(analysis, None, None) header_input = soft[:PHY_HEADER_BIT_COUNT] if len(soft) >= PHY_HEADER_BIT_COUNT else soft header = decode_phy_header_soft(header_input) base = { "phy_header_pass": False, "fec_decode_pass": False, "packet_recovered": False, "protocol_header_pass": False, "crc": CRC_NOT_REACHED, "classification": "C", "phy_header": header, "packet_bytes": None, } if not header["passed"]: return IndependentReception(base, None, None) packet_length = int(header["packet_bytes"]) information_bit_count = packet_length * 8 coded_length = conv.encoded_bit_count(information_bit_count) coded_start = PHY_HEADER_BIT_COUNT coded_end = coded_start + coded_length if len(soft) < coded_end: base["phy_header_pass"] = True base["reason"] = "TRUNCATED_CODED_FRAME" return IndependentReception(base, None, None) coded_soft = soft[coded_start:coded_end] permutation = interleaver.build_permutation(coded_length, INTERLEAVER_COLUMNS) deinterleaved_soft = interleaver.deinterleave(coded_soft, permutation) try: information_bits = conv.viterbi_decode_soft( deinterleaved_soft, information_bit_count, ) except (TypeError, ValueError) as error: base["phy_header_pass"] = True base["reason"] = f"FEC_DECODE_ERROR: {error}" return IndependentReception(base, None, None) packet_bytes = lab044.bits_to_bytes(information_bits) crc_status = CRC_NOT_REACHED protocol_header = lab044.inspect_protocol_header(packet_bytes) protocol_header_pass = bool(protocol_header["passed"]) payload_bytes: bytes | None = None classification = "C" if protocol_header_pass: try: parsed = parse_packet(packet_bytes) except CRCError: crc_status = CRC_FAIL classification = "B" except PacketError as error: protocol_header = { **protocol_header, "passed": False, "reason": f"PACKET_ERROR: {error}", } protocol_header_pass = False else: crc_status = CRC_PASS payload_bytes = parsed.payload classification = "A" analysis = { "phy_header_pass": True, "fec_decode_pass": True, "packet_recovered": True, "protocol_header_pass": protocol_header_pass, "crc": crc_status, "classification": classification, "phy_header": header, "protocol_header": protocol_header, "packet_bytes": packet_length, "payload_known_before_decode": False, } return IndependentReception(analysis, packet_bytes, payload_bytes) def _bit_comparison(received: np.ndarray, expected: np.ndarray) -> dict: received = np.asarray(received, dtype=np.uint8) expected = np.asarray(expected, dtype=np.uint8) if received.shape != expected.shape: return {"comparable": False, "bit_errors": None, "ber": None, "total_bits": int(expected.size)} errors = int(np.count_nonzero(received != expected)) return { "comparable": True, "bit_errors": errors, "ber": errors / expected.size if expected.size else 0.0, "total_bits": int(expected.size), } def diagnostic_pre_and_post_fec( reception: IndependentReception, post_preamble_soft: np.ndarray, plan: TransmissionPlan, ) -> dict: """Сравнить с TX только ПОСЛЕ независимого CRC-решения. Pre-FEC: hard slice принятого coded stream в канальном порядке сравнивается с переданными interleaved coded bits. Post-FEC: декодированный packet сравнивается с переданным. TX data никогда не исправляют приём. """ soft = np.asarray(post_preamble_soft, dtype=np.float64) coded_length = len(plan.interleaved_bits) coded_soft = soft[PHY_HEADER_BIT_COUNT:PHY_HEADER_BIT_COUNT + coded_length] received_coded_hard = (coded_soft >= 0.0).astype(np.uint8) pre_fec = _bit_comparison(received_coded_hard, plan.interleaved_bits) if reception.packet_bytes is not None and len(reception.packet_bytes) == len(plan.packet): received_packet_bits = lab044.bytes_to_bits(reception.packet_bytes) post_fec_packet = _bit_comparison(received_packet_bits, plan.information_bits) else: post_fec_packet = {"comparable": False, "bit_errors": None, "ber": None, "total_bits": len(plan.information_bits)} diagnostic_payload = reception.payload_bytes if diagnostic_payload is None and reception.packet_bytes is not None: candidate = reception.packet_bytes[HEADER_SIZE:-CRC_SIZE] if len(candidate) == len(plan.payload): diagnostic_payload = candidate if diagnostic_payload is not None and len(diagnostic_payload) == len(plan.payload): received_payload_bits = lab044.bytes_to_bits(diagnostic_payload) expected_payload_bits = lab044.bytes_to_bits(plan.payload) payload_bit = _bit_comparison(received_payload_bits, expected_payload_bits) payload_bit["byte_errors"] = int(np.count_nonzero( np.frombuffer(diagnostic_payload, dtype=np.uint8) != np.frombuffer(plan.payload, dtype=np.uint8) )) payload_bit["bit_perfect"] = bool(diagnostic_payload == plan.payload) else: payload_bit = {"comparable": False, "bit_errors": None, "ber": None, "byte_errors": None, "bit_perfect": False} return { "stage": "AFTER_INDEPENDENT_CRC_DECISION", "receiver_knew_payload_before_decode": False, "pre_fec_coded": pre_fec, "post_fec_packet": post_fec_packet, "post_fec_payload": payload_bit, } def simulate_soft_channel( plan: TransmissionPlan, bit_error_probability: float = 0.0, noise_sigma: float = 0.0, seed: int | None = None, burst_positions: tuple[int, ...] | None = None, ) -> np.ndarray: """Собрать soft BPSK после преамбулы для программной проверки. К идеальным символам +/-1 можно добавить Gaussian noise, независимые инверсии знака с вероятностью ``bit_error_probability`` и явную пачку инверсий. Возвращается только принимаемая часть после преамбулы: защищённый header и coded body. """ if not 0.0 <= bit_error_probability <= 1.0: raise ValueError("bit_error_probability должен быть в диапазоне 0..1") if not np.isfinite(noise_sigma) or noise_sigma < 0.0: raise ValueError("noise_sigma должен быть конечным неотрицательным") generator = np.random.default_rng(seed) ideal = modulate_bits_to_ideal_soft(plan.post_preamble_bits) soft = ideal.copy() if noise_sigma > 0.0: soft = soft + generator.normal(0.0, noise_sigma, size=soft.shape) if bit_error_probability > 0.0: flips = generator.random(size=soft.shape) < bit_error_probability soft[flips] = -soft[flips] if burst_positions: positions = np.asarray(burst_positions, dtype=np.int64) if positions.ndim != 1 or np.any(positions < 0) or np.any(positions >= len(soft)): raise ValueError("burst_positions содержит недопустимый индекс") soft[positions] = -soft[positions] return soft def run_functional_tests() -> None: """Быстрая программная самопроверка полного тракта без аппаратуры.""" plan = build_transmission_plan(bytes(range(PAYLOAD_SIZE_BYTES))) reception = analyze_post_preamble_soft(simulate_soft_channel(plan)) if reception.analysis["crc"] != CRC_PASS or reception.payload_bytes != plan.payload: raise AssertionError("идеальный программный тракт Lab045 не восстановил пакет") PayloadFactory = Callable[[int], bytes] DiagnosticComparer = Callable[[IndependentReception, np.ndarray, Path, Path], dict] ProcessChecker = Callable[[], list[str]] def diagnostic_compare_after_decode( reception: IndependentReception, samples: np.ndarray, tx_payload_path: Path, tx_packet_path: Path, ) -> dict: """После независимого CRC прочитать TX artifacts и посчитать FEC-метрики.""" tx_payload = tx_payload_path.read_bytes() tx_packet = tx_packet_path.read_bytes() plan = build_transmission_plan(tx_payload) if plan.packet != tx_packet: raise RuntimeError("TX packet artifact не совпал с восстановленным планом") extraction = extract_soft_symbols_from_capture(samples) if not extraction.passed or len(extraction.normalized_real) < PREAMBLE_BIT_COUNT: post_preamble_soft = np.empty(0, dtype=np.float64) else: post_preamble_soft = extraction.normalized_real[PREAMBLE_BIT_COUNT:] return diagnostic_pre_and_post_fec(reception, post_preamble_soft, plan) def _verify_device_inventory(runner: lab044.lab043.CommandRunner) -> dict: """Read-only подтвердить обе ожидаемые платы по serial через hackrf_info.""" tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_info.exe") result = runner([tool]) output = "\n".join( part.strip() for part in ((result.stdout or ""), (result.stderr or "")) if part.strip() ) if result.returncode != 0: raise RuntimeError(f"hackrf_info завершился с кодом {result.returncode}: {output}") serials = sorted(set( token.strip() for token in output.replace("\r", "").replace("\n", " ").split() if len(token.strip()) == 32 and all(character in "0123456789abcdefABCDEF" for character in token.strip()) )) expected = {lab044.lab043.MASTER_SERIAL, lab044.lab043.SLAVE_SERIAL} if not expected.issubset(serials): raise RuntimeError( "hackrf_info не подтвердил обе serial-роли: " f"ожидалось {sorted(expected)}, найдено {serials}" ) return { "passed": True, "serials": serials, "master_serial": lab044.lab043.MASTER_SERIAL, "slave_serial": lab044.lab043.SLAVE_SERIAL, "raw_output": output, } def _transfer_issue_count(text: str, issue: str) -> int: """Извлечь явные счётчики overrun/underrun; чистое отсутствие равно нулю.""" import re matches = re.findall(rf"(\d+)\s+(?:rx\s+|tx\s+)?{issue}s?", text.lower()) return sum(int(value) for value in matches) def _running_hackrf_transfer_processes() -> list[str]: """Read-only перечислить оставшиеся процессы hackrf_transfer.exe.""" result = subprocess.run( ["tasklist", "/FI", "IMAGENAME eq hackrf_transfer.exe", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: raise RuntimeError(f"tasklist завершился с кодом {result.returncode}: {result.stderr}") return [ line.strip() for line in result.stdout.splitlines() if "hackrf_transfer.exe" in line.lower() ] def _artifact_hashes(output_directory: Path, names: list[str]) -> dict[str, str | None]: return { name: ( lab044.lab043._sha256_file(output_directory / name) if (output_directory / name).is_file() else None ) for name in names } def default_output_directory() -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return ( Path.home() / "Documents" / "SDR_Rover_Lab045_raw" / f"{timestamp}_bpsk_fec" ) def run_hardware_experiment( output_directory: Path, runner: lab044.lab043.CommandRunner | None = None, process_starter: lab044.lab043.ProcessStarter | None = None, sleep_function: lab044.lab043.SleepFunction = time.sleep, payload_factory: PayloadFactory = secrets.token_bytes, analyzer: Analyzer = analyze_capture_independently, comparer: DiagnosticComparer = diagnostic_compare_after_decode, process_checker: ProcessChecker = _running_hackrf_transfer_processes, ) -> dict: """Один fail-closed Lab045 run: preflight, RX, ровно один TX, cleanup.""" runner = runner or lab044.lab043._default_command_runner process_starter = process_starter or lab044.lab043._default_process_starter payload = payload_factory(PAYLOAD_SIZE_BYTES) plan = build_transmission_plan(payload) tx_cs8 = build_tx_cs8(plan) if len(tx_cs8) != 2 * plan.waveform_sample_count: raise ValueError("TX waveform не прошёл frame accounting") output_directory.mkdir(parents=True, exist_ok=True) tx_payload_path = output_directory / "tx_payload.bin" tx_packet_path = output_directory / "tx_packet.bin" tx_waveform_path = output_directory / "tx_bpsk_fec_cs8.iq" tx_payload_path.write_bytes(plan.payload) tx_packet_path.write_bytes(plan.packet) tx_waveform_path.write_bytes(tx_cs8) guard = lab044.lab043.TransmitGuard() rx_process: lab044.lab043.RunningCommand | None = None cleanup: dict = {} failure: Exception | None = None inventory: dict | None = None preflight: dict | None = None reception: IndependentReception | None = None expected_size: int | None = None raw_sha_first: str | None = None raw_sha_second: str | None = None rx_overruns: int | None = None tx_underruns: int | None = None try: inventory = _verify_device_inventory(runner) preflight = lab044.lab043.common_clock_preflight(runner, guard) rx_path = output_directory / "rx_cs8.iq" rx_stdout_path = output_directory / "rx_stdout.txt" rx_stderr_path = output_directory / "rx_stderr.txt" tx_stdout_path = output_directory / "tx_stdout.txt" tx_stderr_path = output_directory / "tx_stderr.txt" transfer_tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe") rx_command = [ transfer_tool, "-d", lab044.lab043.SLAVE_SERIAL, "-r", str(rx_path), "-f", str(RF_HZ), "-s", str(SAMPLE_RATE_HZ), "-n", str(CAPTURE_SAMPLE_COUNT), "-l", str(RX_LNA_DB), "-g", str(RX_VGA_DB), "-a", "0", "-p", "0", "-B", ] rx_process = process_starter(rx_command, rx_stdout_path, rx_stderr_path) sleep_function(RX_LEAD_SECONDS) if rx_process.poll() is not None: rx_process.close_logs() detail = rx_stderr_path.read_text(encoding="utf-8", errors="replace") raise RuntimeError(f"RX завершился до разрешения TX: {detail.strip()}") tx_command = [ transfer_tool, "-d", lab044.lab043.MASTER_SERIAL, "-t", str(tx_waveform_path), "-f", str(RF_HZ), "-s", str(SAMPLE_RATE_HZ), "-n", str(plan.waveform_sample_count), "-x", str(TX_VGA_DB), "-a", "0", "-p", "0", "-B", ] guard.register_transmit() tx_result = runner(tx_command) tx_stdout_path.write_text(tx_result.stdout or "", encoding="utf-8") tx_stderr_path.write_text(tx_result.stderr or "", encoding="utf-8") if tx_result.returncode != 0: raise RuntimeError(f"Единственный TX завершился с кодом {tx_result.returncode}") rx_returncode = rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) if rx_returncode != 0: raise RuntimeError(f"RX завершился с кодом {rx_returncode}") expected_size = CAPTURE_SAMPLE_COUNT * 2 if rx_path.stat().st_size != expected_size: raise RuntimeError("Размер raw не совпал с запрошенным числом samples") raw_sha_first = lab044.lab043._sha256_file(rx_path) raw_sha_second = lab044.lab043._sha256_file(rx_path) if raw_sha_first != raw_sha_second: raise RuntimeError("SHA-256 raw изменился после закрытия") rx_log = rx_stdout_path.read_text(encoding="utf-8", errors="replace") + rx_stderr_path.read_text( encoding="utf-8", errors="replace" ) tx_log = (tx_result.stdout or "") + (tx_result.stderr or "") if not lab044.lab043._transfer_log_is_clean(rx_log): raise RuntimeError("RX log содержит overrun/underrun") if not lab044.lab043._transfer_log_is_clean(tx_log): raise RuntimeError("TX log содержит overrun/underrun") rx_overruns = _transfer_issue_count(rx_log, "overrun") tx_underruns = _transfer_issue_count(tx_log, "underrun") samples = lab044.lab043.load_cs8(rx_path) reception = analyzer(samples) reception.analysis["diagnostic_comparison"] = comparer( reception, samples, tx_payload_path, tx_packet_path, ) comparison = reception.analysis["diagnostic_comparison"] pre_errors = comparison["pre_fec_coded"]["bit_errors"] post_errors = comparison["post_fec_packet"]["bit_errors"] payload_perfect = comparison["post_fec_payload"]["bit_perfect"] correction_demonstrated = bool( pre_errors is not None and pre_errors > 0 and post_errors == 0 and reception.analysis["crc"] == CRC_PASS and payload_perfect ) reception.analysis["real_rf_fec_correction_demonstrated"] = correction_demonstrated if reception.analysis["classification"] == "A" and not payload_perfect: reception.analysis["classification"] = "C" reception.analysis["classification_reason"] = "CRC_PASS_BUT_TX_PAYLOAD_MISMATCH" if reception.packet_bytes is not None: (output_directory / "rx_packet.bin").write_bytes(reception.packet_bytes) if reception.payload_bytes is not None: (output_directory / "rx_payload.bin").write_bytes(reception.payload_bytes) except Exception as error: failure = error finally: if rx_process is not None: try: rx_process.terminate() except Exception as error: cleanup.setdefault("rx_process_errors", []).append(str(error)) clock_errors = lab044.lab043._disable_clock_outputs(runner) cleanup["clock_outputs_off"] = not clock_errors cleanup["clock_output_errors"] = clock_errors try: lingering = process_checker() except Exception as error: cleanup["lingering_hackrf_transfer"] = None cleanup["process_check_error"] = str(error) else: cleanup["lingering_hackrf_transfer"] = lingering cleanup["no_lingering_hackrf_transfer"] = not lingering if failure is not None: failure_record = { "classification": "E" if guard.transmit_calls == 0 else "HARDWARE_ERROR_AFTER_TX", "error": f"{type(failure).__name__}: {failure}", "rf_tx_count": guard.transmit_calls, "inventory": inventory, "preflight": preflight, "cleanup": cleanup, } (output_directory / "failure.json").write_text( json.dumps(failure_record, ensure_ascii=False, indent=2), encoding="utf-8", ) raise failure assert inventory is not None and preflight is not None and reception is not None assert expected_size is not None and raw_sha_first is not None and raw_sha_second is not None artifact_names = [ "tx_payload.bin", "tx_packet.bin", "tx_bpsk_fec_cs8.iq", "rx_cs8.iq", "rx_packet.bin", "rx_payload.bin", ] artifact_hashes_first = _artifact_hashes(output_directory, artifact_names) artifact_hashes_second = _artifact_hashes(output_directory, artifact_names) metadata = { "created_utc": datetime.now(timezone.utc).isoformat(), "software_gate_passed_before_rf": True, "payload_bytes": len(plan.payload), "protocol_packet_bytes": len(plan.packet), "information_bits": len(plan.information_bits), "tail_bits": conv.TAIL_BIT_COUNT, "coded_bits": len(plan.coded_bits), "interleaver_columns": INTERLEAVER_COLUMNS, "unprotected_phy_header_bits": PHY_HEADER_FIELDS_BITS, "protected_phy_header_bits": PHY_HEADER_BIT_COUNT, "preamble_bits": PREAMBLE_BIT_COUNT, "bpsk_frame_symbols": plan.frame_symbol_count, "guard_symbol_intervals": 2 * GUARD_SYMBOL_COUNT, "rrc_tail_symbol_intervals": RRC_SPAN_SYMBOLS, "waveform_symbol_time_equivalents": plan.frame_symbol_count + 2 * GUARD_SYMBOL_COUNT + RRC_SPAN_SYMBOLS, "waveform_samples": plan.waveform_sample_count, "active_bpsk_seconds": plan.frame_symbol_count / SYMBOL_RATE, "waveform_seconds": plan.airtime_seconds, "frame_accounting_note": "42 extra symbol-times = 32 zero guards + 10 full RRC tail; not data symbols", "rf": { "center_hz": RF_HZ, "sample_rate_hz": SAMPLE_RATE_HZ, "symbol_rate": SYMBOL_RATE, "samples_per_symbol": SAMPLES_PER_SYMBOL, "digital_carrier_hz": CARRIER_OFFSET_HZ, "tx_vga_db": TX_VGA_DB, "rx_lna_db": RX_LNA_DB, "rx_vga_db": RX_VGA_DB, "rf_amp": False, "antenna_power": False, }, "inventory": inventory, "preflight": preflight, "rf_tx_count": guard.transmit_calls, "usb": {"rx_overruns": rx_overruns, "tx_underruns": tx_underruns}, "raw": { "bytes": expected_size, "complex_samples": CAPTURE_SAMPLE_COUNT, "duration_seconds": CAPTURE_SAMPLE_COUNT / SAMPLE_RATE_HZ, "sha256_first": raw_sha_first, "sha256_second": raw_sha_second, }, "artifact_sha256": artifact_hashes_first, "artifact_sha256_second_read": artifact_hashes_second, "artifact_sha256_verified": artifact_hashes_first == artifact_hashes_second, "cleanup": cleanup, } (output_directory / "metadata.json").write_text( json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8", ) (output_directory / "analysis.json").write_text( json.dumps(reception.analysis, ensure_ascii=False, indent=2), encoding="utf-8", ) return { "classification": reception.analysis["classification"], "analysis": reception.analysis, "metadata": metadata, "output_directory": str(output_directory), } def main() -> int: parser = argparse.ArgumentParser(description="Lab045 convolutional FEC over BPSK") parser.add_argument("--hardware", action="store_true") parser.add_argument("--i-understand-hardware", action="store_true") parser.add_argument("--output-directory", type=Path) arguments = parser.parse_args() if arguments.hardware: if not arguments.i_understand_hardware: print("Аппаратный режим заблокирован без --i-understand-hardware; RF TX = 0") return 2 result = run_hardware_experiment( arguments.output_directory or default_output_directory() ) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 run_functional_tests() plan = build_transmission_plan(bytes(range(PAYLOAD_SIZE_BYTES))) print("Lab045 software self-check") print(f"protocol packet = {len(plan.packet)} bytes") print(f"information bits = {len(plan.information_bits)}") print(f"coded bits = {len(plan.coded_bits)}") print(f"protected PHY header bits = {PHY_HEADER_BIT_COUNT}") print(f"preamble bits = {PREAMBLE_BIT_COUNT}") print(f"total symbols = {plan.frame_symbol_count}") print(f"waveform samples = {plan.waveform_sample_count}") print(f"airtime = {plan.airtime_seconds * 1000:.1f} ms") print("synthetic CRC = PASS") return 0 if __name__ == "__main__": raise SystemExit(main())