1073 lines
44 KiB
Python
1073 lines
44 KiB
Python
"""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())
|