"""Один сравнительный короткий 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())