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