Files
SDR-Rover/experiments/lab043_hackrf_hardware.py

1174 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Один сравнительный короткий 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())