1174 lines
52 KiB
Python
1174 lines
52 KiB
Python
"""Один сравнительный короткий S Lab043: Pluto+ TX -> HackRF RX.
|
||
|
||
HackRF используется только как приёмник через непрерывный callback-сеанс
|
||
libhackrf. Модуль не импортирует и не вызывает ни одну функцию HackRF TX.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import ctypes
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
if __package__ in {None, ""}:
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from experiments import lab043_bpsk_asymmetry_diagnostics as diagnostics
|
||
from experiments import lab043_pluto_to_rtlsdr as lab043
|
||
from experiments import lab043_prbs11_hardware as rtl_hardware
|
||
from protocol import bpsk_radio as radio
|
||
|
||
|
||
HACKRF_DLL = Path(r"C:\Users\user\radioconda\Library\bin\hackrf-0.dll")
|
||
HACKRF_INFO = Path(r"C:\Users\user\radioconda\Library\bin\hackrf_info.exe")
|
||
EXPECTED_SERIAL = "000000000000000097c063dc323b97d3"
|
||
HACKRF_CENTER_HZ = 435_000_000
|
||
HACKRF_SAMPLE_RATE_HZ = 2_400_000.0
|
||
HACKRF_RF_AMP = 0
|
||
HACKRF_ANTENNA_BIAS = 0
|
||
HACKRF_LNA_GAIN_DB = 8
|
||
HACKRF_VGA_GAIN_DB = 8
|
||
HACKRF_WARMUP_CALLBACK_COUNT = 1
|
||
HACKRF_EXPECTED_CALLBACK_BYTES = 262_144
|
||
HACKRF_PROBE_SAMPLE_COUNT = 65_536
|
||
HACKRF_STREAM_TIMEOUT_SECONDS = 15.0
|
||
RAW_DIRECTORY = Path("data/raw/lab043_hackrf")
|
||
PROCESSED_DIRECTORY = Path("data/processed/lab043_hackrf_compare")
|
||
|
||
|
||
def validate_hackrf_rx_gains(lna_gain_db: int, vga_gain_db: int) -> tuple[int, int]:
|
||
"""Проверить официальные аппаратные ступени HackRF One RX."""
|
||
|
||
lna = int(lna_gain_db)
|
||
vga = int(vga_gain_db)
|
||
if lna != lna_gain_db or not 0 <= lna <= 40 or lna % 8:
|
||
raise ValueError("LNA должна быть 0..40 дБ с шагом 8 дБ")
|
||
if vga != vga_gain_db or not 0 <= vga <= 62 or vga % 2:
|
||
raise ValueError("VGA должна быть 0..62 дБ с шагом 2 дБ")
|
||
return lna, vga
|
||
|
||
|
||
class HackrfTransfer(ctypes.Structure):
|
||
_fields_ = (
|
||
("device", ctypes.c_void_p),
|
||
("buffer", ctypes.POINTER(ctypes.c_uint8)),
|
||
("buffer_length", ctypes.c_int),
|
||
("valid_length", ctypes.c_int),
|
||
("rx_ctx", ctypes.c_void_p),
|
||
("tx_ctx", ctypes.c_void_p),
|
||
)
|
||
|
||
|
||
HackrfCallback = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(HackrfTransfer))
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class HackrfBoundary:
|
||
callback_index: int
|
||
valid_bytes: int
|
||
accepted_bytes: int
|
||
accepted_start_sample: int
|
||
accepted_end_sample: int
|
||
monotonic_seconds: float
|
||
block_sha256: str
|
||
buffer_length: int | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class HackrfCapture:
|
||
samples: np.ndarray
|
||
boundaries: tuple[HackrfBoundary, ...]
|
||
diagnostics: dict
|
||
raw_bytes: bytes = b""
|
||
|
||
|
||
def signed_iq_bytes_to_complex64(raw: bytes | bytearray | memoryview) -> np.ndarray:
|
||
"""Преобразовать interleaved signed int8 I/Q HackRF в complex64 [-1, 1)."""
|
||
|
||
values = np.frombuffer(raw, dtype=np.int8)
|
||
if len(values) == 0 or len(values) % 2:
|
||
raise ValueError("Поток HackRF должен содержать чётное ненулевое число I/Q-байтов")
|
||
pairs = values.reshape(-1, 2).astype(np.float32)
|
||
return ((pairs[:, 0] + 1j * pairs[:, 1]) / 128.0).astype(np.complex64)
|
||
|
||
|
||
def copy_valid_callback_bytes(
|
||
buffer_pointer,
|
||
buffer_length: int,
|
||
valid_length: int,
|
||
) -> bytes:
|
||
"""Скопировать только валидную interleaved-I/Q часть callback HackRF."""
|
||
|
||
if buffer_length <= 0:
|
||
raise ValueError("buffer_length callback должен быть положительным")
|
||
if valid_length <= 0 or valid_length % 2:
|
||
raise ValueError("valid_length callback должен быть положительным и чётным")
|
||
if valid_length > buffer_length:
|
||
raise ValueError("valid_length callback превышает buffer_length")
|
||
if not buffer_pointer:
|
||
raise ValueError("buffer callback не может быть нулевым")
|
||
return ctypes.string_at(buffer_pointer, valid_length)
|
||
|
||
|
||
def assemble_callback_blocks(
|
||
blocks: list[bytes],
|
||
target_sample_count: int,
|
||
warmup_callback_count: int = HACKRF_WARMUP_CALLBACK_COUNT,
|
||
buffer_lengths: list[int] | None = None,
|
||
) -> tuple[np.ndarray, tuple[HackrfBoundary, ...], dict]:
|
||
"""Детерминированно собрать callback-блоки без перестановки и дубликатов очереди."""
|
||
|
||
if target_sample_count <= 0:
|
||
raise ValueError("Нужно положительное число комплексных отсчётов")
|
||
if warmup_callback_count < 0:
|
||
raise ValueError("Число разогревочных callback не может быть отрицательным")
|
||
if buffer_lengths is not None and len(buffer_lengths) != len(blocks):
|
||
raise ValueError("Число buffer_length не совпадает с числом callback-блоков")
|
||
accepted_parts: list[bytes] = []
|
||
boundaries: list[HackrfBoundary] = []
|
||
accepted_samples = 0
|
||
received_bytes = 0
|
||
for callback_index, block in enumerate(blocks):
|
||
payload = bytes(block)
|
||
if len(payload) % 2:
|
||
raise ValueError(f"Callback {callback_index} содержит нечётное число байтов")
|
||
buffer_length = (
|
||
len(payload) if buffer_lengths is None else int(buffer_lengths[callback_index])
|
||
)
|
||
if buffer_length < len(payload):
|
||
raise ValueError(
|
||
f"Callback {callback_index}: valid_length превышает buffer_length"
|
||
)
|
||
received_bytes += len(payload)
|
||
if callback_index < warmup_callback_count:
|
||
continue
|
||
remaining_bytes = 2 * (target_sample_count - accepted_samples)
|
||
if remaining_bytes <= 0:
|
||
break
|
||
accepted = payload[:remaining_bytes]
|
||
accepted_parts.append(accepted)
|
||
accepted_count = len(accepted) // 2
|
||
boundary = HackrfBoundary(
|
||
callback_index=callback_index,
|
||
valid_bytes=len(payload),
|
||
accepted_bytes=len(accepted),
|
||
accepted_start_sample=accepted_samples,
|
||
accepted_end_sample=accepted_samples + accepted_count,
|
||
monotonic_seconds=float(callback_index),
|
||
block_sha256=hashlib.sha256(payload).hexdigest(),
|
||
buffer_length=buffer_length,
|
||
)
|
||
boundaries.append(boundary)
|
||
accepted_samples += accepted_count
|
||
if accepted_samples == target_sample_count:
|
||
break
|
||
if accepted_samples != target_sample_count:
|
||
raise RuntimeError(
|
||
f"Поток короче ожидаемого: {accepted_samples}/{target_sample_count} отсчётов"
|
||
)
|
||
raw = b"".join(accepted_parts)
|
||
samples = signed_iq_bytes_to_complex64(raw)
|
||
callback_indexes = [row.callback_index for row in boundaries]
|
||
sequential = callback_indexes == list(
|
||
range(warmup_callback_count, warmup_callback_count + len(callback_indexes))
|
||
)
|
||
hashes = [row.block_sha256 for row in boundaries if row.accepted_bytes == row.valid_bytes]
|
||
diagnostics_row = {
|
||
"callback_count_total": min(len(blocks), warmup_callback_count + len(boundaries)),
|
||
"warmup_callback_count": warmup_callback_count,
|
||
"accepted_callback_count": len(boundaries),
|
||
"received_bytes_including_warmup": received_bytes,
|
||
"accepted_bytes": len(raw),
|
||
"accepted_sample_count": len(samples),
|
||
"callback_indexes_sequential": sequential,
|
||
"program_queue_missing_callback_count": 0 if sequential else None,
|
||
"duplicate_full_block_hash_count": len(hashes) - len(set(hashes)),
|
||
"observed_buffer_lengths": sorted(
|
||
{
|
||
len(block) if buffer_lengths is None else int(buffer_lengths[index])
|
||
for index, block in enumerate(blocks)
|
||
}
|
||
),
|
||
"observed_valid_lengths": sorted({len(block) for block in blocks}),
|
||
}
|
||
return samples, tuple(boundaries), diagnostics_row
|
||
|
||
|
||
class HackrfRxDevice:
|
||
"""Минимальная RX-only ctypes-обёртка libhackrf."""
|
||
|
||
def __init__(self, dll_path: Path = HACKRF_DLL, serial: str = EXPECTED_SERIAL):
|
||
self.dll_path = Path(dll_path)
|
||
self.serial = serial
|
||
self._dll_directory = None
|
||
self.library = None
|
||
self.device = ctypes.c_void_p()
|
||
self._callback_reference = None
|
||
self._initialized = False
|
||
|
||
def _load(self) -> None:
|
||
if not self.dll_path.exists():
|
||
raise FileNotFoundError(f"libhackrf не найдена: {self.dll_path}")
|
||
if hasattr(os, "add_dll_directory"):
|
||
self._dll_directory = os.add_dll_directory(str(self.dll_path.parent))
|
||
library = ctypes.CDLL(str(self.dll_path))
|
||
library.hackrf_init.argtypes = []
|
||
library.hackrf_init.restype = ctypes.c_int
|
||
library.hackrf_exit.argtypes = []
|
||
library.hackrf_exit.restype = ctypes.c_int
|
||
library.hackrf_open_by_serial.argtypes = [
|
||
ctypes.c_char_p,
|
||
ctypes.POINTER(ctypes.c_void_p),
|
||
]
|
||
library.hackrf_open_by_serial.restype = ctypes.c_int
|
||
library.hackrf_close.argtypes = [ctypes.c_void_p]
|
||
library.hackrf_close.restype = ctypes.c_int
|
||
library.hackrf_set_freq.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
|
||
library.hackrf_set_freq.restype = ctypes.c_int
|
||
library.hackrf_set_sample_rate.argtypes = [ctypes.c_void_p, ctypes.c_double]
|
||
library.hackrf_set_sample_rate.restype = ctypes.c_int
|
||
library.hackrf_set_amp_enable.argtypes = [ctypes.c_void_p, ctypes.c_uint8]
|
||
library.hackrf_set_amp_enable.restype = ctypes.c_int
|
||
library.hackrf_set_lna_gain.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||
library.hackrf_set_lna_gain.restype = ctypes.c_int
|
||
library.hackrf_set_vga_gain.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||
library.hackrf_set_vga_gain.restype = ctypes.c_int
|
||
library.hackrf_set_antenna_enable.argtypes = [ctypes.c_void_p, ctypes.c_uint8]
|
||
library.hackrf_set_antenna_enable.restype = ctypes.c_int
|
||
library.hackrf_start_rx.argtypes = [ctypes.c_void_p, HackrfCallback, ctypes.c_void_p]
|
||
library.hackrf_start_rx.restype = ctypes.c_int
|
||
library.hackrf_stop_rx.argtypes = [ctypes.c_void_p]
|
||
library.hackrf_stop_rx.restype = ctypes.c_int
|
||
library.hackrf_is_streaming.argtypes = [ctypes.c_void_p]
|
||
library.hackrf_is_streaming.restype = ctypes.c_int
|
||
library.hackrf_error_name.argtypes = [ctypes.c_int]
|
||
library.hackrf_error_name.restype = ctypes.c_char_p
|
||
self.library = library
|
||
|
||
def _check(self, code: int, action: str) -> None:
|
||
if code == 0:
|
||
return
|
||
name = "unknown"
|
||
if self.library is not None:
|
||
raw = self.library.hackrf_error_name(code)
|
||
name = raw.decode("ascii", errors="replace") if raw else "unknown"
|
||
raise RuntimeError(f"{action}: {name} ({code})")
|
||
|
||
def open(self) -> None:
|
||
self._load()
|
||
self._check(self.library.hackrf_init(), "hackrf_init")
|
||
self._initialized = True
|
||
self._check(
|
||
self.library.hackrf_open_by_serial(
|
||
self.serial.encode("ascii"), ctypes.byref(self.device)
|
||
),
|
||
"hackrf_open_by_serial",
|
||
)
|
||
|
||
def configure_rx(self) -> dict:
|
||
self._check(
|
||
self.library.hackrf_set_sample_rate(self.device, HACKRF_SAMPLE_RATE_HZ),
|
||
"hackrf_set_sample_rate",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_freq(self.device, HACKRF_CENTER_HZ),
|
||
"hackrf_set_freq",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_amp_enable(self.device, HACKRF_RF_AMP),
|
||
"hackrf_set_amp_enable",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_lna_gain(self.device, HACKRF_LNA_GAIN_DB),
|
||
"hackrf_set_lna_gain",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_vga_gain(self.device, HACKRF_VGA_GAIN_DB),
|
||
"hackrf_set_vga_gain",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_antenna_enable(
|
||
self.device,
|
||
HACKRF_ANTENNA_BIAS,
|
||
),
|
||
"hackrf_set_antenna_enable",
|
||
)
|
||
return {
|
||
"requested_center_frequency_hz": HACKRF_CENTER_HZ,
|
||
"set_center_frequency_hz": HACKRF_CENTER_HZ,
|
||
"requested_sample_rate_hz": HACKRF_SAMPLE_RATE_HZ,
|
||
"set_sample_rate_hz": HACKRF_SAMPLE_RATE_HZ,
|
||
"sample_rate_readback_available": False,
|
||
"center_frequency_readback_available": False,
|
||
"rf_amp_enabled": False,
|
||
"lna_gain_db": HACKRF_LNA_GAIN_DB,
|
||
"vga_gain_db": HACKRF_VGA_GAIN_DB,
|
||
"antenna_bias_enabled": False,
|
||
}
|
||
|
||
def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict:
|
||
"""Безопасно задать RX-усиления, повторно фиксируя RF amp и bias OFF."""
|
||
|
||
lna, vga = validate_hackrf_rx_gains(lna_gain_db, vga_gain_db)
|
||
self._check(
|
||
self.library.hackrf_set_amp_enable(self.device, HACKRF_RF_AMP),
|
||
"hackrf_set_amp_enable(OFF)",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_antenna_enable(
|
||
self.device,
|
||
HACKRF_ANTENNA_BIAS,
|
||
),
|
||
"hackrf_set_antenna_enable(OFF)",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_lna_gain(self.device, lna),
|
||
"hackrf_set_lna_gain",
|
||
)
|
||
self._check(
|
||
self.library.hackrf_set_vga_gain(self.device, vga),
|
||
"hackrf_set_vga_gain",
|
||
)
|
||
return {
|
||
"rf_amp_enabled": False,
|
||
"antenna_bias_enabled": False,
|
||
"lna_gain_db": lna,
|
||
"vga_gain_db": vga,
|
||
"gain_readback_available": False,
|
||
"set_calls_succeeded": True,
|
||
}
|
||
|
||
def capture(
|
||
self,
|
||
target_sample_count: int,
|
||
timeout_seconds: float = HACKRF_STREAM_TIMEOUT_SECONDS,
|
||
) -> HackrfCapture:
|
||
"""Принять заданное число отсчётов, сохранив исходные callback-байты."""
|
||
|
||
if not np.isfinite(timeout_seconds) or timeout_seconds <= 0.0:
|
||
raise ValueError("Тайм-аут HackRF RX должен быть положительным")
|
||
blocks: list[bytes] = []
|
||
callback_buffer_lengths: list[int] = []
|
||
callback_times: list[float] = []
|
||
ready = threading.Event()
|
||
enough = threading.Event()
|
||
callback_error: list[str] = []
|
||
accepted_samples = 0
|
||
callback_index = 0
|
||
lock = threading.Lock()
|
||
|
||
@HackrfCallback
|
||
def receive_callback(transfer_pointer) -> int:
|
||
nonlocal accepted_samples, callback_index
|
||
try:
|
||
transfer = transfer_pointer.contents
|
||
payload = copy_valid_callback_bytes(
|
||
transfer.buffer,
|
||
transfer.buffer_length,
|
||
transfer.valid_length,
|
||
)
|
||
now = time.perf_counter()
|
||
with lock:
|
||
index = callback_index
|
||
callback_index += 1
|
||
blocks.append(payload)
|
||
callback_buffer_lengths.append(int(transfer.buffer_length))
|
||
callback_times.append(now)
|
||
if index >= HACKRF_WARMUP_CALLBACK_COUNT:
|
||
accepted_samples += len(payload) // 2
|
||
ready.set()
|
||
if accepted_samples >= target_sample_count:
|
||
enough.set()
|
||
return 0
|
||
except Exception as error: # pragma: no cover - аппаратный callback
|
||
callback_error.append(f"{type(error).__name__}: {error}")
|
||
enough.set()
|
||
return 1
|
||
|
||
self._callback_reference = receive_callback
|
||
started_at = time.perf_counter()
|
||
self._check(
|
||
self.library.hackrf_start_rx(self.device, receive_callback, None),
|
||
"hackrf_start_rx",
|
||
)
|
||
if not ready.wait(timeout=5.0):
|
||
self.library.hackrf_stop_rx(self.device)
|
||
raise RuntimeError("HackRF RX не вернул первый рабочий callback")
|
||
if not enough.wait(timeout=timeout_seconds):
|
||
self.library.hackrf_stop_rx(self.device)
|
||
raise TimeoutError("HackRF RX не набрал заданное число отсчётов")
|
||
stop_code = self.library.hackrf_stop_rx(self.device)
|
||
self._check(stop_code, "hackrf_stop_rx")
|
||
stopped_at = time.perf_counter()
|
||
if callback_error:
|
||
raise RuntimeError("; ".join(callback_error))
|
||
with lock:
|
||
frozen_blocks = list(blocks)
|
||
frozen_buffer_lengths = list(callback_buffer_lengths)
|
||
frozen_times = list(callback_times)
|
||
samples, synthetic_boundaries, assembly = assemble_callback_blocks(
|
||
frozen_blocks,
|
||
target_sample_count,
|
||
HACKRF_WARMUP_CALLBACK_COUNT,
|
||
frozen_buffer_lengths,
|
||
)
|
||
raw_bytes = b"".join(
|
||
frozen_blocks[row.callback_index][: row.accepted_bytes]
|
||
for row in synthetic_boundaries
|
||
)
|
||
if len(raw_bytes) != 2 * target_sample_count:
|
||
raise RuntimeError("Размер сохранённых int8-байтов не совпал с raw IQ")
|
||
if not np.array_equal(signed_iq_bytes_to_complex64(raw_bytes), samples):
|
||
raise RuntimeError("Исходные int8-байты не совпали с преобразованным raw IQ")
|
||
real_boundaries = []
|
||
for row in synthetic_boundaries:
|
||
real_boundaries.append(
|
||
HackrfBoundary(
|
||
callback_index=row.callback_index,
|
||
valid_bytes=row.valid_bytes,
|
||
accepted_bytes=row.accepted_bytes,
|
||
accepted_start_sample=row.accepted_start_sample,
|
||
accepted_end_sample=row.accepted_end_sample,
|
||
monotonic_seconds=frozen_times[row.callback_index] - started_at,
|
||
block_sha256=row.block_sha256,
|
||
buffer_length=row.buffer_length,
|
||
)
|
||
)
|
||
first_time = frozen_times[HACKRF_WARMUP_CALLBACK_COUNT]
|
||
last_time = frozen_times[real_boundaries[-1].callback_index]
|
||
interval_seconds = max(
|
||
last_time - first_time
|
||
+ real_boundaries[-1].valid_bytes / (2.0 * HACKRF_SAMPLE_RATE_HZ),
|
||
1e-12,
|
||
)
|
||
callback_stream_samples = sum(row.valid_bytes for row in real_boundaries) / 2.0
|
||
assembly.update(
|
||
{
|
||
"stream_started_monotonic": started_at,
|
||
"stream_stopped_monotonic": stopped_at,
|
||
"stream_wall_seconds": stopped_at - started_at,
|
||
"accepted_interval_seconds": interval_seconds,
|
||
"mean_callback_stream_sample_rate_hz": (
|
||
callback_stream_samples / interval_seconds
|
||
),
|
||
"expected_callback_bytes": HACKRF_EXPECTED_CALLBACK_BYTES,
|
||
"observed_callback_byte_sizes": sorted(
|
||
{len(block) for block in frozen_blocks}
|
||
),
|
||
"observed_callback_buffer_lengths": sorted(
|
||
set(frozen_buffer_lengths)
|
||
),
|
||
"usb_loss_counter_available": False,
|
||
"usb_loss_claim": None,
|
||
"usb_loss_claim_status": "not_computed",
|
||
"usb_loss_claim_reason": (
|
||
"libhackrf callback API не предоставляет счётчик потерь"
|
||
),
|
||
}
|
||
)
|
||
return HackrfCapture(samples, tuple(real_boundaries), assembly, raw_bytes)
|
||
|
||
def close(self) -> None:
|
||
if self.library is not None and self.device.value:
|
||
self._check(self.library.hackrf_close(self.device), "hackrf_close")
|
||
self.device = ctypes.c_void_p()
|
||
if self.library is not None and self._initialized:
|
||
self._check(self.library.hackrf_exit(), "hackrf_exit")
|
||
self._initialized = False
|
||
if self._dll_directory is not None:
|
||
self._dll_directory.close()
|
||
self._dll_directory = None
|
||
|
||
def __enter__(self):
|
||
self.open()
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc, traceback):
|
||
self.close()
|
||
return False
|
||
|
||
|
||
def query_hackrf_info() -> dict:
|
||
completed = subprocess.run(
|
||
[str(HACKRF_INFO)],
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
)
|
||
text = completed.stdout + completed.stderr
|
||
|
||
def value(prefix: str) -> str:
|
||
for line in text.splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith(prefix):
|
||
return stripped.split(":", 1)[1].strip()
|
||
raise RuntimeError(f"hackrf_info не вернул поле {prefix}")
|
||
|
||
serial = value("Serial number")
|
||
if serial != EXPECTED_SERIAL:
|
||
raise RuntimeError(f"Неожиданный серийный номер HackRF: {serial}")
|
||
return {
|
||
"model": "HackRF One",
|
||
"serial_number": serial,
|
||
"firmware_version": value("Firmware Version"),
|
||
"libhackrf_version": value("libhackrf version"),
|
||
"hardware_revision": value("Hardware Revision"),
|
||
"hackrf_info_version": value("hackrf_info version"),
|
||
"information_query": text.strip(),
|
||
}
|
||
|
||
|
||
def preflight_hackrf(
|
||
lna_gain_db: int = HACKRF_LNA_GAIN_DB,
|
||
vga_gain_db: int = HACKRF_VGA_GAIN_DB,
|
||
) -> dict:
|
||
information = query_hackrf_info()
|
||
with HackrfRxDevice() as receiver:
|
||
parameters = receiver.configure_rx()
|
||
gain_parameters = receiver.set_rx_gains(lna_gain_db, vga_gain_db)
|
||
probe = receiver.capture(HACKRF_PROBE_SAMPLE_COUNT)
|
||
samples = probe.samples
|
||
raw_i = np.rint(samples.real * 128.0)
|
||
raw_q = np.rint(samples.imag * 128.0)
|
||
return information | parameters | gain_parameters | {
|
||
"device_opened_via_libhackrf": True,
|
||
"rx_started": True,
|
||
"probe_sample_count": len(samples),
|
||
"probe_dtype": str(samples.dtype),
|
||
"probe_mean_i": float(np.mean(samples).real),
|
||
"probe_mean_q": float(np.mean(samples).imag),
|
||
"probe_rms": float(np.sqrt(np.mean(np.abs(samples) ** 2))),
|
||
"probe_peak_magnitude": float(np.max(np.abs(samples))),
|
||
"probe_clipped_component_fraction": float(
|
||
np.mean((np.abs(raw_i) >= 127) | (np.abs(raw_q) >= 127))
|
||
),
|
||
"probe_stream": probe.diagnostics,
|
||
}
|
||
|
||
|
||
def _waveform_metadata(plan: lab043.PrbsTransmissionPlan, waveform_sha256: str) -> dict:
|
||
return {
|
||
"calibration_tones_hz": [-lab043.F_CAL_HZ, lab043.F_CAL_HZ],
|
||
"calibration_duration_seconds": lab043.CALIBRATION_TONE_DURATION_SECONDS,
|
||
"fixed_guard_seconds": lab043.CALIBRATION_TO_BPSK_GUARD_SECONDS,
|
||
"single_initial_marker_bit_count": len(plan.marker_bits),
|
||
"pilot_symbol_count": len(plan.pilot_bits),
|
||
"pilot_duration_seconds": len(plan.pilot_bits) / lab043.SYMBOL_RATE,
|
||
"pilot_polynomial": lab043.PILOT_POLYNOMIAL,
|
||
"pilot_initial_state_hex": f"0x{lab043.PILOT_INITIAL_STATE:02X}",
|
||
"pilot_sha256": hashlib.sha256(plan.pilot_bits.tobytes()).hexdigest(),
|
||
"periodic_resynchronization": False,
|
||
"prbs_polynomial": lab043.PRBS11_POLYNOMIAL,
|
||
"prbs_initial_state_hex": f"0x{lab043.PRBS11_INITIAL_STATE:03X}",
|
||
"prbs_useful_duration_seconds": plan.useful_duration_seconds,
|
||
"transmitted_prbs_bit_count": len(plan.payload_bits),
|
||
"transmitted_prbs_sha256": hashlib.sha256(plan.payload_bits.tobytes()).hexdigest(),
|
||
"tx_sample_count": len(plan.tx_samples),
|
||
"tx_samples_sha256": waveform_sha256,
|
||
}
|
||
|
||
|
||
def _capture_triplets(capture: diagnostics.CaptureAnalysis) -> list[dict]:
|
||
return diagnostics.triplet_rows(
|
||
capture.name,
|
||
capture.payload_bits,
|
||
capture.payload_normalized,
|
||
)
|
||
|
||
|
||
def save_comparison_artifacts(
|
||
hackrf_metadata_path: Path,
|
||
output_directory: Path = PROCESSED_DIRECTORY,
|
||
) -> tuple[Path, ...]:
|
||
output_directory.mkdir(parents=True, exist_ok=True)
|
||
hackrf = diagnostics.load_capture(
|
||
hackrf_metadata_path.parent,
|
||
hackrf_metadata_path.name,
|
||
require_processing_success=False,
|
||
)
|
||
rtl_raw = Path("data/raw/lab043")
|
||
rtl_captures = [
|
||
diagnostics.load_capture(rtl_raw, name)
|
||
for name in diagnostics.VALID_CAPTURE_NAMES
|
||
]
|
||
captures = rtl_captures + [hackrf]
|
||
metadata_documents = {
|
||
capture.name: capture.metadata for capture in captures
|
||
}
|
||
summary_rows = []
|
||
triplet_rows = []
|
||
run_rows = []
|
||
dc_rows = []
|
||
phase_rows = []
|
||
for capture in captures:
|
||
metadata = metadata_documents[capture.name]
|
||
mode_b = metadata["analysis"]["modes"]["B"]
|
||
summary_rows.append(
|
||
{
|
||
"receiver": "HackRF" if capture is hackrf else "RTL-SDR",
|
||
"capture": capture.name,
|
||
"marker_detected": mode_b["detected"],
|
||
"marker_correlation": mode_b["marker_correlation"],
|
||
"coarse_carrier_offset_hz": metadata["analysis"]["calibration"]["carrier_offset_hz"],
|
||
"carrier_offset_ppm": metadata["analysis"]["calibration"]["carrier_offset_ppm"],
|
||
"sample_clock_error_ppm": metadata["analysis"]["calibration"]["sample_clock_error_ppm"],
|
||
"mode_b_bit_errors": mode_b["bit_errors"],
|
||
"mode_b_ber": mode_b["bit_error_rate"],
|
||
"mode_b_quarter_bers": json.dumps(mode_b["quarter_bit_error_rates"]),
|
||
"mode_b_evm_percent": mode_b["evm_percent"],
|
||
"mode_b_maximum_correct_run_bits": mode_b["maximum_correct_run_bits"],
|
||
"peak_magnitude": metadata["overload"]["peak_magnitude"],
|
||
"clipped_component_fraction": metadata["overload"]["clipped_component_fraction"],
|
||
}
|
||
)
|
||
triplet_rows.extend(_capture_triplets(capture))
|
||
run_rows.extend(diagnostics.run_length_rows(capture))
|
||
dc_rows.extend(diagnostics.dc_iq_rows(capture))
|
||
phase_rows.extend(diagnostics.phase_sweep_rows(capture))
|
||
channel_models, channel_coefficients, _channel_triplets = (
|
||
diagnostics.fitted_symbol_channel_rows(captures)
|
||
)
|
||
diagnostics.write_csv(output_directory / "summary.csv", summary_rows)
|
||
diagnostics.write_csv(output_directory / "triplets.csv", triplet_rows)
|
||
diagnostics.write_csv(output_directory / "run_lengths.csv", run_rows)
|
||
diagnostics.write_csv(output_directory / "dc_iq.csv", dc_rows)
|
||
diagnostics.write_csv(output_directory / "channel_models.csv", channel_models)
|
||
diagnostics.write_csv(
|
||
output_directory / "channel_coefficients.csv", channel_coefficients
|
||
)
|
||
diagnostics.save_plots(output_directory, phase_rows, run_rows)
|
||
|
||
lines = [
|
||
"Lab043: сравнительный короткий S, RTL-SDR и HackRF",
|
||
"",
|
||
"Основной режим: двухтональная грубая коррекция, без пилотной тонкой коррекции и без SRO.",
|
||
]
|
||
for row in summary_rows:
|
||
lines.append(
|
||
f"- {row['receiver']} / {row['capture']}: маркер={row['marker_detected']}, "
|
||
f"corr={float(row['marker_correlation']):.6f}, CFO={float(row['coarse_carrier_offset_hz']):+.3f} Гц, "
|
||
f"SRO={float(row['sample_clock_error_ppm']):+.3f} ppm, "
|
||
f"ошибок={row['mode_b_bit_errors']}/5000, BER={float(row['mode_b_ber']):.6f}, "
|
||
f"EVM={float(row['mode_b_evm_percent']):.3f}%."
|
||
)
|
||
lines.append("")
|
||
lines.append("Тройки после общей диагностической линейной фазовой коррекции по известной PRBS:")
|
||
for capture in captures:
|
||
selected = [row for row in triplet_rows if row["source"] == capture.name]
|
||
counts = {row["triplet"]: row["bit_errors"] for row in selected}
|
||
lines.append(
|
||
f"- {capture.name}: 000={counts['000']}, 001={counts['001']}, "
|
||
f"остальные={sum(counts[key] for key in diagnostics.TRIPLETS if key not in {'000', '001'})}."
|
||
)
|
||
lines.append("")
|
||
lines.append(
|
||
"Полные численные таблицы находятся в summary.csv, triplets.csv, run_lengths.csv, "
|
||
"dc_iq.csv и channel_coefficients.csv. Классификация A/B/C/D определяется только по факту."
|
||
)
|
||
report_path = output_directory / "report.txt"
|
||
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return tuple(sorted(output_directory.iterdir()))
|
||
|
||
|
||
def run_one_hackrf_s(
|
||
raw_directory: Path = RAW_DIRECTORY,
|
||
processed_directory: Path = PROCESSED_DIRECTORY,
|
||
) -> dict:
|
||
"""Выполнить ровно один S; функция никогда не повторяет передачу."""
|
||
|
||
plan = lab043.build_prbs11_transmission_plan(
|
||
"S", lab043.PRBS_SHORT_DURATION_SECONDS
|
||
)
|
||
waveform_sha256 = hashlib.sha256(
|
||
np.asarray(plan.tx_samples, dtype=np.complex64).tobytes()
|
||
).hexdigest()
|
||
expected_rtl_waveform_sha = "981aeddde3ed63192c24bfbe8a11f58b2896b50a643dd5faf3f81eaa9c2bf0bc"
|
||
if waveform_sha256 != expected_rtl_waveform_sha:
|
||
raise RuntimeError(
|
||
"Waveform S не совпал с последним валидным RTL-SDR S; TX запрещён"
|
||
)
|
||
|
||
pluto_preflight = rtl_hardware.preflight_pluto()
|
||
hackrf_preflight = preflight_hackrf()
|
||
pluto, tx_parameters = rtl_hardware.configure_pluto()
|
||
actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"])
|
||
expected_without_callback_margin = lab043.receive_sample_count(
|
||
len(plan.tx_samples), actual_tx_rate, HACKRF_SAMPLE_RATE_HZ
|
||
)
|
||
target_samples = expected_without_callback_margin + HACKRF_EXPECTED_CALLBACK_BYTES // 2
|
||
capture_started = datetime.now(timezone.utc)
|
||
ready = threading.Event()
|
||
tx_error: list[str] = []
|
||
tx_hold_seconds: list[float] = []
|
||
capture_holder: list[HackrfCapture] = []
|
||
|
||
with HackrfRxDevice() as receiver:
|
||
rx_parameters = receiver.configure_rx()
|
||
|
||
def capture_rx() -> None:
|
||
try:
|
||
# capture() подтверждает первый рабочий callback внутренне; внешний ready
|
||
# выставляется сразу после старта потока посредством короткой проверки is_streaming.
|
||
capture_holder.append(receiver.capture(target_samples))
|
||
except Exception as error: # pragma: no cover - аппаратный путь
|
||
tx_error.append(f"RX {type(error).__name__}: {error}")
|
||
|
||
rx_thread = threading.Thread(target=capture_rx, daemon=True)
|
||
rx_thread.start()
|
||
deadline = time.perf_counter() + 5.0
|
||
while time.perf_counter() < deadline:
|
||
if receiver.library.hackrf_is_streaming(receiver.device) == 1:
|
||
ready.set()
|
||
break
|
||
if tx_error:
|
||
break
|
||
time.sleep(0.01)
|
||
if not ready.is_set():
|
||
rx_thread.join(timeout=1.0)
|
||
raise RuntimeError("HackRF RX не подтвердил поток; Pluto TX не включён")
|
||
time.sleep(lab043.RX_LEADING_MARGIN_SECONDS)
|
||
try:
|
||
tx_hold_seconds.append(
|
||
rtl_hardware.transmit_noncyclic_buffer_once(
|
||
pluto,
|
||
lab043.to_pluto_tx_samples(plan.tx_samples),
|
||
actual_tx_rate,
|
||
)
|
||
)
|
||
except Exception as error: # pragma: no cover - аппаратный путь
|
||
tx_error.append(f"TX {type(error).__name__}: {error}")
|
||
rx_thread.join(timeout=HACKRF_STREAM_TIMEOUT_SECONDS + 5.0)
|
||
if rx_thread.is_alive():
|
||
raise RuntimeError("Непрерывный поток HackRF не завершился")
|
||
if tx_error:
|
||
raise RuntimeError("; ".join(tx_error))
|
||
if len(capture_holder) != 1:
|
||
raise RuntimeError("HackRF не вернул единственный массив RX")
|
||
capture = capture_holder[0]
|
||
received = capture.samples
|
||
if len(received) != target_samples or not np.all(np.isfinite(received)):
|
||
raise RuntimeError("Полученный массив HackRF неполон или содержит нечисловые значения")
|
||
|
||
raw_i = np.rint(received.real * 128.0)
|
||
raw_q = np.rint(received.imag * 128.0)
|
||
overload = {
|
||
"peak_magnitude": float(np.max(np.abs(received))),
|
||
"clipped_component_fraction": float(
|
||
np.mean((np.abs(raw_i) >= 127) | (np.abs(raw_q) >= 127))
|
||
),
|
||
}
|
||
boundary_rows = [asdict(row) for row in capture.boundaries]
|
||
calibration_end_seconds = plan.calibration_sample_count / actual_tx_rate
|
||
guard_end_seconds = plan.bpsk_start_sample / actual_tx_rate
|
||
tx_duration = len(plan.tx_samples) / actual_tx_rate
|
||
metadata = {
|
||
"experiment": "Lab043 HackRF comparative PRBS11 continuous RX",
|
||
"capture_label": "S",
|
||
"capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"),
|
||
"capture_status": "captured",
|
||
"processing_status": "pending",
|
||
"processing_error": None,
|
||
"receiver_model": "HackRF",
|
||
"actual_sample_rate_hz": HACKRF_SAMPLE_RATE_HZ,
|
||
"center_frequency_hz": HACKRF_CENTER_HZ,
|
||
"rx_gain_db": HACKRF_LNA_GAIN_DB + HACKRF_VGA_GAIN_DB,
|
||
"pluto_preflight": pluto_preflight,
|
||
"hackrf_preflight": hackrf_preflight,
|
||
"tx_parameters": tx_parameters,
|
||
"rx_parameters": rx_parameters | {
|
||
"serial_number": hackrf_preflight["serial_number"],
|
||
"firmware_version": hackrf_preflight["firmware_version"],
|
||
"libhackrf_version": hackrf_preflight["libhackrf_version"],
|
||
},
|
||
"physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> SMA/adapter -> HackRF ANTENNA; HackRF RX only",
|
||
"waveform_sha256": waveform_sha256,
|
||
"tx_waveform_duration_seconds": tx_duration,
|
||
"callback_count": capture.diagnostics["callback_count_total"],
|
||
"callback_boundaries": boundary_rows,
|
||
"expected_intervals": {
|
||
"relative_to_tx_start_seconds": {
|
||
"calibration": [0.0, calibration_end_seconds],
|
||
"guard": [calibration_end_seconds, guard_end_seconds],
|
||
"bpsk_marker_pilot_prbs": [guard_end_seconds, tx_duration],
|
||
},
|
||
"rx_leading_margin_seconds": lab043.RX_LEADING_MARGIN_SECONDS,
|
||
"rx_trailing_margin_seconds_minimum": lab043.RX_TRAILING_MARGIN_SECONDS,
|
||
"extra_callback_margin_samples": HACKRF_EXPECTED_CALLBACK_BYTES // 2,
|
||
},
|
||
"waveform": _waveform_metadata(plan, waveform_sha256),
|
||
"continuous_rx": capture.diagnostics | {
|
||
"boundaries": boundary_rows,
|
||
"program_continuity_confirmed": bool(
|
||
capture.diagnostics["callback_indexes_sequential"]
|
||
and capture.diagnostics["program_queue_missing_callback_count"] == 0
|
||
and capture.diagnostics["duplicate_full_block_hash_count"] == 0
|
||
),
|
||
"usb_continuity_proven": False,
|
||
},
|
||
"tx_buffer_hold_seconds": tx_hold_seconds[0] if tx_hold_seconds else None,
|
||
"overload": overload,
|
||
}
|
||
stamp = capture_started.strftime("%Y%m%d_%H%M%S")
|
||
base = raw_directory / f"lab043_hackrf_prbs11_s_{stamp}"
|
||
iq_path, metadata_path, analysis, processing_error = lab043.save_then_process_capture(
|
||
base,
|
||
received,
|
||
metadata,
|
||
lambda stored: rtl_hardware.analyze_captured_prbs(
|
||
stored,
|
||
plan,
|
||
HACKRF_SAMPLE_RATE_HZ,
|
||
actual_tx_rate,
|
||
capture.boundaries,
|
||
),
|
||
)
|
||
result = {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"waveform_sha256": waveform_sha256,
|
||
"capture_status": "captured",
|
||
"processing_status": "processing_failed" if analysis is None else "success",
|
||
"processing_error": processing_error,
|
||
"tx_parameters": tx_parameters,
|
||
"rx_parameters": rx_parameters,
|
||
"continuous_rx": capture.diagnostics,
|
||
"overload": overload,
|
||
}
|
||
if analysis is not None:
|
||
result.update(
|
||
{
|
||
"processing_success": analysis.processing_success,
|
||
"invalid_reason": analysis.invalid_reason,
|
||
"calibration": asdict(analysis.calibration),
|
||
"continuity": analysis.continuity,
|
||
"modes": {
|
||
mode: asdict(metrics) for mode, metrics in analysis.modes.items()
|
||
},
|
||
}
|
||
)
|
||
if analysis.processing_success:
|
||
save_comparison_artifacts(metadata_path, processed_directory)
|
||
else:
|
||
processed_directory.mkdir(parents=True, exist_ok=True)
|
||
diagnostics.write_csv(
|
||
processed_directory / "summary.csv",
|
||
[
|
||
{
|
||
"receiver": "HackRF",
|
||
"capture": metadata_path.stem,
|
||
"processing_success": False,
|
||
"invalid_reason": analysis.invalid_reason,
|
||
"peak_magnitude": overload["peak_magnitude"],
|
||
"clipped_component_fraction": overload[
|
||
"clipped_component_fraction"
|
||
],
|
||
}
|
||
],
|
||
)
|
||
(processed_directory / "report.txt").write_text(
|
||
"Lab043 HackRF: отрицательный результат первого S\n\n"
|
||
f"Raw сохранён: {iq_path}\n"
|
||
f"Причина недостоверности: {analysis.invalid_reason}\n"
|
||
"Усиление не изменялось, повторный TX не выполнялся.\n",
|
||
encoding="utf-8",
|
||
)
|
||
return result
|
||
|
||
|
||
def save_negative_comparison_artifacts(
|
||
document: dict,
|
||
analysis: rtl_hardware.CapturedPrbsAnalysis,
|
||
iq_path: Path,
|
||
output_directory: Path,
|
||
) -> tuple[Path, ...]:
|
||
"""Зафиксировать недействительный S без выдуманных BER и кластеров."""
|
||
|
||
from matplotlib import pyplot as plt
|
||
|
||
output_directory.mkdir(parents=True, exist_ok=True)
|
||
mode_b = analysis.modes["B"]
|
||
calibration = analysis.calibration
|
||
stream = document["continuous_rx"]
|
||
summary = {
|
||
"receiver": "HackRF",
|
||
"capture": iq_path.stem,
|
||
"result_valid": False,
|
||
"invalid_reason": analysis.invalid_reason or mode_b.failure_reason,
|
||
"raw_iq_sha256": document["iq_sha256"],
|
||
"sample_count": document["sample_count"],
|
||
"marker_detected": mode_b.detected,
|
||
"marker_correlation": float("nan"),
|
||
"carrier_offset_hz_preliminary": calibration.carrier_offset_hz,
|
||
"carrier_offset_ppm_preliminary": calibration.carrier_offset_ppm,
|
||
"sample_clock_error_ppm_preliminary": calibration.sample_clock_error_ppm,
|
||
"phase_fit_rmse_rad": calibration.phase_fit_rmse_rad,
|
||
"mode_b_bit_errors": None,
|
||
"mode_b_ber": None,
|
||
"mode_b_evm_percent": None,
|
||
"mode_b_metrics_status": "not_computed",
|
||
"peak_magnitude": document["overload"]["peak_magnitude"],
|
||
"clipped_component_fraction": document["overload"][
|
||
"clipped_component_fraction"
|
||
],
|
||
"callback_count": stream["callback_count_total"],
|
||
"accepted_callback_count": stream["accepted_callback_count"],
|
||
"program_continuity_confirmed": stream["program_continuity_confirmed"],
|
||
"usb_continuity_proven": stream["usb_continuity_proven"],
|
||
"mean_callback_stream_sample_rate_hz": stream[
|
||
"mean_callback_stream_sample_rate_hz"
|
||
],
|
||
"confirmed_phase_discontinuities": analysis.continuity[
|
||
"confirmed_discontinuity_count"
|
||
],
|
||
"maximum_phase_jump_rad": analysis.continuity[
|
||
"maximum_absolute_phase_jump_rad"
|
||
],
|
||
"classification": "D",
|
||
}
|
||
diagnostics.write_csv(output_directory / "summary.csv", [summary])
|
||
diagnostics.write_csv(
|
||
output_directory / "triplets.csv",
|
||
[
|
||
{
|
||
"triplet": triplet,
|
||
"status": "not_computed",
|
||
"reason": "захват недействителен из-за фазовых разрывов",
|
||
}
|
||
for triplet in diagnostics.TRIPLETS
|
||
],
|
||
)
|
||
diagnostics.write_csv(
|
||
output_directory / "run_lengths.csv",
|
||
[
|
||
{
|
||
"bit": bit,
|
||
"run_length": length,
|
||
"status": "not_computed",
|
||
"reason": "захват недействителен из-за фазовых разрывов",
|
||
}
|
||
for bit in (0, 1)
|
||
for length in ("1", "2", "3", "4", "5", "6", "7+")
|
||
],
|
||
)
|
||
diagnostics.write_csv(
|
||
output_directory / "continuity.csv",
|
||
analysis.continuity["boundaries"],
|
||
)
|
||
|
||
samples = np.load(iq_path, allow_pickle=False)
|
||
sample_rate = float(document["actual_sample_rate_hz"])
|
||
block_samples = 8192
|
||
hop_samples = 4096
|
||
centered = samples - np.mean(samples[: round(0.3 * sample_rate)])
|
||
starts = np.arange(0, len(samples) - block_samples + 1, hop_samples)
|
||
powers = np.asarray(
|
||
[
|
||
np.mean(np.abs(centered[start : start + block_samples]) ** 2)
|
||
for start in starts
|
||
]
|
||
)
|
||
baseline_limit = max(3, int(0.30 * sample_rate / hop_samples))
|
||
baseline = float(np.median(powers[:baseline_limit]))
|
||
excess_db = 10.0 * np.log10(np.maximum(powers, 1e-30) / max(baseline, 1e-30))
|
||
maximum_broadband_excess_db = float(np.max(excess_db))
|
||
sections = asdict(analysis)["sections"]
|
||
boundary_rows = analysis.continuity["boundaries"]
|
||
|
||
figure, axes = plt.subplots(2, 1, figsize=(10, 8))
|
||
axes[0].plot(starts / sample_rate, excess_db)
|
||
axes[0].axhline(10.0, color="tab:red", linestyle="--", label="Фиксированный порог 10 дБ")
|
||
axes[0].axvspan(
|
||
sections["calibration_start_sample"] / sample_rate,
|
||
sections["calibration_end_sample"] / sample_rate,
|
||
color="tab:green",
|
||
alpha=0.15,
|
||
label="Найденный двухтоновый участок",
|
||
)
|
||
axes[0].set_title("HackRF S: широкополосная мощность")
|
||
axes[0].set_xlabel("Время от начала сохранённого raw, с")
|
||
axes[0].set_ylabel("Превышение над фоном, дБ")
|
||
axes[0].grid(True, alpha=0.3)
|
||
axes[0].legend()
|
||
boundary_times = [row["boundary_sample"] / sample_rate for row in boundary_rows]
|
||
axes[1].plot(
|
||
boundary_times,
|
||
[row["low_phase_jump_rad"] for row in boundary_rows],
|
||
marker="o",
|
||
label="Нижний тон",
|
||
)
|
||
axes[1].plot(
|
||
boundary_times,
|
||
[row["high_phase_jump_rad"] for row in boundary_rows],
|
||
marker="o",
|
||
label="Верхний тон",
|
||
)
|
||
axes[1].axhline(0.25, color="tab:red", linestyle="--")
|
||
axes[1].axhline(-0.25, color="tab:red", linestyle="--", label="Предел ±0,25 рад")
|
||
axes[1].set_title("Фазовые скачки на callback-границах")
|
||
axes[1].set_xlabel("Время границы, с")
|
||
axes[1].set_ylabel("Скачок фазы, рад")
|
||
axes[1].grid(True, alpha=0.3)
|
||
axes[1].legend()
|
||
figure.tight_layout()
|
||
figure.savefig(output_directory / "negative_capture_diagnostics.png", dpi=150)
|
||
plt.close(figure)
|
||
|
||
report = f"""Lab043: единственный сравнительный S с HackRF
|
||
|
||
Результат: недействителен для BER и локализации асимметрии 000/001.
|
||
Классификация: D, данных недостаточно для локализации.
|
||
|
||
Raw: {iq_path}
|
||
SHA-256: {document['iq_sha256']}
|
||
Waveform SHA-256: {document['waveform_sha256']}
|
||
|
||
HackRF: {document['rx_parameters']['serial_number']}, firmware {document['rx_parameters']['firmware_version']}, libhackrf {document['rx_parameters']['libhackrf_version']}.
|
||
RX: 435 МГц, запрос 2 400 000 отсчётов/с, RF amp OFF, LNA 8 дБ, VGA 8 дБ, bias OFF.
|
||
Pluto+: фактически {document['tx_parameters']['actual_center_frequency_hz']} Гц, {document['tx_parameters']['actual_sample_rate_hz']} отсчётов/с, gain {document['tx_parameters']['actual_gain_db']} дБ, RF bandwidth {document['tx_parameters']['actual_rf_bandwidth_hz']} Гц.
|
||
|
||
Программная сборка: {stream['accepted_callback_count']} рабочих callback после одного заранее отброшенного разогревочного; индексы последовательны, дубликатов полных блоков {stream['duplicate_full_block_hash_count']}, пропусков очереди {stream['program_queue_missing_callback_count']}.
|
||
Средняя callback-скорость: {stream['mean_callback_stream_sample_rate_hz']:.3f} отсчётов/с.
|
||
USB-потери аппаратно не доказаны и не опровергнуты: внутреннего счётчика API нет.
|
||
|
||
Два тона найдены неизменным спектральным порогом 10 дБ: нижний +{calibration.peak_margin_low_db:.3f} дБ, верхний +{calibration.peak_margin_high_db:.3f} дБ.
|
||
Максимальный рост полной мощности: только {maximum_broadband_excess_db:.3f} дБ, поэтому исходный широкополосный локатор не сработал.
|
||
Предварительная оценка CFO: {calibration.carrier_offset_hz:+.3f} Гц ({calibration.carrier_offset_ppm:+.6f} ppm).
|
||
Предварительная оценка SRO: {calibration.sample_clock_error_ppm:+.3f} ppm.
|
||
Эти оценки нельзя считать итоговыми: RMSE фазовой модели {calibration.phase_fit_rmse_rad:.3f} рад и в калибровке подтверждено {analysis.continuity['confirmed_discontinuity_count']} фазовых разрыва из {analysis.continuity['checked_boundary_count']} проверенных границ; максимум {analysis.continuity['maximum_absolute_phase_jump_rad']:.3f} рад при пределе 0,25 рад.
|
||
|
||
Маркер не вычислялся.
|
||
BER (доля ошибочных битов) не вычислялась.
|
||
BER по четвертям не вычислялась.
|
||
EVM (величина вектора ошибки) не вычислялась.
|
||
Тройки 000–111, серии и кластеры не вычислялись, чтобы не выдавать недостоверный захват за измерение.
|
||
|
||
Сравнение с RTL-SDR невозможно: два RTL-SDR S валидны, а HackRF S не прошёл контроль непрерывности. Главный текущий подозреваемый не 000/001-механизм, а достоверность приёма HackRF на минимальном усилении и/или непрерывность USB/драйвера. Усиления и пороги не менялись, повторный TX не выполнялся.
|
||
|
||
Следующий шаг, пока не выполненный: отдельно доказать непрерывность HackRF RX и достаточную фазовую точность на 2,4 Мвыб/с без изменения протокола; только затем отдельно решить, разрешать ли новый S или изменение усиления.
|
||
"""
|
||
(output_directory / "report.txt").write_text(report, encoding="utf-8")
|
||
return tuple(sorted(output_directory.iterdir()))
|
||
|
||
|
||
def reprocess_saved_capture(
|
||
metadata_path: Path,
|
||
processed_directory: Path = PROCESSED_DIRECTORY,
|
||
) -> dict:
|
||
"""Повторно обработать только уже сохранённый raw, не открывая SDR."""
|
||
|
||
metadata_path = Path(metadata_path)
|
||
document = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||
iq_path = metadata_path.parent / document["iq_file"]
|
||
actual_sha = hashlib.sha256(iq_path.read_bytes()).hexdigest()
|
||
if not document.get("raw_verified") or actual_sha != document.get("iq_sha256"):
|
||
raise RuntimeError("SHA-256 сохранённого raw не прошёл повторную проверку")
|
||
samples = np.load(iq_path, allow_pickle=False)
|
||
if samples.dtype != np.dtype(np.complex64) or len(samples) != document["sample_count"]:
|
||
raise RuntimeError("Формат сохранённого raw не совпал с JSON")
|
||
plan = lab043.build_prbs11_transmission_plan(
|
||
"S", lab043.PRBS_SHORT_DURATION_SECONDS
|
||
)
|
||
waveform_sha256 = hashlib.sha256(
|
||
np.asarray(plan.tx_samples, dtype=np.complex64).tobytes()
|
||
).hexdigest()
|
||
if waveform_sha256 != document["waveform_sha256"]:
|
||
raise RuntimeError("Waveform сохранённого опыта не совпал с текущим детерминированным S")
|
||
boundaries = tuple(
|
||
HackrfBoundary(**row) for row in document["callback_boundaries"]
|
||
)
|
||
try:
|
||
analysis = rtl_hardware.analyze_captured_prbs(
|
||
samples,
|
||
plan,
|
||
float(document["actual_sample_rate_hz"]),
|
||
float(document["tx_parameters"]["actual_sample_rate_hz"]),
|
||
boundaries,
|
||
)
|
||
except Exception as error:
|
||
message = f"{type(error).__name__}: {error}"
|
||
lab043.update_processing_metadata(
|
||
metadata_path,
|
||
processing_status="processing_failed",
|
||
processing_error=message,
|
||
)
|
||
return {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"processing_status": "processing_failed",
|
||
"processing_error": message,
|
||
}
|
||
lab043.update_processing_metadata(
|
||
metadata_path,
|
||
processing_status="success",
|
||
processing_error=None,
|
||
analysis=asdict(analysis),
|
||
)
|
||
mode_b = analysis.modes["B"]
|
||
result = {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"iq_sha256": actual_sha,
|
||
"processing_status": "success",
|
||
"processing_success": analysis.processing_success,
|
||
"invalid_reason": analysis.invalid_reason,
|
||
"calibration": asdict(analysis.calibration),
|
||
"continuity": analysis.continuity,
|
||
"modes": {mode: asdict(metrics) for mode, metrics in analysis.modes.items()},
|
||
}
|
||
mode_b_valid = bool(
|
||
mode_b.detected and mode_b.matched_bit_count == len(plan.payload_bits)
|
||
)
|
||
if mode_b_valid:
|
||
save_comparison_artifacts(metadata_path, processed_directory)
|
||
else:
|
||
refreshed_document = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||
save_negative_comparison_artifacts(
|
||
refreshed_document,
|
||
analysis,
|
||
iq_path,
|
||
processed_directory,
|
||
)
|
||
return result
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
group = parser.add_mutually_exclusive_group(required=True)
|
||
group.add_argument("--preflight-only", action="store_true")
|
||
group.add_argument("--run-one-s", action="store_true")
|
||
group.add_argument("--reprocess-metadata", type=Path)
|
||
arguments = parser.parse_args()
|
||
if arguments.preflight_only:
|
||
result = {
|
||
"pluto": rtl_hardware.preflight_pluto(),
|
||
"hackrf": preflight_hackrf(),
|
||
}
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0
|
||
if arguments.reprocess_metadata is not None:
|
||
result = reprocess_saved_capture(arguments.reprocess_metadata)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0 if result["processing_status"] == "success" else 2
|
||
result = run_one_hackrf_s()
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0 if result["capture_status"] == "captured" else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|