Files
SDR-Rover/experiments/lab043_prbs11_hardware.py
2026-08-19 18:14:13 +03:00

486 lines
19 KiB
Python
Raw Permalink 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.
"""Один разрешённый аппаратный захват 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())