Files
SDR-Rover/experiments/lab045_hackrf_bpsk_fec.py
LittleSam129 62a376c811 Lab045: demonstrate real RF BPSK error correction
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 10:49:00 +03:00

1073 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())