"""Один разрешённый аппаратный захват PRBS11 для Lab043. Скрипт выполняет ровно один из заранее определённых опытов S или L. Он не подбирает параметры, не повторяет передачу при ошибке и не переходит к пакетам, CRC либо JPEG. """ 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_pluto_to_rtlsdr as lab043 PLUTO_URI = "ip:192.168.2.1" TX_GAIN_DB = -30.0 RTL_GAIN_DB = 19.7 RF_BANDWIDTH_HZ = 200_000 CONTINUITY_MAX_PHASE_JUMP_RAD = 0.25 @dataclass(frozen=True) class CapturedPrbsAnalysis: """Полный результат обработки одного уже принятого S/L.""" processing_success: bool invalid_reason: str sections: dict calibration: lab043.CalibrationResult continuity: dict modes: dict[str, lab043.PrbsModeMetrics] def preflight_pluto() -> dict: """Подтвердить IIO-контекст, PHY и первый TX-канал без передачи.""" import iio context = iio.Context(PLUTO_URI) phy = context.find_device("ad9361-phy") tx_device = context.find_device("cf-ad9361-dds-core-lpc") tx_channel = tx_device.find_channel("voltage0", True) if tx_device is not None else None if phy is None: raise RuntimeError("IIO-контекст открыт, но ad9361-phy не найден") if tx_device is None or tx_channel is None: raise RuntimeError("IIO-контекст открыт, но первый TX-канал недоступен") return { "uri": PLUTO_URI, "context_opened": True, "phy_name": str(phy.name), "tx_device_name": str(tx_device.name), "first_tx_channel": str(tx_channel.id), } def _configure_rtlsdr(sdr) -> dict: sdr.sample_rate = float(lab043.SAMPLE_RATE_HZ) sdr.center_freq = float(lab043.CARRIER_HZ) sdr.gain = float(RTL_GAIN_DB) actual_sample_rate = float(sdr.sample_rate) actual_center = float(sdr.center_freq) raw_gain = float(sdr.gain) actual_gain = raw_gain if math.isclose(raw_gain, RTL_GAIN_DB, abs_tol=0.2) else None return { "requested_center_frequency_hz": float(lab043.CARRIER_HZ), "actual_center_frequency_hz": actual_center, "requested_sample_rate_hz": float(lab043.SAMPLE_RATE_HZ), "actual_sample_rate_hz": actual_sample_rate, "requested_gain_db": RTL_GAIN_DB, "raw_gain_readback_db": raw_gain, "actual_gain_db": actual_gain, } def preflight_rtlsdr() -> dict: """Открыть RTL-SDR, прочитать короткий блок и закрыть до TX.""" from rtlsdr import RtlSdr sdr = RtlSdr(device_index=0) try: parameters = _configure_rtlsdr(sdr) probe = np.asarray(sdr.read_samples(16_384), dtype=np.complex64) if len(probe) != 16_384 or not np.all(np.isfinite(probe)): raise RuntimeError("RTL-SDR не вернул полный конечный пробный блок") parameters["probe_sample_count"] = int(len(probe)) parameters["probe_rms"] = float(np.sqrt(np.mean(np.abs(probe) ** 2))) return parameters finally: sdr.close() def configure_pluto() -> tuple[object, dict]: import adi device = adi.Pluto(uri=PLUTO_URI) device.sample_rate = int(lab043.SAMPLE_RATE_HZ) device.tx_lo = int(lab043.CARRIER_HZ) device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ) device.tx_hardwaregain_chan0 = float(TX_GAIN_DB) device.tx_cyclic_buffer = False return device, { "requested_center_frequency_hz": int(lab043.CARRIER_HZ), "actual_center_frequency_hz": int(device.tx_lo), "requested_sample_rate_hz": int(lab043.SAMPLE_RATE_HZ), "actual_sample_rate_hz": int(device.sample_rate), "requested_gain_db": TX_GAIN_DB, "actual_gain_db": float(device.tx_hardwaregain_chan0), "requested_rf_bandwidth_hz": RF_BANDWIDTH_HZ, "actual_rf_bandwidth_hz": int(device.tx_rf_bandwidth), } def transmit_noncyclic_buffer_once( device, device_samples: np.ndarray, actual_sample_rate_hz: float, sleep_function=time.sleep, ) -> float: """Передать один нециклический буфер и не уничтожать его раньше времени. В libiio v1 постановка блока в поток может завершиться до того, как DMA физически выведет все отсчёты. Поэтому буфер остаётся жив не меньше его расчётной длительности. Возвращаемое значение сохраняется в диагностике. """ samples = np.asarray(device_samples, dtype=np.complex64) if samples.ndim != 1 or samples.size == 0: raise ValueError("TX-буфер должен быть непустым и одномерным") if not np.isfinite(actual_sample_rate_hz) or actual_sample_rate_hz <= 0.0: raise ValueError("Фактическая частота TX должна быть положительной") hold_seconds = len(samples) / actual_sample_rate_hz device.tx_destroy_buffer() device.tx(samples) sleep_function(hold_seconds) device.tx_destroy_buffer() return float(hold_seconds) def calibration_boundary_continuity( samples: np.ndarray, callback_boundaries, sections: dict, calibration: lab043.CalibrationResult, sample_rate_hz: float, ) -> dict: """Проверить фазу обоих тонов на callback-границах внутри калибровки.""" relevant = [ int(row.accepted_end_sample) for row in callback_boundaries if sections["calibration_start_sample"] + 4_096 <= row.accepted_end_sample <= sections["calibration_end_sample"] - 4_096 ] jumps: list[dict] = [] for boundary in relevant: row = {"boundary_sample": boundary} for name, frequency_hz in ( ("low", calibration.f_low_hz), ("high", calibration.f_high_hz), ): before_indexes = np.arange(boundary - 4_096, boundary, dtype=np.float64) after_indexes = np.arange(boundary, boundary + 4_096, dtype=np.float64) before = np.mean( samples[boundary - 4_096 : boundary] * np.exp(-1j * 2.0 * np.pi * frequency_hz * before_indexes / sample_rate_hz) ) after = np.mean( samples[boundary : boundary + 4_096] * np.exp(-1j * 2.0 * np.pi * frequency_hz * after_indexes / sample_rate_hz) ) row[f"{name}_phase_jump_rad"] = float(np.angle(after * np.conj(before))) jumps.append(row) maximum = max( ( abs(value) for row in jumps for key, value in row.items() if key.endswith("phase_jump_rad") ), default=0.0, ) return { "checked_boundary_count": len(relevant), "maximum_absolute_phase_jump_rad": float(maximum), "fixed_limit_rad": CONTINUITY_MAX_PHASE_JUMP_RAD, "confirmed_discontinuity_count": int( sum( max(abs(row["low_phase_jump_rad"]), abs(row["high_phase_jump_rad"])) > CONTINUITY_MAX_PHASE_JUMP_RAD for row in jumps ) ), "boundaries": jumps, } def analyze_captured_prbs( samples: np.ndarray, plan: lab043.PrbsTransmissionPlan, actual_rx_rate: float, actual_tx_rate: float, callback_boundaries=(), ) -> CapturedPrbsAnalysis: """Пройти тем же полным путём, что аппаратный S, но без обращения к SDR.""" calibration_samples, bpsk_samples, sections = lab043.split_calibration_and_bpsk( samples, plan, actual_rx_rate, actual_tx_rate, ) calibration = lab043.estimate_refined_calibration(calibration_samples, actual_rx_rate) if not calibration.valid: reason = f"калибровка недостоверна: {calibration.invalid_reason}" modes = { mode: lab043._failed_prbs_metrics(mode, len(plan.payload_bits), reason) for mode in ("A", "B", "C", "D") } return CapturedPrbsAnalysis( processing_success=False, invalid_reason=reason, sections=sections, calibration=calibration, continuity={ "checked_boundary_count": 0, "confirmed_discontinuity_count": 0, "reason": "тоны недостоверны", }, modes=modes, ) continuity = calibration_boundary_continuity( np.asarray(samples, dtype=np.complex64), callback_boundaries, sections, calibration, actual_rx_rate, ) if continuity["confirmed_discontinuity_count"]: reason = "в той же записи подтверждён фазовый разрыв" modes = { mode: lab043._failed_prbs_metrics(mode, len(plan.payload_bits), reason) for mode in ("A", "B", "C", "D") } return CapturedPrbsAnalysis( processing_success=False, invalid_reason=reason, sections=sections, calibration=calibration, continuity=continuity, modes=modes, ) modes = lab043.analyze_prbs_modes( bpsk_samples, plan.payload_bits, calibration.carrier_offset_hz, calibration.clock_scale, actual_rx_rate, known_pilot_bits=plan.pilot_bits, ) mode_d = modes["D"] success = bool(mode_d.detected and mode_d.matched_bit_count == len(plan.payload_bits)) reason = "" if success else (mode_d.failure_reason or "режим D не восстановил полную PRBS11") return CapturedPrbsAnalysis( processing_success=success, invalid_reason=reason, sections=sections, calibration=calibration, continuity=continuity, modes=modes, ) def run_one_capture(label: str, output_directory: Path) -> dict: if label != "S": raise RuntimeError("На текущем этапе разрешён только один короткий опыт S") duration = ( lab043.PRBS_SHORT_DURATION_SECONDS if label == "S" else lab043.PRBS_LONG_DURATION_SECONDS ) plan = lab043.build_prbs11_transmission_plan(label, duration) capture_started = datetime.now(timezone.utc) pluto_preflight = preflight_pluto() rtl_preflight = preflight_rtlsdr() time.sleep(0.25) pluto, tx_parameters = configure_pluto() from rtlsdr import RtlSdr sdr = RtlSdr(device_index=0) rx_parameters = _configure_rtlsdr(sdr) actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"]) actual_rx_rate = float(rx_parameters["actual_sample_rate_hz"]) expected_rx_samples = lab043.receive_sample_count( len(plan.tx_samples), actual_tx_rate, actual_rx_rate, ) ready = threading.Event() tx_errors: list[str] = [] tx_buffer_hold_seconds: list[float] = [] def transmit_once() -> None: if not ready.wait(timeout=10.0): tx_errors.append("RTL-SDR async-приём не подтвердил готовность") return time.sleep(lab043.RX_LEADING_MARGIN_SECONDS) try: hold_seconds = transmit_noncyclic_buffer_once( pluto, lab043.to_pluto_tx_samples(plan.tx_samples), actual_tx_rate, ) tx_buffer_hold_seconds.append(hold_seconds) except Exception as error: # pragma: no cover - аппаратный путь tx_errors.append(f"{type(error).__name__}: {error}") tx_thread = threading.Thread(target=transmit_once, daemon=True) tx_thread.start() samples, boundaries, async_diagnostics = lab043.capture_continuous_rtlsdr_async( sdr, expected_rx_samples, capture_ready_event=ready, buffer_bytes=lab043.RTL_ASYNC_BUFFER_BYTES, buffer_count=lab043.RTL_ASYNC_BUFFER_COUNT, warmup_callback_count=lab043.RTL_ASYNC_WARMUP_CALLBACK_COUNT, ) tx_thread.join(timeout=15.0) if tx_thread.is_alive(): raise RuntimeError("Поток единственной передачи не завершился") if tx_errors: raise RuntimeError("; ".join(tx_errors)) received = np.asarray(samples, dtype=np.complex64) if received.ndim != 1 or len(received) != expected_rx_samples: raise RuntimeError("RX завершён, но размер массива не совпал с ожидаемым") if not np.all(np.isfinite(received)): raise RuntimeError("RX завершён, но массив содержит нечисловые значения") boundary_rows = [asdict(row) for row in boundaries] waveform_sha256 = hashlib.sha256( np.asarray(plan.tx_samples, dtype=np.complex64).tobytes() ).hexdigest() tx_waveform_duration_seconds = len(plan.tx_samples) / actual_tx_rate calibration_end_seconds = plan.calibration_sample_count / actual_tx_rate guard_end_seconds = plan.bpsk_start_sample / actual_tx_rate prbs_end_seconds = len(plan.tx_samples) / actual_tx_rate overload = { "peak_magnitude": float(np.max(np.abs(received))), "clipped_component_fraction": float( np.mean((np.abs(received.real) >= 0.999) | (np.abs(received.imag) >= 0.999)) ), } metadata = { "experiment": "Lab043 PRBS11 continuous async", "capture_label": label, "capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"), "capture_order": 1 if label == "S" else 2, "capture_status": "captured", "processing_status": "pending", "processing_error": None, "actual_sample_rate_hz": actual_rx_rate, "center_frequency_hz": rx_parameters["actual_center_frequency_hz"], "rx_gain_db": rx_parameters["actual_gain_db"], "pluto_preflight": pluto_preflight, "rtl_preflight": rtl_preflight, "tx_parameters": tx_parameters, "rx_parameters": rx_parameters, "physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> RTL-SDR RX; antennas removed", "waveform_sha256": waveform_sha256, "tx_waveform_duration_seconds": tx_waveform_duration_seconds, "callback_count": int(async_diagnostics["callback_count"]), "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, prbs_end_seconds], }, "rx_leading_margin_seconds": lab043.RX_LEADING_MARGIN_SECONDS, "rx_trailing_margin_seconds": lab043.RX_TRAILING_MARGIN_SECONDS, }, "waveform": { "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, }, "async_capture": async_diagnostics | { "expected_sample_count": expected_rx_samples, "callback_boundaries": boundary_rows, }, "tx_buffer_hold_seconds": ( tx_buffer_hold_seconds[0] if tx_buffer_hold_seconds else None ), "overload": overload, } stamp = capture_started.strftime("%Y%m%d_%H%M%S") base = output_directory / f"lab043_prbs11_{label.lower()}_{stamp}" iq_path, json_path, analysis, processing_error = lab043.save_then_process_capture( base, received, metadata, lambda stored: analyze_captured_prbs( stored, plan, actual_rx_rate, actual_tx_rate, boundaries, ), ) if analysis is None: return { "iq_path": str(iq_path), "metadata_path": str(json_path), "capture_status": "captured", "processing_status": "processing_failed", "processing_error": processing_error, "tx_parameters": tx_parameters, "rx_parameters": rx_parameters, "async_capture": async_diagnostics, "overload": overload, } result = { "iq_path": str(iq_path), "metadata_path": str(json_path), "capture_status": "captured", "processing_status": "success", "processing_error": None, "processing_success": analysis.processing_success, "invalid_reason": analysis.invalid_reason, "calibration": asdict(analysis.calibration), "continuity": analysis.continuity, "modes": {mode: asdict(value) for mode, value in analysis.modes.items()}, "tx_parameters": tx_parameters, "rx_parameters": rx_parameters, "async_capture": async_diagnostics, "overload": overload, } return result def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--label", choices=("S",), required=True) parser.add_argument("--output-directory", type=Path, default=Path("data/raw/lab043")) arguments = parser.parse_args() result = run_one_capture(arguments.label, arguments.output_directory) print(json.dumps(result, ensure_ascii=False, indent=2)) if result["processing_status"] != "success": return 2 mode_d = result["modes"]["D"] return 0 if result["calibration"]["valid"] and mode_d["detected"] else 2 if __name__ == "__main__": raise SystemExit(main())