Lab044: validate arbitrary BPSK packet reception
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
840
experiments/lab044_hackrf_bpsk_packet.py
Normal file
840
experiments/lab044_hackrf_bpsk_packet.py
Normal file
@@ -0,0 +1,840 @@
|
||||
"""Lab044: один произвольный protocol packet через реальный BPSK HackRF link.
|
||||
|
||||
Приёмный тракт принципиально не принимает ожидаемый payload и не читает
|
||||
``tx_payload.bin``. Сравнение с переданными данными выполняется отдельной
|
||||
диагностической стадией только после независимого frame/CRC-решения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
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 scipy.signal import fftconvolve
|
||||
|
||||
from experiments import lab043_hackrf_bpsk as lab043
|
||||
from experiments.lab043_hackrf_hardware import signed_iq_bytes_to_complex64
|
||||
from experiments.lab043_pluto_to_rtlsdr import pilot_prbs7, shape_frame_like_lab042
|
||||
from protocol import bpsk_radio as radio
|
||||
from protocol.packet import (
|
||||
CRC_SIZE,
|
||||
HEADER_FORMAT,
|
||||
HEADER_SIZE,
|
||||
MESSAGE_TYPE_ACK,
|
||||
MESSAGE_TYPE_IMAGE_FRAGMENT,
|
||||
MESSAGE_TYPE_TELEMETRY,
|
||||
MESSAGE_TYPE_TEXT,
|
||||
PROTOCOL_VERSION,
|
||||
SYNC_WORD,
|
||||
CRCError,
|
||||
PacketError,
|
||||
build_packet,
|
||||
parse_packet,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================ Параметры Lab044
|
||||
|
||||
PAYLOAD_SIZE_BYTES = 256
|
||||
MESSAGE_TYPE = MESSAGE_TYPE_TELEMETRY
|
||||
SEQUENCE_NUMBER = 44
|
||||
MAX_PACKET_BYTES = 1024
|
||||
|
||||
PHY_SYNC = radio.RADIO_SYNC_WORD
|
||||
PHY_HEADER_FORMAT = ">HH"
|
||||
PHY_HEADER_SIZE_BYTES = struct.calcsize(PHY_HEADER_FORMAT)
|
||||
PHY_HEADER_BIT_COUNT = PHY_HEADER_SIZE_BYTES * 8
|
||||
BIT_ORDER = "big"
|
||||
|
||||
PREAMBLE_BIT_COUNT = lab043.PILOT_SYMBOL_COUNT
|
||||
PREAMBLE_INITIAL_STATE = lab043.PILOT_INITIAL_STATE
|
||||
DETECTION_GUARD_SYMBOLS = lab043.SECOND_PEAK_GUARD_SYMBOLS
|
||||
|
||||
RF_HZ = lab043.RF_HZ
|
||||
SAMPLE_RATE_HZ = lab043.SAMPLE_RATE_HZ
|
||||
SYMBOL_RATE = lab043.SYMBOL_RATE
|
||||
SAMPLES_PER_SYMBOL = lab043.SAMPLES_PER_SYMBOL
|
||||
CARRIER_OFFSET_HZ = lab043.CARRIER_OFFSET_HZ
|
||||
TARGET_RMS_CODE = lab043.TARGET_RMS_CODE
|
||||
TX_VGA_DB = lab043.TX_VGA_DB
|
||||
RX_LNA_DB = 24
|
||||
RX_VGA_DB = 24
|
||||
CAPTURE_SAMPLE_COUNT = lab043.CAPTURE_SAMPLE_COUNT
|
||||
RX_LEAD_SECONDS = lab043.RX_LEAD_SECONDS
|
||||
RX_WAIT_TIMEOUT_SECONDS = lab043.RX_WAIT_TIMEOUT_SECONDS
|
||||
|
||||
KNOWN_MESSAGE_TYPES = {
|
||||
MESSAGE_TYPE_TEXT,
|
||||
MESSAGE_TYPE_TELEMETRY,
|
||||
MESSAGE_TYPE_IMAGE_FRAGMENT,
|
||||
MESSAGE_TYPE_ACK,
|
||||
}
|
||||
|
||||
CRC_PASS = "PASS"
|
||||
CRC_FAIL = "FAIL"
|
||||
CRC_NOT_REACHED = "NOT_REACHED"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransmissionPlan:
|
||||
payload: bytes
|
||||
packet: bytes
|
||||
phy_header: bytes
|
||||
frame_bits: np.ndarray
|
||||
tx_cs8: bytes
|
||||
payload_sha256: str
|
||||
packet_sha256: str
|
||||
waveform_sha256: str
|
||||
|
||||
@property
|
||||
def frame_symbol_count(self) -> int:
|
||||
return int(len(self.frame_bits))
|
||||
|
||||
@property
|
||||
def waveform_sample_count(self) -> int:
|
||||
return len(self.tx_cs8) // 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndependentReception:
|
||||
analysis: dict
|
||||
packet_bytes: bytes | None
|
||||
payload_bytes: bytes | None
|
||||
|
||||
|
||||
PayloadFactory = Callable[[int], bytes]
|
||||
Analyzer = Callable[[np.ndarray], IndependentReception]
|
||||
DiagnosticComparer = Callable[[IndependentReception, Path, Path], dict]
|
||||
|
||||
|
||||
# ============================================================ Bytes и frame
|
||||
|
||||
|
||||
def bytes_to_bits(data: bytes) -> np.ndarray:
|
||||
"""Преобразовать bytes в биты MSB first с явным bitorder."""
|
||||
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError("data должен иметь тип bytes")
|
||||
return np.unpackbits(np.frombuffer(data, dtype=np.uint8), bitorder=BIT_ORDER)
|
||||
|
||||
|
||||
def bits_to_bytes(bits: np.ndarray) -> bytes:
|
||||
"""Упаковать одномерные 0/1 биты MSB first."""
|
||||
|
||||
bits = np.asarray(bits, dtype=np.uint8)
|
||||
if bits.ndim != 1:
|
||||
raise ValueError("bits должен быть одномерным")
|
||||
if len(bits) % 8:
|
||||
raise ValueError("Количество битов должно быть кратно восьми")
|
||||
if not np.all((bits == 0) | (bits == 1)):
|
||||
raise ValueError("bits должен содержать только 0 и 1")
|
||||
return np.packbits(bits, bitorder=BIT_ORDER).tobytes()
|
||||
|
||||
|
||||
def preamble_bits() -> np.ndarray:
|
||||
return pilot_prbs7(PREAMBLE_BIT_COUNT, PREAMBLE_INITIAL_STATE)
|
||||
|
||||
|
||||
def encode_phy_header(packet_length_bytes: int) -> bytes:
|
||||
if not 1 <= packet_length_bytes <= MAX_PACKET_BYTES:
|
||||
raise ValueError("Длина protocol packet вне границ Lab044")
|
||||
return struct.pack(PHY_HEADER_FORMAT, PHY_SYNC, packet_length_bytes)
|
||||
|
||||
|
||||
def decode_phy_header(header_bits: np.ndarray) -> dict:
|
||||
"""Fail-closed разобрать PHY header, не выделяя память по принятой длине."""
|
||||
|
||||
bits = np.asarray(header_bits, dtype=np.uint8)
|
||||
if len(bits) < PHY_HEADER_BIT_COUNT:
|
||||
return {"passed": False, "reason": "TRUNCATED", "sync": None, "packet_bytes": None}
|
||||
try:
|
||||
sync, packet_bytes = struct.unpack(
|
||||
PHY_HEADER_FORMAT,
|
||||
bits_to_bytes(bits[:PHY_HEADER_BIT_COUNT]),
|
||||
)
|
||||
except (ValueError, struct.error):
|
||||
return {"passed": False, "reason": "DECODE_ERROR", "sync": None, "packet_bytes": None}
|
||||
if sync != PHY_SYNC:
|
||||
return {
|
||||
"passed": False,
|
||||
"reason": "SYNC_MISMATCH",
|
||||
"sync": sync,
|
||||
"packet_bytes": packet_bytes,
|
||||
}
|
||||
if not 1 <= packet_bytes <= MAX_PACKET_BYTES:
|
||||
return {
|
||||
"passed": False,
|
||||
"reason": "LENGTH_OUT_OF_RANGE",
|
||||
"sync": sync,
|
||||
"packet_bytes": packet_bytes,
|
||||
}
|
||||
return {"passed": True, "reason": None, "sync": sync, "packet_bytes": packet_bytes}
|
||||
|
||||
|
||||
def _scale_and_carrier(symbols: np.ndarray) -> np.ndarray:
|
||||
shaped = shape_frame_like_lab042(symbols, SAMPLES_PER_SYMBOL)
|
||||
indexes = np.arange(len(shaped), dtype=np.float64)
|
||||
carriered = shaped * np.exp(
|
||||
2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ
|
||||
)
|
||||
rms = float(np.sqrt(np.mean(np.abs(carriered) ** 2)))
|
||||
if not np.isfinite(rms) or rms <= 0.0:
|
||||
raise ValueError("Нулевая или нечисловая мощность формы")
|
||||
return carriered * (TARGET_RMS_CODE / rms)
|
||||
|
||||
|
||||
def build_transmission_plan(payload: bytes) -> TransmissionPlan:
|
||||
"""Собрать и полностью проверить единственный произвольный Lab044 frame."""
|
||||
|
||||
if not isinstance(payload, bytes):
|
||||
raise TypeError("payload должен иметь тип bytes")
|
||||
if len(payload) != PAYLOAD_SIZE_BYTES:
|
||||
raise ValueError(f"Lab044 требует ровно {PAYLOAD_SIZE_BYTES} payload bytes")
|
||||
|
||||
packet = build_packet(payload, MESSAGE_TYPE, SEQUENCE_NUMBER)
|
||||
phy_header = encode_phy_header(len(packet))
|
||||
frame_bits = np.concatenate((
|
||||
preamble_bits(),
|
||||
bytes_to_bits(phy_header),
|
||||
bytes_to_bits(packet),
|
||||
))
|
||||
tx_cs8 = lab043.to_cs8_bytes(_scale_and_carrier(radio.bpsk_modulate(frame_bits)))
|
||||
plan = TransmissionPlan(
|
||||
payload=payload,
|
||||
packet=packet,
|
||||
phy_header=phy_header,
|
||||
frame_bits=frame_bits,
|
||||
tx_cs8=tx_cs8,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
packet_sha256=hashlib.sha256(packet).hexdigest(),
|
||||
waveform_sha256=hashlib.sha256(tx_cs8).hexdigest(),
|
||||
)
|
||||
validate_transmission_plan(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def validate_transmission_plan(plan: TransmissionPlan) -> None:
|
||||
"""Закрыть TX-gate при любом внутреннем расхождении frame/packet."""
|
||||
|
||||
expected_packet_bytes = HEADER_SIZE + PAYLOAD_SIZE_BYTES + CRC_SIZE
|
||||
if len(plan.packet) != expected_packet_bytes:
|
||||
raise ValueError(f"Protocol packet имеет {len(plan.packet)}, ожидалось {expected_packet_bytes}")
|
||||
if parse_packet(plan.packet).payload != plan.payload:
|
||||
raise ValueError("Protocol packet round trip не восстановил payload")
|
||||
if bits_to_bytes(bytes_to_bits(plan.packet)) != plan.packet:
|
||||
raise ValueError("MSB-first packet bits round trip не совпал")
|
||||
header = decode_phy_header(bytes_to_bits(plan.phy_header))
|
||||
if not header["passed"] or header["packet_bytes"] != len(plan.packet):
|
||||
raise ValueError("PHY header round trip не совпал")
|
||||
expected_frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + len(plan.packet) * 8
|
||||
if plan.frame_symbol_count != expected_frame_symbols:
|
||||
raise ValueError("Число frame symbols не совпало с форматом")
|
||||
if len(plan.tx_cs8) != 2 * plan.waveform_sample_count or not plan.tx_cs8:
|
||||
raise ValueError("Некорректная cs8-форма")
|
||||
tx_codes = np.frombuffer(plan.tx_cs8, dtype=np.int8)
|
||||
if int(np.max(np.abs(tx_codes.astype(np.int16)))) >= 127:
|
||||
raise ValueError("TX waveform касается предела signed int8")
|
||||
|
||||
|
||||
# ============================================================ Независимый RX
|
||||
|
||||
|
||||
def _preamble_reference_samples() -> np.ndarray:
|
||||
waveform = _scale_and_carrier(radio.bpsk_modulate(preamble_bits()))
|
||||
return signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
|
||||
|
||||
|
||||
def _detect_preamble(samples: np.ndarray) -> dict:
|
||||
reference = _preamble_reference_samples()
|
||||
rx_decimated, reference_decimated = lab043._detection_streams(samples, reference)
|
||||
scores = lab043._normalized_sliding_correlation(rx_decimated, reference_decimated)
|
||||
guard = (
|
||||
DETECTION_GUARD_SYMBOLS
|
||||
* SAMPLES_PER_SYMBOL
|
||||
// lab043.DETECTION_DECIMATION
|
||||
)
|
||||
metrics = lab043._peak_metrics(scores, guard)
|
||||
detected = bool(
|
||||
metrics["over_median_db"] >= lab043.PREAMBLE_OVER_MEDIAN_DB_MIN
|
||||
and metrics["over_second_db"] >= lab043.PREAMBLE_OVER_SECOND_DB_MIN
|
||||
)
|
||||
coarse_start = metrics["peak_index"] * lab043.DETECTION_DECIMATION
|
||||
|
||||
low = max(0, coarse_start - 2_000)
|
||||
high = min(len(samples), coarse_start + len(reference) + 2_000)
|
||||
local = samples[low:high] - np.mean(samples)
|
||||
centered_reference = reference - np.mean(reference)
|
||||
if len(local) < len(centered_reference):
|
||||
refined_start = coarse_start
|
||||
else:
|
||||
correlation = fftconvolve(local, np.conj(centered_reference[::-1]), mode="valid")
|
||||
refined_start = low + int(np.argmax(np.abs(correlation)))
|
||||
|
||||
return {
|
||||
"preamble_detected": detected,
|
||||
"method": "global PRBS7 preamble-only normalized correlation",
|
||||
"payload_used": False,
|
||||
"peak": metrics["peak"],
|
||||
"median": metrics["median"],
|
||||
"over_median_db": metrics["over_median_db"],
|
||||
"over_second_db": metrics["over_second_db"],
|
||||
"coarse_start_sample": coarse_start,
|
||||
"refined_start_sample": refined_start,
|
||||
}
|
||||
|
||||
|
||||
def inspect_protocol_header(packet_bytes: bytes) -> dict:
|
||||
"""Проверить существующий protocol header отдельно от CRC."""
|
||||
|
||||
minimum = HEADER_SIZE + CRC_SIZE
|
||||
if len(packet_bytes) < minimum:
|
||||
return {"passed": False, "reason": "TRUNCATED", "payload_length": None}
|
||||
try:
|
||||
sync, version, message_type, sequence_number, payload_length = struct.unpack(
|
||||
HEADER_FORMAT,
|
||||
packet_bytes[:HEADER_SIZE],
|
||||
)
|
||||
except struct.error:
|
||||
return {"passed": False, "reason": "DECODE_ERROR", "payload_length": None}
|
||||
expected_size = HEADER_SIZE + payload_length + CRC_SIZE
|
||||
reason = None
|
||||
if sync != SYNC_WORD:
|
||||
reason = "SYNC_MISMATCH"
|
||||
elif version != PROTOCOL_VERSION:
|
||||
reason = "VERSION_MISMATCH"
|
||||
elif message_type not in KNOWN_MESSAGE_TYPES:
|
||||
reason = "MESSAGE_TYPE_INVALID"
|
||||
elif expected_size != len(packet_bytes):
|
||||
reason = "PAYLOAD_LENGTH_MISMATCH"
|
||||
return {
|
||||
"passed": reason is None,
|
||||
"reason": reason,
|
||||
"sync": sync,
|
||||
"version": version,
|
||||
"message_type": message_type,
|
||||
"sequence_number": sequence_number,
|
||||
"payload_length": payload_length,
|
||||
"expected_packet_bytes": expected_size,
|
||||
}
|
||||
|
||||
|
||||
def _empty_analysis(raw: dict, detection: dict) -> dict:
|
||||
return {
|
||||
"phy_sync_pass": False,
|
||||
"frame_header_pass": False,
|
||||
"packet_received": False,
|
||||
"protocol_header_pass": False,
|
||||
"crc": CRC_NOT_REACHED,
|
||||
"classification": "D",
|
||||
"raw": raw,
|
||||
"detection": detection,
|
||||
"frame_header": None,
|
||||
"protocol_header": None,
|
||||
"carrier": None,
|
||||
}
|
||||
|
||||
|
||||
def _capture_diagnostics(
|
||||
samples: np.ndarray,
|
||||
detection: dict,
|
||||
packet_bytes_hint: int | None,
|
||||
) -> dict:
|
||||
"""BEFORE/DURING/AFTER статистика записи вокруг независимо найденного кадра.
|
||||
|
||||
Переиспользует ``lab043._capture_intervals`` и ``lab043.raw_statistics``
|
||||
без изменения самого Lab043. Граница DURING строится только по
|
||||
независимо найденному ``detection["refined_start_sample"]`` и по длине
|
||||
кадра: если PHY LENGTH уже декодирован (``packet_bytes_hint``), длина
|
||||
точная; иначе используется максимально возможная длина кадра Lab044.
|
||||
Payload при этом никогда не используется. Если независимый preamble не
|
||||
найден, DURING остаётся неопределённым и явно помечается как таковой.
|
||||
"""
|
||||
|
||||
if not detection.get("preamble_detected"):
|
||||
return {
|
||||
"before": None,
|
||||
"during": None,
|
||||
"after": None,
|
||||
"frame_start_sample": None,
|
||||
"frame_end_sample": None,
|
||||
"note": "DURING interval не определён: независимый preamble не найден",
|
||||
"whole_capture": lab043.raw_statistics(samples),
|
||||
}
|
||||
|
||||
frame_start = int(detection["refined_start_sample"])
|
||||
if packet_bytes_hint is not None and 1 <= packet_bytes_hint <= MAX_PACKET_BYTES:
|
||||
frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + packet_bytes_hint * 8
|
||||
note = "DURING построен по независимому preamble start и декодированной PHY LENGTH"
|
||||
else:
|
||||
frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + MAX_PACKET_BYTES * 8
|
||||
note = (
|
||||
"DURING построен по независимому preamble start и максимально возможной "
|
||||
"длине кадра Lab044 (PHY header ещё не восстановлен)"
|
||||
)
|
||||
frame_length_samples = frame_symbols * SAMPLES_PER_SYMBOL
|
||||
intervals = lab043._capture_intervals(samples, frame_start, frame_length_samples)
|
||||
|
||||
sample_rate = float(SAMPLE_RATE_HZ)
|
||||
region_bounds = {
|
||||
"before": (0, intervals["frame_start_sample"]),
|
||||
"during": (intervals["frame_start_sample"], intervals["frame_end_sample"]),
|
||||
"after": (intervals["frame_end_sample"], int(len(samples))),
|
||||
}
|
||||
for region_key, (start, end) in region_bounds.items():
|
||||
stats = intervals[region_key]
|
||||
if stats is None:
|
||||
continue
|
||||
stats["sample_range"] = [int(start), int(end)]
|
||||
stats["duration_seconds"] = (end - start) / sample_rate
|
||||
|
||||
intervals["note"] = note
|
||||
return intervals
|
||||
|
||||
|
||||
def _artifact_sha256(output_directory: Path, filenames: list[str]) -> dict[str, str | None]:
|
||||
"""SHA-256 каждого реально существующего файла; None для несозданных.
|
||||
|
||||
Никогда не создаёт файл ради вычисления хэша: если файла нет на диске
|
||||
(например, rx_payload.bin не был извлечён), значение — None.
|
||||
Переиспользует lab043._sha256_file.
|
||||
"""
|
||||
|
||||
hashes: dict[str, str | None] = {}
|
||||
for name in filenames:
|
||||
path = output_directory / name
|
||||
hashes[name] = lab043._sha256_file(path) if path.is_file() else None
|
||||
return hashes
|
||||
|
||||
|
||||
def analyze_capture_independently(samples: np.ndarray) -> IndependentReception:
|
||||
"""Принять Lab044 frame без ожидаемого packet или payload."""
|
||||
|
||||
samples = np.asarray(samples, dtype=np.complex64)
|
||||
raw = lab043.raw_statistics(samples)
|
||||
detection = _detect_preamble(samples)
|
||||
if not detection["preamble_detected"]:
|
||||
analysis = _empty_analysis(raw, detection)
|
||||
analysis["capture_intervals"] = _capture_diagnostics(samples, detection, None)
|
||||
return IndependentReception(analysis, None, None)
|
||||
|
||||
maximum_frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + MAX_PACKET_BYTES * 8
|
||||
maximum_waveform_samples = (
|
||||
2 * 16 * SAMPLES_PER_SYMBOL
|
||||
+ maximum_frame_symbols * SAMPLES_PER_SYMBOL
|
||||
+ 10 * SAMPLES_PER_SYMBOL
|
||||
)
|
||||
matched, nominal = lab043._matched_waveform(
|
||||
samples,
|
||||
detection["refined_start_sample"],
|
||||
maximum_waveform_samples,
|
||||
)
|
||||
expected_preamble = radio.bpsk_modulate(preamble_bits())
|
||||
timing, _ = lab043._timing_offsets(matched, nominal, expected_preamble)
|
||||
carrier = lab043._evaluate_symbols(
|
||||
matched,
|
||||
nominal,
|
||||
timing,
|
||||
preamble_bits(),
|
||||
PREAMBLE_BIT_COUNT,
|
||||
)
|
||||
available_symbols = matched[
|
||||
nominal + timing : nominal + timing + maximum_frame_symbols * SAMPLES_PER_SYMBOL : SAMPLES_PER_SYMBOL
|
||||
].astype(np.complex128)
|
||||
if carrier["abs_h"] == 0.0:
|
||||
analysis = _empty_analysis(raw, detection)
|
||||
analysis["classification"] = "C"
|
||||
analysis["carrier"] = carrier
|
||||
analysis["capture_intervals"] = _capture_diagnostics(samples, detection, None)
|
||||
return IndependentReception(analysis, None, None)
|
||||
|
||||
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"]))
|
||||
)
|
||||
received_bits = (normalized.real >= 0.0).astype(np.uint8)
|
||||
preamble_error_count = int(np.count_nonzero(
|
||||
received_bits[:PREAMBLE_BIT_COUNT] != preamble_bits()
|
||||
))
|
||||
phy_sync_pass = bool(
|
||||
raw["clipping_count"] == 0
|
||||
and raw["near_int8_count"] == 0
|
||||
and len(received_bits) >= PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT
|
||||
)
|
||||
header_start = PREAMBLE_BIT_COUNT
|
||||
header_end = header_start + PHY_HEADER_BIT_COUNT
|
||||
frame_header = decode_phy_header(received_bits[header_start:header_end])
|
||||
if not frame_header["passed"]:
|
||||
analysis = {
|
||||
**_empty_analysis(raw, detection),
|
||||
"phy_sync_pass": phy_sync_pass,
|
||||
"classification": "C",
|
||||
"frame_header": frame_header,
|
||||
"carrier": carrier,
|
||||
"preamble_errors": preamble_error_count,
|
||||
"timing_offset_samples": timing,
|
||||
"capture_intervals": _capture_diagnostics(samples, detection, frame_header.get("packet_bytes")),
|
||||
}
|
||||
return IndependentReception(analysis, None, None)
|
||||
|
||||
packet_length = int(frame_header["packet_bytes"])
|
||||
packet_start = header_end
|
||||
packet_end = packet_start + packet_length * 8
|
||||
if len(received_bits) < packet_end:
|
||||
frame_header = {**frame_header, "passed": False, "reason": "TRUNCATED_PACKET"}
|
||||
analysis = {
|
||||
**_empty_analysis(raw, detection),
|
||||
"phy_sync_pass": phy_sync_pass,
|
||||
"classification": "C",
|
||||
"frame_header": frame_header,
|
||||
"carrier": carrier,
|
||||
"preamble_errors": preamble_error_count,
|
||||
"timing_offset_samples": timing,
|
||||
"capture_intervals": _capture_diagnostics(samples, detection, packet_length),
|
||||
}
|
||||
return IndependentReception(analysis, None, None)
|
||||
|
||||
packet_bytes = bits_to_bytes(received_bits[packet_start:packet_end])
|
||||
protocol_header = inspect_protocol_header(packet_bytes)
|
||||
crc_status = CRC_NOT_REACHED
|
||||
payload_bytes: bytes | None = None
|
||||
if protocol_header["passed"]:
|
||||
payload_length = int(protocol_header["payload_length"])
|
||||
payload_bytes = packet_bytes[HEADER_SIZE : HEADER_SIZE + payload_length]
|
||||
try:
|
||||
parsed = parse_packet(packet_bytes)
|
||||
except CRCError:
|
||||
crc_status = CRC_FAIL
|
||||
except PacketError as error:
|
||||
protocol_header = {
|
||||
**protocol_header,
|
||||
"passed": False,
|
||||
"reason": f"PACKET_ERROR: {error}",
|
||||
}
|
||||
else:
|
||||
crc_status = CRC_PASS
|
||||
payload_bytes = parsed.payload
|
||||
|
||||
if crc_status == CRC_PASS:
|
||||
classification = "A"
|
||||
elif crc_status == CRC_FAIL and protocol_header["passed"]:
|
||||
classification = "B"
|
||||
else:
|
||||
classification = "C"
|
||||
analysis = {
|
||||
"phy_sync_pass": phy_sync_pass,
|
||||
"frame_header_pass": True,
|
||||
"packet_received": True,
|
||||
"protocol_header_pass": bool(protocol_header["passed"]),
|
||||
"crc": crc_status,
|
||||
"classification": classification,
|
||||
"raw": raw,
|
||||
"detection": detection,
|
||||
"frame_header": frame_header,
|
||||
"protocol_header": protocol_header,
|
||||
"carrier": carrier,
|
||||
"preamble_errors": preamble_error_count,
|
||||
"timing_offset_samples": timing,
|
||||
"received_symbol_count": int(len(received_bits)),
|
||||
"payload_known_before_decode": False,
|
||||
"capture_intervals": _capture_diagnostics(samples, detection, packet_length),
|
||||
}
|
||||
return IndependentReception(analysis, packet_bytes, payload_bytes)
|
||||
|
||||
|
||||
# ============================================================ Диагностика после CRC
|
||||
|
||||
|
||||
def _comparison(received: bytes | None, expected: bytes) -> dict:
|
||||
if received is None or len(received) != len(expected):
|
||||
return {
|
||||
"comparable": False,
|
||||
"expected_bytes": len(expected),
|
||||
"received_bytes": None if received is None else len(received),
|
||||
"bit_errors": None,
|
||||
"ber": None,
|
||||
"byte_errors": None,
|
||||
"byte_error_rate": None,
|
||||
"first_differing_byte_positions": [],
|
||||
"bit_error_run_lengths": [],
|
||||
}
|
||||
expected_array = np.frombuffer(expected, dtype=np.uint8)
|
||||
received_array = np.frombuffer(received, dtype=np.uint8)
|
||||
xor = np.bitwise_xor(expected_array, received_array)
|
||||
differing_bytes = np.flatnonzero(xor)
|
||||
error_bits = bytes_to_bits(xor.tobytes())
|
||||
error_positions = np.flatnonzero(error_bits)
|
||||
runs: list[int] = []
|
||||
if len(error_positions):
|
||||
run_length = 1
|
||||
for previous, current in zip(error_positions[:-1], error_positions[1:]):
|
||||
if current == previous + 1:
|
||||
run_length += 1
|
||||
else:
|
||||
runs.append(run_length)
|
||||
run_length = 1
|
||||
runs.append(run_length)
|
||||
bit_errors = int(np.count_nonzero(error_bits))
|
||||
byte_errors = int(len(differing_bytes))
|
||||
return {
|
||||
"comparable": True,
|
||||
"expected_bytes": len(expected),
|
||||
"received_bytes": len(received),
|
||||
"bit_errors": bit_errors,
|
||||
"ber": bit_errors / (8 * len(expected)) if expected else 0.0,
|
||||
"byte_errors": byte_errors,
|
||||
"byte_error_rate": byte_errors / len(expected) if expected else 0.0,
|
||||
"first_differing_byte_positions": differing_bytes[:20].astype(int).tolist(),
|
||||
"bit_error_run_lengths": runs,
|
||||
}
|
||||
|
||||
|
||||
def diagnostic_compare_after_decode(
|
||||
reception: IndependentReception,
|
||||
tx_payload_path: Path,
|
||||
tx_packet_path: Path,
|
||||
) -> dict:
|
||||
"""Только теперь прочитать TX artifacts и сравнить без исправления RX."""
|
||||
|
||||
tx_packet = tx_packet_path.read_bytes()
|
||||
tx_payload = tx_payload_path.read_bytes()
|
||||
return {
|
||||
"stage": "AFTER_INDEPENDENT_CRC_DECISION",
|
||||
"receiver_knew_payload_before_decode": False,
|
||||
"packet": _comparison(reception.packet_bytes, tx_packet),
|
||||
"payload": _comparison(reception.payload_bytes, tx_payload),
|
||||
}
|
||||
|
||||
|
||||
def analyze_then_compare(
|
||||
samples: np.ndarray,
|
||||
tx_payload_path: Path,
|
||||
tx_packet_path: Path,
|
||||
analyzer: Analyzer = analyze_capture_independently,
|
||||
comparer: DiagnosticComparer = diagnostic_compare_after_decode,
|
||||
) -> IndependentReception:
|
||||
"""Зафиксировать порядок: независимый decode, затем чтение TX artifacts."""
|
||||
|
||||
reception = analyzer(samples)
|
||||
reception.analysis["diagnostic_comparison"] = comparer(
|
||||
reception,
|
||||
tx_payload_path,
|
||||
tx_packet_path,
|
||||
)
|
||||
return reception
|
||||
|
||||
|
||||
# ============================================================ Hardware
|
||||
|
||||
|
||||
def default_output_directory() -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return Path.home() / "Documents" / "SDR_Rover_Lab044_raw" / f"{timestamp}_bpsk_packet"
|
||||
|
||||
|
||||
def run_hardware_experiment(
|
||||
output_directory: Path,
|
||||
runner: lab043.CommandRunner | None = None,
|
||||
process_starter: lab043.ProcessStarter | None = None,
|
||||
sleep_function: lab043.SleepFunction = time.sleep,
|
||||
payload_factory: PayloadFactory = secrets.token_bytes,
|
||||
analyzer: Analyzer = analyze_capture_independently,
|
||||
comparer: DiagnosticComparer = diagnostic_compare_after_decode,
|
||||
) -> dict:
|
||||
"""Один fail-closed hardware packet: preflight, RX, один TX, decode."""
|
||||
|
||||
runner = runner or lab043._default_command_runner
|
||||
process_starter = process_starter or lab043._default_process_starter
|
||||
payload = payload_factory(PAYLOAD_SIZE_BYTES)
|
||||
plan = build_transmission_plan(payload)
|
||||
validate_transmission_plan(plan)
|
||||
|
||||
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_cs8.iq"
|
||||
tx_payload_path.write_bytes(plan.payload)
|
||||
tx_packet_path.write_bytes(plan.packet)
|
||||
tx_waveform_path.write_bytes(plan.tx_cs8)
|
||||
|
||||
guard = lab043.TransmitGuard()
|
||||
rx_process: lab043.RunningCommand | None = None
|
||||
try:
|
||||
preflight = 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(lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe")
|
||||
rx_command = [
|
||||
transfer_tool,
|
||||
"-d", 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", 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 = lab043._sha256_file(rx_path)
|
||||
raw_sha_second = 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 lab043._transfer_log_is_clean(rx_log):
|
||||
raise RuntimeError("RX log содержит overrun/underrun")
|
||||
if not lab043._transfer_log_is_clean(tx_log):
|
||||
raise RuntimeError("TX log содержит overrun/underrun")
|
||||
|
||||
samples = lab043.load_cs8(rx_path)
|
||||
reception = analyze_then_compare(
|
||||
samples,
|
||||
tx_payload_path,
|
||||
tx_packet_path,
|
||||
analyzer=analyzer,
|
||||
comparer=comparer,
|
||||
)
|
||||
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)
|
||||
|
||||
metadata = {
|
||||
"payload_bytes": len(plan.payload),
|
||||
"protocol_packet_bytes": len(plan.packet),
|
||||
"phy_header_bits": PHY_HEADER_BIT_COUNT,
|
||||
"total_frame_symbols": plan.frame_symbol_count,
|
||||
"total_waveform_samples": plan.waveform_sample_count,
|
||||
"tx_duration_seconds": plan.waveform_sample_count / SAMPLE_RATE_HZ,
|
||||
"bit_order": "MSB first",
|
||||
"byte_order": "big-endian/network order",
|
||||
"max_packet_bytes": MAX_PACKET_BYTES,
|
||||
"payload_sha256": plan.payload_sha256,
|
||||
"packet_sha256": plan.packet_sha256,
|
||||
"waveform_sha256": plan.waveform_sha256,
|
||||
"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,
|
||||
},
|
||||
"rf": {
|
||||
"center_hz": RF_HZ,
|
||||
"sample_rate_hz": SAMPLE_RATE_HZ,
|
||||
"symbol_rate": SYMBOL_RATE,
|
||||
"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,
|
||||
},
|
||||
"preflight": preflight,
|
||||
"rf_tx_count": guard.transmit_calls,
|
||||
"artifact_sha256": _artifact_sha256(
|
||||
output_directory,
|
||||
[
|
||||
"tx_payload.bin",
|
||||
"tx_packet.bin",
|
||||
"tx_bpsk_cs8.iq",
|
||||
"rx_cs8.iq",
|
||||
"rx_packet.bin",
|
||||
"rx_payload.bin",
|
||||
],
|
||||
),
|
||||
}
|
||||
(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),
|
||||
}
|
||||
finally:
|
||||
if rx_process is not None:
|
||||
try:
|
||||
rx_process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
lab043._disable_clock_outputs(runner)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Lab044 arbitrary BPSK packet + CRC")
|
||||
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 not arguments.hardware:
|
||||
parser.print_help()
|
||||
return 0
|
||||
if not arguments.i_understand_hardware:
|
||||
print("Аппаратный режим заблокирован без --i-understand-hardware")
|
||||
return 2
|
||||
result = run_hardware_experiment(arguments.output_directory or default_output_directory())
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user