"""Однотонная проверка непрерывности HackRF RX для Lab043. Разрешён ровно один непрерывный комплексный тон Pluto+ +50 кГц. Здесь нет BPSK, PRBS, пакетов, JPEG и HackRF TX. """ from __future__ import annotations import argparse import hashlib import json import math import threading import time from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path import numpy as np from experiments import lab043_hackrf_hardware as hackrf from experiments import lab043_pluto_to_rtlsdr as lab043 from experiments import lab043_prbs11_hardware as rtl_hardware TONE_OFFSET_HZ = 50_000.0 TONE_AMPLITUDE = 0.35 TONE_SECONDS = 5.0 RX_LEADING_SECONDS = 0.75 RX_TRAILING_SECONDS = 0.75 STABLE_EDGE_SECONDS = 0.25 CYCLIC_PERIOD_COUNT = 4096 COARSE_FFT_SAMPLES = 262_144 TONE_MARGIN_THRESHOLD_DB = 10.0 BOUNDARY_PHASE_THRESHOLD_RAD = 0.25 LOCAL_PHASE_RMS_LIMIT_RAD = 0.35 BOUNDARY_SIDE_SAMPLES = 65_536 LOCAL_PHASE_BLOCK_SAMPLES = 32_768 LOCAL_PHASE_HOP_SAMPLES = 8_192 GLOBAL_PHASE_BLOCK_SAMPLES = 262_144 GLOBAL_PHASE_HOP_SAMPLES = 65_536 TRANSITION_BLOCK_SAMPLES = 16_384 TRANSITION_HOP_SAMPLES = 512 RAW_DIRECTORY = Path("data/raw/lab043_hackrf") @dataclass(frozen=True) class TonePhaseFit: frequency_hz: float phase_at_reference_rad: float phase_fit_rms_rad: float maximum_phase_error_rad: float projection_count: int def build_periodic_tone(actual_sample_rate_hz: float) -> tuple[np.ndarray, float]: """Построить циклически непрерывный тон около +50 кГц.""" samples_per_cycle = int(round(actual_sample_rate_hz / TONE_OFFSET_HZ)) sample_count = samples_per_cycle * CYCLIC_PERIOD_COUNT indexes = np.arange(sample_count, dtype=np.float64) tone = TONE_AMPLITUDE * np.exp( 1j * 2.0 * np.pi * CYCLIC_PERIOD_COUNT * indexes / sample_count ) actual_tone_hz = CYCLIC_PERIOD_COUNT * actual_sample_rate_hz / sample_count return tone.astype(np.complex64), float(actual_tone_hz) def _phase_projections( samples: np.ndarray, sample_rate_hz: float, coarse_frequency_hz: float, global_start_sample: int, reference_sample: int, block_samples: int, hop_samples: int, ) -> tuple[np.ndarray, np.ndarray]: received = np.asarray(samples, dtype=np.complex64) if len(received) < block_samples + 2 * hop_samples: raise ValueError("Участок слишком короток для фазовой модели") window = np.hanning(block_samples).astype(np.float64) starts = np.arange(0, len(received) - block_samples + 1, hop_samples) projections = np.empty(len(starts), dtype=np.complex128) centers = np.empty(len(starts), dtype=np.float64) local_indexes = np.arange(block_samples, dtype=np.float64) for row, start in enumerate(starts): absolute_start = global_start_sample + int(start) relative_indexes = absolute_start - reference_sample + local_indexes oscillator = np.exp( -1j * 2.0 * np.pi * coarse_frequency_hz * relative_indexes / sample_rate_hz ) projections[row] = np.sum(received[start : start + block_samples] * oscillator * window) centers[row] = ( absolute_start + (block_samples - 1) / 2.0 - reference_sample ) / sample_rate_hz return centers, projections def fit_tone_phase( samples: np.ndarray, sample_rate_hz: float, coarse_frequency_hz: float, global_start_sample: int, reference_sample: int, block_samples: int, hop_samples: int, ) -> TonePhaseFit: """Оценить частоту и фазу тона, не пересекая неизвестную границу.""" times, projections = _phase_projections( samples, sample_rate_hz, coarse_frequency_hz, global_start_sample, reference_sample, block_samples, hop_samples, ) phases = np.unwrap(np.angle(projections)) weights = np.abs(projections) ** 2 design = np.column_stack((np.ones(len(times)), times)) root_weights = np.sqrt(weights / max(float(np.max(weights)), 1e-30)) coefficients, *_ = np.linalg.lstsq( design * root_weights[:, None], phases * root_weights, rcond=None ) fitted = design @ coefficients residuals = phases - fitted weight_sum = max(float(np.sum(weights)), 1e-30) rms = math.sqrt(float(np.sum(weights * residuals**2)) / weight_sum) return TonePhaseFit( frequency_hz=float(coarse_frequency_hz + coefficients[1] / (2.0 * np.pi)), phase_at_reference_rad=float(np.angle(np.exp(1j * coefficients[0]))), phase_fit_rms_rad=float(rms), maximum_phase_error_rad=float(np.max(np.abs(residuals))), projection_count=int(len(projections)), ) def estimate_coarse_tone( samples: np.ndarray, sample_rate_hz: float, fft_samples: int = COARSE_FFT_SAMPLES, ) -> dict: """Найти единственный тон около |50 кГц| по усреднённому спектру.""" received = np.asarray(samples, dtype=np.complex64) if len(received) < fft_samples: raise ValueError("Недостаточно отсчётов для фиксированного спектрального окна") window = np.hanning(fft_samples).astype(np.float64) accumulated = np.zeros(fft_samples, dtype=np.float64) segment_count = len(received) // fft_samples for index in range(segment_count): segment = received[index * fft_samples : (index + 1) * fft_samples] spectrum = np.fft.fft(segment * window) accumulated += np.abs(spectrum) ** 2 powers = accumulated / segment_count frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate_hz) search = (np.abs(frequencies) >= 35_000.0) & (np.abs(frequencies) <= 65_000.0) candidate_indexes = np.flatnonzero(search) peak_index = int(candidate_indexes[np.argmax(powers[candidate_indexes])]) peak_frequency = float(frequencies[peak_index]) local_noise = search & (np.abs(frequencies - peak_frequency) >= 2_000.0) noise_floor = float(np.median(powers[local_noise])) margin_db = 10.0 * math.log10(float(powers[peak_index]) / max(noise_floor, 1e-30)) resolution_hz = sample_rate_hz / fft_samples left = (peak_index - 1) % fft_samples right = (peak_index + 1) % fft_samples y0, y1, y2 = np.log(np.maximum(powers[[left, peak_index, right]], 1e-300)) denominator = y0 - 2.0 * y1 + y2 offset_bins = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator refined = peak_frequency + float(offset_bins) * resolution_hz return { "frequency_hz": float(refined), "peak_bin_frequency_hz": peak_frequency, "tone_above_noise_db": float(margin_db), "noise_floor_power": noise_floor, "fft_resolution_hz": float(resolution_hz), "fft_segment_count": int(segment_count), "credible": bool(margin_db >= TONE_MARGIN_THRESHOLD_DB), } def _wrap_phase(value: float) -> float: return float(np.angle(np.exp(1j * value))) def _transition_offset( samples: np.ndarray, sample_rate_hz: float, coarse_frequency_hz: float, global_start_sample: int, boundary_sample: int, before_fit: TonePhaseFit, jump_rad: float, ) -> dict: times, projections = _phase_projections( samples, sample_rate_hz, coarse_frequency_hz, global_start_sample, boundary_sample, TRANSITION_BLOCK_SAMPLES, TRANSITION_HOP_SAMPLES, ) before_model = before_fit.phase_at_reference_rad + 2.0 * np.pi * ( before_fit.frequency_hz - coarse_frequency_hz ) * times residual = np.unwrap(np.angle(projections * np.exp(-1j * before_model))) kernel = np.ones(9, dtype=np.float64) / 9.0 smoothed = np.convolve(residual, kernel, mode="same") guard = 4 usable = slice(guard, len(times) - guard) target = jump_rad / 2.0 local_index = int(np.argmin(np.abs(smoothed[usable] - target))) + guard return { "transition_offset_samples": float(times[local_index] * sample_rate_hz), "transition_resolution_samples": TRANSITION_HOP_SAMPLES, "transition_window_half_width_samples": TRANSITION_BLOCK_SAMPLES // 2, } def analyze_callback_boundaries( samples: np.ndarray, boundaries: tuple[hackrf.HackrfBoundary, ...], sample_rate_hz: float, coarse_frequency_hz: float, stable_start_sample: int, stable_end_sample: int, side_samples: int = BOUNDARY_SIDE_SAMPLES, block_samples: int = LOCAL_PHASE_BLOCK_SAMPLES, hop_samples: int = LOCAL_PHASE_HOP_SAMPLES, ) -> tuple[list[dict], dict]: rows: list[dict] = [] for boundary in boundaries: sample = int(boundary.accepted_end_sample) if sample - side_samples < stable_start_sample or sample + side_samples > stable_end_sample: continue before = fit_tone_phase( samples[sample - side_samples : sample], sample_rate_hz, coarse_frequency_hz, sample - side_samples, sample, block_samples, hop_samples, ) after = fit_tone_phase( samples[sample : sample + side_samples], sample_rate_hz, coarse_frequency_hz, sample, sample, block_samples, hop_samples, ) jump = _wrap_phase(after.phase_at_reference_rad - before.phase_at_reference_rad) credible = bool( before.phase_fit_rms_rad <= LOCAL_PHASE_RMS_LIMIT_RAD and after.phase_fit_rms_rad <= LOCAL_PHASE_RMS_LIMIT_RAD ) confirmed = bool(credible and abs(jump) > BOUNDARY_PHASE_THRESHOLD_RAD) mean_frequency = 0.5 * (before.frequency_hz + after.frequency_hz) phase_per_sample = 2.0 * np.pi * mean_frequency / sample_rate_hz period_samples = sample_rate_hz / abs(mean_frequency) equivalent_shift = jump / phase_per_sample row = { "callback_index": boundary.callback_index, "boundary_sample": sample, "buffer_length_bytes": boundary.buffer_length, "valid_length_bytes": boundary.valid_bytes, "complex_samples_in_callback": boundary.valid_bytes // 2, "callback_duration_seconds": boundary.valid_bytes / (2.0 * sample_rate_hz), "frequency_before_hz": before.frequency_hz, "frequency_after_hz": after.frequency_hz, "phase_before_at_boundary_rad": before.phase_at_reference_rad, "phase_after_at_boundary_rad": after.phase_at_reference_rad, "expected_phase_per_sample_rad": float(phase_per_sample), "additional_phase_jump_rad": jump, "before_phase_fit_rms_rad": before.phase_fit_rms_rad, "after_phase_fit_rms_rad": after.phase_fit_rms_rad, "credible": credible, "confirmed_jump": confirmed, "equivalent_sample_shift_principal": float(equivalent_shift), "tone_period_samples": float(period_samples), "equivalent_sample_shift_ambiguity": "principal + k * tone_period_samples", } if confirmed: transition_radius = 2 * TRANSITION_BLOCK_SAMPLES local_start = sample - transition_radius local_end = sample + transition_radius transition = _transition_offset( samples[local_start:local_end], sample_rate_hz, coarse_frequency_hz, local_start, sample, before, jump, ) row.update(transition) uncertainty = transition["transition_window_half_width_samples"] offset = transition["transition_offset_samples"] row["relation_to_callback_boundary"] = ( "consistent_with_exact_boundary" if abs(offset) <= uncertainty else "delayed_after_boundary" if offset > uncertainty else "before_boundary" ) else: row.update( { "transition_offset_samples": None, "transition_resolution_samples": None, "transition_window_half_width_samples": None, "relation_to_callback_boundary": "no_confirmed_jump", } ) rows.append(row) credible_count = sum(bool(row["credible"]) for row in rows) confirmed_count = sum(bool(row["confirmed_jump"]) for row in rows) return rows, { "checked_boundary_count": len(rows), "credible_boundary_count": credible_count, "confirmed_jump_count": confirmed_count, "maximum_absolute_jump_rad": float( max((abs(row["additional_phase_jump_rad"]) for row in rows), default=float("nan")) ), } def analyze_continuity_tone( samples: np.ndarray, boundaries: tuple[hackrf.HackrfBoundary, ...], sample_rate_hz: float, stable_start_sample: int, stable_end_sample: int, ) -> dict: stable = np.asarray(samples[stable_start_sample:stable_end_sample], dtype=np.complex64) coarse = estimate_coarse_tone(stable, sample_rate_hz) if not coarse["credible"]: return { "classification": "D", "reason": "Тон не прошёл заранее заданный спектральный порог 10 дБ", "coarse_tone": coarse, "boundaries": [], } whole = fit_tone_phase( stable, sample_rate_hz, coarse["frequency_hz"], stable_start_sample, stable_start_sample, GLOBAL_PHASE_BLOCK_SAMPLES, GLOBAL_PHASE_HOP_SAMPLES, ) rows, boundary_summary = analyze_callback_boundaries( samples, boundaries, sample_rate_hz, coarse["frequency_hz"], stable_start_sample, stable_end_sample, ) thirds = [] edges = np.linspace(stable_start_sample, stable_end_sample, 4, dtype=int) for index in range(3): start, end = int(edges[index]), int(edges[index + 1]) fit = fit_tone_phase( samples[start:end], sample_rate_hz, coarse["frequency_hz"], start, start, GLOBAL_PHASE_BLOCK_SAMPLES, GLOBAL_PHASE_HOP_SAMPLES, ) thirds.append( { "section": index + 1, "start_sample": start, "end_sample": end, **asdict(fit), } ) centers = np.asarray( [0.5 * (row["start_sample"] + row["end_sample"]) / sample_rate_hz for row in thirds] ) frequencies = np.asarray([row["frequency_hz"] for row in thirds]) drift_hz_per_second = float(np.polyfit(centers, frequencies, 1)[0]) required_credible = max(10, math.ceil(0.8 * len(rows))) if len(rows) == 0 or boundary_summary["credible_boundary_count"] < required_credible: classification = "D" reason = "Локальная фазовая точность недостаточна для решения о непрерывности" elif boundary_summary["confirmed_jump_count"]: classification = "B" reason = "Подтверждены реальные фазовые скачки в непрерывном HackRF RX" else: classification = "A" reason = "Однотонный контроль непрерывен; прежняя двухтональная проверка была недостоверна" return { "classification": classification, "reason": reason, "coarse_tone": coarse, "stable_start_sample": int(stable_start_sample), "stable_end_sample": int(stable_end_sample), "stable_sample_count": int(len(stable)), "stable_duration_seconds": float(len(stable) / sample_rate_hz), "whole_tone_phase_fit": asdict(whole), "frequency_by_thirds": thirds, "frequency_drift_hz_per_second": drift_hz_per_second, "frequency_span_hz": float(np.ptp(frequencies)), "boundary_summary": boundary_summary, "boundaries": rows, "phase_jump_threshold_rad": BOUNDARY_PHASE_THRESHOLD_RAD, "local_phase_rms_limit_rad": LOCAL_PHASE_RMS_LIMIT_RAD, "software_callback_bug_found": False, } def _monotonic_to_sample( monotonic_value: float, capture: hackrf.HackrfCapture, sample_rate_hz: float, ) -> int: relative = monotonic_value - capture.diagnostics["stream_started_monotonic"] boundary = min( capture.boundaries, key=lambda row: abs(row.monotonic_seconds - relative), ) estimate = boundary.accepted_end_sample + round( (relative - boundary.monotonic_seconds) * sample_rate_hz ) return int(np.clip(estimate, 0, len(capture.samples))) def run_one_continuity_tone(raw_directory: Path = RAW_DIRECTORY) -> dict: """Выполнить ровно один однотоновый опыт и остановиться.""" pluto_preflight = rtl_hardware.preflight_pluto() hackrf_preflight = hackrf.preflight_hackrf() pluto, tx_parameters = rtl_hardware.configure_pluto() actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) waveform, actual_tx_tone_hz = build_periodic_tone(actual_tx_rate) device_waveform = lab043.to_pluto_tx_samples(waveform) waveform_sha256 = hashlib.sha256(waveform.tobytes()).hexdigest() target_samples = math.ceil( (RX_LEADING_SECONDS + TONE_SECONDS + RX_TRAILING_SECONDS) * hackrf.HACKRF_SAMPLE_RATE_HZ ) + hackrf.HACKRF_EXPECTED_CALLBACK_BYTES // 2 capture_started = datetime.now(timezone.utc) capture_holder: list[hackrf.HackrfCapture] = [] errors: list[str] = [] tx_times: dict[str, float] = {} with hackrf.HackrfRxDevice() as receiver: rx_parameters = receiver.configure_rx() def receive() -> None: try: capture_holder.append(receiver.capture(target_samples)) except Exception as error: # pragma: no cover - аппаратный путь errors.append(f"RX {type(error).__name__}: {error}") rx_thread = threading.Thread(target=receive, 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: break if errors: break time.sleep(0.01) else: raise RuntimeError("HackRF RX не подтвердил непрерывный поток; TX не включён") time.sleep(RX_LEADING_SECONDS) pluto.tx_cyclic_buffer = True pluto.tx_destroy_buffer() try: tx_times["tx_call_started_monotonic"] = time.perf_counter() pluto.tx(device_waveform) tx_times["tx_confirmed_monotonic"] = time.perf_counter() time.sleep(TONE_SECONDS) tx_times["tx_stop_started_monotonic"] = time.perf_counter() finally: pluto.tx_destroy_buffer() tx_times["tx_destroyed_monotonic"] = time.perf_counter() rx_thread.join(timeout=hackrf.HACKRF_STREAM_TIMEOUT_SECONDS + 5.0) if rx_thread.is_alive(): raise RuntimeError("Непрерывный поток HackRF не завершился") if errors: raise RuntimeError("; ".join(errors)) if len(capture_holder) != 1: raise RuntimeError("HackRF не вернул единственный непрерывный массив") capture = capture_holder[0] stable_start = _monotonic_to_sample( tx_times["tx_confirmed_monotonic"] + STABLE_EDGE_SECONDS, capture, hackrf.HACKRF_SAMPLE_RATE_HZ, ) stable_end = _monotonic_to_sample( tx_times["tx_stop_started_monotonic"] - STABLE_EDGE_SECONDS, capture, hackrf.HACKRF_SAMPLE_RATE_HZ, ) overload = { "peak_magnitude": float(np.max(np.abs(capture.samples))), "clipped_component_fraction": float( np.mean( (np.abs(capture.samples.real * 128.0) >= 127) | (np.abs(capture.samples.imag * 128.0) >= 127) ) ), } boundary_rows = [asdict(row) for row in capture.boundaries] metadata = { "experiment": "Lab043 HackRF single-tone continuity", "capture_status": "captured", "processing_status": "pending", "processing_error": None, "capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"), "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"], }, "pluto_preflight": pluto_preflight, "hackrf_preflight": hackrf_preflight, "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF ANTENNA; antennas removed", "waveform_sha256": waveform_sha256, "waveform": { "kind": "single_complex_tone", "requested_offset_hz": TONE_OFFSET_HZ, "actual_periodic_offset_hz": actual_tx_tone_hz, "amplitude": TONE_AMPLITUDE, "cyclic_buffer_sample_count": len(waveform), "cyclic_period_count": CYCLIC_PERIOD_COUNT, }, "tx_waveform_duration_seconds": TONE_SECONDS, "tx_timing": tx_times, "callback_count": capture.diagnostics["callback_count_total"], "callback_boundaries": boundary_rows, "continuous_rx": capture.diagnostics | {"boundaries": boundary_rows}, "expected_intervals": { "rx_leading_seconds": RX_LEADING_SECONDS, "tone_seconds": TONE_SECONDS, "rx_trailing_seconds": RX_TRAILING_SECONDS, "stable_edge_seconds": STABLE_EDGE_SECONDS, "stable_start_sample": stable_start, "stable_end_sample": stable_end, }, "overload": overload, } stamp = capture_started.strftime("%Y%m%d_%H%M%S") base = raw_directory / f"lab043_hackrf_continuity_tone_{stamp}" iq_path, metadata_path, document = lab043.save_raw_iq_before_processing( base, capture.samples, metadata, ) restored = np.load(iq_path, allow_pickle=False) if hashlib.sha256(iq_path.read_bytes()).hexdigest() != document["iq_sha256"]: raise RuntimeError("SHA-256 raw изменился перед анализом") try: analysis = analyze_continuity_tone( restored, capture.boundaries, hackrf.HACKRF_SAMPLE_RATE_HZ, stable_start, stable_end, ) 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), "iq_sha256": document["iq_sha256"], "processing_status": "processing_failed", "processing_error": message, } lab043.update_processing_metadata( metadata_path, processing_status="success", processing_error=None, analysis=analysis, ) return { "iq_path": str(iq_path), "metadata_path": str(metadata_path), "iq_sha256": document["iq_sha256"], "processing_status": "success", "tx_parameters": tx_parameters, "rx_parameters": metadata["rx_parameters"], "overload": overload, "continuous_rx": capture.diagnostics, "analysis": analysis, } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--run-one-continuity-tone", action="store_true", required=True) arguments = parser.parse_args() if not arguments.run_one_continuity_tone: # pragma: no cover return 2 result = run_one_continuity_tone() print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result["processing_status"] == "success" else 2 if __name__ == "__main__": raise SystemExit(main())