Lab043: validate pilot-aided short BPSK link
This commit is contained in:
485
experiments/lab043_prbs11_hardware.py
Normal file
485
experiments/lab043_prbs11_hardware.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""Один разрешённый аппаратный захват 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())
|
||||
Reference in New Issue
Block a user