Lab043: add hardware diagnostics and HackRF comparison tools
This commit is contained in:
1034
experiments/lab043_bpsk_asymmetry_diagnostics.py
Normal file
1034
experiments/lab043_bpsk_asymmetry_diagnostics.py
Normal file
File diff suppressed because it is too large
Load Diff
618
experiments/lab043_hackrf_continuity.py
Normal file
618
experiments/lab043_hackrf_continuity.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""Однотонная проверка непрерывности 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())
|
||||
1397
experiments/lab043_hackrf_counter.py
Normal file
1397
experiments/lab043_hackrf_counter.py
Normal file
File diff suppressed because it is too large
Load Diff
402
experiments/lab043_hackrf_gain_ladder.py
Normal file
402
experiments/lab043_hackrf_gain_ladder.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""Безопасный однотоновый поиск рабочего RX-усиления HackRF для Lab043.
|
||||
|
||||
Разрешён один непрерывный циклический тон Pluto+ и заранее фиксированная
|
||||
лестница усилений HackRF. RF amp и antenna bias всегда выключены. Здесь нет
|
||||
BPSK, PRBS, пакетов, счётчика или JPEG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from experiments import lab043_hackrf_continuity as continuity
|
||||
from experiments import lab043_hackrf_hardware as hackrf
|
||||
from experiments import lab043_pluto_to_rtlsdr as lab043
|
||||
from experiments import lab043_prbs11_hardware as pluto_hardware
|
||||
|
||||
|
||||
GAIN_LADDER = ((0, 0), (8, 8), (16, 8), (16, 16), (24, 16), (24, 24))
|
||||
SETTLE_SECONDS = 0.25
|
||||
MEASUREMENT_SECONDS = 0.75
|
||||
FIRST_CAPTURE_SECONDS = SETTLE_SECONDS + MEASUREMENT_SECONDS
|
||||
FFT_SAMPLES = 262_144
|
||||
SEARCH_LOW_HZ = 35_000.0
|
||||
SEARCH_HIGH_HZ = 65_000.0
|
||||
NOISE_EXCLUSION_HZ = 2_000.0
|
||||
DESIRABLE_TONE_OVER_NOISE_DB = 35.0
|
||||
NEAR_LIMIT_COMPONENT = 0.90
|
||||
PREFERRED_MAX_COMPONENT = 0.75
|
||||
CLIPPED_COMPONENT = 127.0 / 128.0
|
||||
NEAR_LIMIT_FRACTION_STOP = 1e-4
|
||||
NONLINEAR_HARMONIC_LIMIT_DBC = -30.0
|
||||
HARMONIC_DETECTION_MARGIN_DB = 10.0
|
||||
HARMONIC_SEARCH_HALF_WIDTH_HZ = 2_000.0
|
||||
HARMONIC_NOISE_OUTER_HALF_WIDTH_HZ = 10_000.0
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab043_hackrf_gain_ladder")
|
||||
|
||||
|
||||
def _db(power: float) -> float:
|
||||
return 10.0 * math.log10(max(float(power), 1e-300))
|
||||
|
||||
|
||||
def alias_frequency_to_nyquist(frequency_hz: float, sample_rate_hz: float) -> float:
|
||||
"""Свернуть частоту в полуоткрытую полосу [-Fs/2, Fs/2)."""
|
||||
|
||||
if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0:
|
||||
raise ValueError("Частота дискретизации должна быть положительной")
|
||||
return float(
|
||||
(float(frequency_hz) + sample_rate_hz / 2.0) % sample_rate_hz
|
||||
- sample_rate_hz / 2.0
|
||||
)
|
||||
|
||||
|
||||
def _cyclic_frequency_distance(
|
||||
frequencies_hz: np.ndarray,
|
||||
target_hz: float,
|
||||
sample_rate_hz: float,
|
||||
) -> np.ndarray:
|
||||
return np.abs(
|
||||
(frequencies_hz - target_hz + sample_rate_hz / 2.0) % sample_rate_hz
|
||||
- sample_rate_hz / 2.0
|
||||
)
|
||||
|
||||
|
||||
def analyze_harmonic(
|
||||
powers: np.ndarray,
|
||||
frequencies: np.ndarray,
|
||||
fundamental_frequency_hz: float,
|
||||
fundamental_power: float,
|
||||
order: int,
|
||||
sample_rate_hz: float,
|
||||
) -> dict:
|
||||
"""Оценить гармонику отдельно относительно тона и локального фона."""
|
||||
|
||||
if order not in (2, 3):
|
||||
raise ValueError("Поддерживаются только вторая и третья гармоники")
|
||||
expected_hz = alias_frequency_to_nyquist(
|
||||
order * fundamental_frequency_hz,
|
||||
sample_rate_hz,
|
||||
)
|
||||
distances = _cyclic_frequency_distance(frequencies, expected_hz, sample_rate_hz)
|
||||
peak_mask = distances <= HARMONIC_SEARCH_HALF_WIDTH_HZ
|
||||
noise_mask = (
|
||||
(distances > HARMONIC_SEARCH_HALF_WIDTH_HZ)
|
||||
& (distances <= HARMONIC_NOISE_OUTER_HALF_WIDTH_HZ)
|
||||
)
|
||||
if not np.any(peak_mask) or not np.any(noise_mask):
|
||||
raise ValueError("Недостаточно спектральных отсчётов около гармоники")
|
||||
local_indexes = np.flatnonzero(peak_mask)
|
||||
peak_index = int(local_indexes[np.argmax(powers[local_indexes])])
|
||||
peak_power = float(powers[peak_index])
|
||||
local_noise_power = float(np.median(powers[noise_mask]))
|
||||
above_local_noise_db = _db(peak_power) - _db(local_noise_power)
|
||||
relative_to_fundamental_db = _db(peak_power) - _db(fundamental_power)
|
||||
credible = bool(above_local_noise_db >= HARMONIC_DETECTION_MARGIN_DB)
|
||||
nonlinear = bool(
|
||||
credible and relative_to_fundamental_db >= NONLINEAR_HARMONIC_LIMIT_DBC
|
||||
)
|
||||
return {
|
||||
"order": order,
|
||||
"expected_frequency_hz": expected_hz,
|
||||
"peak_frequency_hz": float(frequencies[peak_index]),
|
||||
"peak_power_dbfs": _db(peak_power),
|
||||
"local_noise_floor_dbfs_per_fft_bin": _db(local_noise_power),
|
||||
"above_local_noise_db": float(above_local_noise_db),
|
||||
"relative_to_fundamental_db": float(relative_to_fundamental_db),
|
||||
"credible": credible,
|
||||
"nonlinear": nonlinear,
|
||||
}
|
||||
|
||||
|
||||
def measure_tone_segment(
|
||||
samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
fft_samples: int = FFT_SAMPLES,
|
||||
) -> dict:
|
||||
"""Измерить тон и перегрузку по неизменяемой спектральной методике."""
|
||||
|
||||
received = np.asarray(samples, dtype=np.complex64)
|
||||
if received.ndim != 1 or len(received) < fft_samples:
|
||||
raise ValueError("Участок короче одного фиксированного БПФ-окна")
|
||||
if not np.all(np.isfinite(received)):
|
||||
raise ValueError("Участок содержит нечисловые отсчёты")
|
||||
|
||||
segment_count = len(received) // fft_samples
|
||||
window = np.hanning(fft_samples).astype(np.float64)
|
||||
coherent_scale = float(np.sum(window))
|
||||
powers = np.zeros(fft_samples, dtype=np.float64)
|
||||
for index in range(segment_count):
|
||||
segment = received[index * fft_samples : (index + 1) * fft_samples]
|
||||
spectrum = np.fft.fft(segment * window)
|
||||
powers += np.abs(spectrum / coherent_scale) ** 2
|
||||
powers /= segment_count
|
||||
frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate_hz)
|
||||
|
||||
search = (frequencies >= SEARCH_LOW_HZ) & (frequencies <= SEARCH_HIGH_HZ)
|
||||
candidates = np.flatnonzero(search)
|
||||
peak_index = int(candidates[np.argmax(powers[candidates])])
|
||||
peak_bin_hz = float(frequencies[peak_index])
|
||||
resolution_hz = float(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
|
||||
bin_offset = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator
|
||||
tone_frequency_hz = peak_bin_hz + float(np.clip(bin_offset, -0.5, 0.5)) * resolution_hz
|
||||
|
||||
noise_mask = search & (np.abs(frequencies - tone_frequency_hz) >= NOISE_EXCLUSION_HZ)
|
||||
noise_power = float(np.median(powers[noise_mask]))
|
||||
tone_power = float(powers[peak_index])
|
||||
tone_over_noise_db = _db(tone_power) - _db(noise_power)
|
||||
|
||||
second = analyze_harmonic(
|
||||
powers,
|
||||
frequencies,
|
||||
tone_frequency_hz,
|
||||
tone_power,
|
||||
2,
|
||||
sample_rate_hz,
|
||||
)
|
||||
third = analyze_harmonic(
|
||||
powers,
|
||||
frequencies,
|
||||
tone_frequency_hz,
|
||||
tone_power,
|
||||
3,
|
||||
sample_rate_hz,
|
||||
)
|
||||
|
||||
component_peak = np.maximum(np.abs(received.real), np.abs(received.imag))
|
||||
near_fraction = float(np.mean(component_peak >= NEAR_LIMIT_COMPONENT))
|
||||
clipped_fraction = float(np.mean(component_peak >= CLIPPED_COMPONENT))
|
||||
max_component = float(np.max(component_peak))
|
||||
harmonic_nonlinearity = bool(second["nonlinear"] or third["nonlinear"])
|
||||
stop = bool(
|
||||
clipped_fraction > 0.0
|
||||
or near_fraction >= NEAR_LIMIT_FRACTION_STOP
|
||||
or max_component >= NEAR_LIMIT_COMPONENT
|
||||
or harmonic_nonlinearity
|
||||
)
|
||||
return {
|
||||
"sample_count": int(len(received)),
|
||||
"fft_samples": int(fft_samples),
|
||||
"fft_segment_count": int(segment_count),
|
||||
"fft_resolution_hz": resolution_hz,
|
||||
"tone_frequency_hz": float(tone_frequency_hz),
|
||||
"tone_peak_bin_hz": peak_bin_hz,
|
||||
"tone_power_dbfs": _db(tone_power),
|
||||
"noise_floor_dbfs_per_fft_bin": _db(noise_power),
|
||||
"tone_over_noise_db": float(tone_over_noise_db),
|
||||
"mean_amplitude": float(np.mean(np.abs(received))),
|
||||
"rms_amplitude": float(np.sqrt(np.mean(np.abs(received) ** 2))),
|
||||
"maximum_amplitude": float(np.max(np.abs(received))),
|
||||
"maximum_component": max_component,
|
||||
"near_limit_component_fraction": near_fraction,
|
||||
"clipped_component_fraction": clipped_fraction,
|
||||
"dc_mean_i": float(np.mean(received.real)),
|
||||
"dc_mean_q": float(np.mean(received.imag)),
|
||||
"second_harmonic": second,
|
||||
"third_harmonic": third,
|
||||
"second_harmonic_dbc": second["relative_to_fundamental_db"],
|
||||
"third_harmonic_dbc": third["relative_to_fundamental_db"],
|
||||
"harmonic_nonlinearity_detected": harmonic_nonlinearity,
|
||||
"stop_gain_escalation": stop,
|
||||
"stop_reason": (
|
||||
"перегрузка/близость к пределу/гармоническая нелинейность"
|
||||
if stop
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def choose_preferred_gain(rows: list[dict]) -> dict | None:
|
||||
"""Выбрать минимальное безопасное усиление без погони за лишними децибелами."""
|
||||
|
||||
safe = [
|
||||
row
|
||||
for row in rows
|
||||
if not row["metrics"]["stop_gain_escalation"]
|
||||
and row["metrics"]["maximum_component"] <= PREFERRED_MAX_COMPONENT
|
||||
]
|
||||
if not safe:
|
||||
return None
|
||||
desirable = [
|
||||
row
|
||||
for row in safe
|
||||
if row["metrics"]["tone_over_noise_db"] >= DESIRABLE_TONE_OVER_NOISE_DB
|
||||
]
|
||||
if desirable:
|
||||
candidates = desirable
|
||||
else:
|
||||
best_margin = max(row["metrics"]["tone_over_noise_db"] for row in safe)
|
||||
candidates = [
|
||||
row
|
||||
for row in safe
|
||||
if row["metrics"]["tone_over_noise_db"] >= best_margin - 2.0
|
||||
]
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda row: (row["lna_gain_db"] + row["vga_gain_db"], row["step_index"]),
|
||||
)
|
||||
|
||||
|
||||
def _capture(receiver: hackrf.HackrfRxDevice, seconds: float) -> hackrf.HackrfCapture:
|
||||
count = math.ceil(seconds * hackrf.HACKRF_SAMPLE_RATE_HZ)
|
||||
return receiver.capture(count)
|
||||
|
||||
|
||||
def run_gain_ladder(output_directory: Path = OUTPUT_DIRECTORY) -> dict:
|
||||
"""Выполнить один аппаратный сеанс с одним непрерывным тоном."""
|
||||
|
||||
for lna, vga in GAIN_LADDER:
|
||||
hackrf.validate_hackrf_rx_gains(lna, vga)
|
||||
pluto_preflight = pluto_hardware.preflight_pluto()
|
||||
hackrf_preflight = hackrf.preflight_hackrf()
|
||||
pluto, tx_parameters = pluto_hardware.configure_pluto()
|
||||
actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"])
|
||||
waveform, actual_tone_hz = continuity.build_periodic_tone(actual_tx_rate)
|
||||
device_waveform = lab043.to_pluto_tx_samples(waveform)
|
||||
rows: list[dict] = []
|
||||
tx_started = False
|
||||
stop_reason = "лестница завершена"
|
||||
started = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
with hackrf.HackrfRxDevice() as receiver:
|
||||
rx_parameters = receiver.configure_rx()
|
||||
first_holder: list[hackrf.HackrfCapture] = []
|
||||
first_errors: list[str] = []
|
||||
|
||||
def first_receive() -> None:
|
||||
try:
|
||||
first_holder.append(_capture(receiver, FIRST_CAPTURE_SECONDS))
|
||||
except Exception as error: # pragma: no cover - аппаратный путь
|
||||
first_errors.append(f"{type(error).__name__}: {error}")
|
||||
|
||||
first_gain = receiver.set_rx_gains(*GAIN_LADDER[0])
|
||||
rx_thread = threading.Thread(target=first_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 first_errors:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise RuntimeError("HackRF RX не подтвердил поток; TX не включён")
|
||||
time.sleep(SETTLE_SECONDS)
|
||||
pluto.tx_cyclic_buffer = True
|
||||
pluto.tx_destroy_buffer()
|
||||
pluto.tx(device_waveform)
|
||||
tx_started = True
|
||||
rx_thread.join(timeout=hackrf.HACKRF_STREAM_TIMEOUT_SECONDS + 5.0)
|
||||
if rx_thread.is_alive():
|
||||
raise RuntimeError("Первый короткий RX-сеанс не завершился")
|
||||
if first_errors:
|
||||
raise RuntimeError("; ".join(first_errors))
|
||||
if len(first_holder) != 1:
|
||||
raise RuntimeError("Первый RX-сеанс не вернул единственный массив")
|
||||
|
||||
first_capture = first_holder[0]
|
||||
measurement_count = math.floor(MEASUREMENT_SECONDS * hackrf.HACKRF_SAMPLE_RATE_HZ)
|
||||
metrics = measure_tone_segment(first_capture.samples[-measurement_count:], hackrf.HACKRF_SAMPLE_RATE_HZ)
|
||||
rows.append({
|
||||
"step_index": 0,
|
||||
"lna_gain_db": GAIN_LADDER[0][0],
|
||||
"vga_gain_db": GAIN_LADDER[0][1],
|
||||
"gain_configuration": first_gain,
|
||||
"capture_diagnostics": first_capture.diagnostics,
|
||||
"metrics": metrics,
|
||||
})
|
||||
|
||||
if metrics["stop_gain_escalation"]:
|
||||
stop_reason = metrics["stop_reason"]
|
||||
else:
|
||||
for step_index, (lna_gain, vga_gain) in enumerate(GAIN_LADDER[1:], start=1):
|
||||
gain_configuration = receiver.set_rx_gains(lna_gain, vga_gain)
|
||||
time.sleep(SETTLE_SECONDS)
|
||||
capture = _capture(receiver, MEASUREMENT_SECONDS)
|
||||
metrics = measure_tone_segment(capture.samples, hackrf.HACKRF_SAMPLE_RATE_HZ)
|
||||
rows.append({
|
||||
"step_index": step_index,
|
||||
"lna_gain_db": lna_gain,
|
||||
"vga_gain_db": vga_gain,
|
||||
"gain_configuration": gain_configuration,
|
||||
"capture_diagnostics": capture.diagnostics,
|
||||
"metrics": metrics,
|
||||
})
|
||||
if metrics["stop_gain_escalation"]:
|
||||
stop_reason = metrics["stop_reason"]
|
||||
break
|
||||
finally:
|
||||
if tx_started:
|
||||
pluto.tx_destroy_buffer()
|
||||
|
||||
preferred = choose_preferred_gain(rows)
|
||||
preferred_summary = None if preferred is None else {
|
||||
"step_index": preferred["step_index"],
|
||||
"lna_gain_db": preferred["lna_gain_db"],
|
||||
"vga_gain_db": preferred["vga_gain_db"],
|
||||
"tone_over_noise_db": preferred["metrics"]["tone_over_noise_db"],
|
||||
"desirable_margin_reached": bool(
|
||||
preferred["metrics"]["tone_over_noise_db"] >= DESIRABLE_TONE_OVER_NOISE_DB
|
||||
),
|
||||
}
|
||||
result = {
|
||||
"experiment": "Lab043 HackRF RX gain ladder with one continuous tone",
|
||||
"started_utc": started.isoformat().replace("+00:00", "Z"),
|
||||
"physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF RX; antennas removed",
|
||||
"method": "one continuous cyclic Pluto tone; separate short HackRF RX sessions per gain point",
|
||||
"fixed_gain_ladder": [list(point) for point in GAIN_LADDER],
|
||||
"fixed_thresholds": {
|
||||
"desirable_tone_over_noise_db": DESIRABLE_TONE_OVER_NOISE_DB,
|
||||
"near_limit_component": NEAR_LIMIT_COMPONENT,
|
||||
"preferred_max_component": PREFERRED_MAX_COMPONENT,
|
||||
"clipped_component": CLIPPED_COMPONENT,
|
||||
"near_limit_fraction_stop": NEAR_LIMIT_FRACTION_STOP,
|
||||
"nonlinear_harmonic_limit_dbc": NONLINEAR_HARMONIC_LIMIT_DBC,
|
||||
"harmonic_detection_margin_db": HARMONIC_DETECTION_MARGIN_DB,
|
||||
"harmonic_search_half_width_hz": HARMONIC_SEARCH_HALF_WIDTH_HZ,
|
||||
"harmonic_noise_outer_half_width_hz": HARMONIC_NOISE_OUTER_HALF_WIDTH_HZ,
|
||||
},
|
||||
"pluto_preflight": pluto_preflight,
|
||||
"hackrf_preflight": hackrf_preflight,
|
||||
"tx_parameters": tx_parameters,
|
||||
"rx_parameters": rx_parameters,
|
||||
"actual_transmitted_tone_offset_hz": actual_tone_hz,
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"completed_step_count": len(rows),
|
||||
"stop_reason": stop_reason,
|
||||
"rows": rows,
|
||||
"preferred_gain": preferred_summary,
|
||||
"digital_experiment_authorized": False,
|
||||
}
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
stamp = started.strftime("%Y%m%d_%H%M%S")
|
||||
output_path = output_directory / f"lab043_hackrf_gain_ladder_{stamp}.json"
|
||||
output_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
result["result_path"] = str(output_path)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-directory", type=Path, default=OUTPUT_DIRECTORY)
|
||||
arguments = parser.parse_args()
|
||||
result = run_gain_ladder(arguments.output_directory)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1173
experiments/lab043_hackrf_hardware.py
Normal file
1173
experiments/lab043_hackrf_hardware.py
Normal file
File diff suppressed because it is too large
Load Diff
1141
experiments/lab043_hackrf_zero_if_compare.py
Normal file
1141
experiments/lab043_hackrf_zero_if_compare.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -650,6 +650,60 @@ def locate_calibration_tone_start(
|
||||
raise RuntimeError("Начало двухтоновой калибровки не найдено по фиксированному порогу 10 дБ")
|
||||
|
||||
|
||||
def locate_calibration_tone_start_spectral(
|
||||
samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
block_samples: int = CALIBRATION_NFFT,
|
||||
hop_samples: int = CALIBRATION_NFFT // 4,
|
||||
required_consecutive_blocks: int = 3,
|
||||
) -> int:
|
||||
"""Найти начало по двум известным тонам с тем же порогом 10 дБ.
|
||||
|
||||
Резервный метод нужен, когда узкие тоны надёжны спектрально, но не дают
|
||||
десятикратного роста полной широкополосной мощности. Дополнительно
|
||||
применяется существующее ограничение геометрии тонов: ошибка такта не
|
||||
должна превышать 1000 ppm.
|
||||
"""
|
||||
|
||||
received = np.asarray(samples, dtype=np.complex128)
|
||||
if received.ndim != 1 or len(received) < block_samples:
|
||||
raise ValueError("Захват слишком короток для спектрального поиска калибровки")
|
||||
if (
|
||||
sample_rate_hz <= 0.0
|
||||
or block_samples < 1024
|
||||
or hop_samples <= 0
|
||||
or required_consecutive_blocks <= 0
|
||||
):
|
||||
raise ValueError("Параметры спектрального поиска должны быть положительными")
|
||||
window = np.hanning(block_samples)
|
||||
frequencies = np.fft.fftshift(
|
||||
np.fft.fftfreq(block_samples, d=1.0 / sample_rate_hz)
|
||||
)
|
||||
starts = np.arange(
|
||||
0,
|
||||
len(received) - block_samples + 1,
|
||||
hop_samples,
|
||||
dtype=np.int64,
|
||||
)
|
||||
active = np.zeros(len(starts), dtype=bool)
|
||||
for index, start in enumerate(starts):
|
||||
block = received[start : start + block_samples]
|
||||
centered = block - np.mean(block)
|
||||
spectrum = np.fft.fftshift(np.fft.fft(centered * window))
|
||||
estimate = estimate_calibration_from_spectrum(
|
||||
frequencies,
|
||||
np.abs(spectrum) ** 2,
|
||||
)
|
||||
active[index] = bool(estimate.valid)
|
||||
for index in range(len(active) - required_consecutive_blocks + 1):
|
||||
if bool(np.all(active[index : index + required_consecutive_blocks])):
|
||||
return int(starts[index])
|
||||
raise RuntimeError(
|
||||
"Начало двухтоновой калибровки не найдено спектрально: "
|
||||
"два тона не прошли неизменный порог 10 дБ и проверку геометрии"
|
||||
)
|
||||
|
||||
|
||||
def split_calibration_and_bpsk(
|
||||
samples: np.ndarray,
|
||||
plan: PrbsTransmissionPlan,
|
||||
@@ -659,7 +713,15 @@ def split_calibration_and_bpsk(
|
||||
"""Выделить тоны и BPSK по одной временной шкале одного захвата."""
|
||||
|
||||
received = np.asarray(samples, dtype=np.complex128)
|
||||
tone_start = locate_calibration_tone_start(received, actual_rx_sample_rate_hz)
|
||||
try:
|
||||
tone_start = locate_calibration_tone_start(received, actual_rx_sample_rate_hz)
|
||||
tone_start_locator = "broadband_power_10db"
|
||||
except RuntimeError:
|
||||
tone_start = locate_calibration_tone_start_spectral(
|
||||
received,
|
||||
actual_rx_sample_rate_hz,
|
||||
)
|
||||
tone_start_locator = "two_tone_spectral_10db"
|
||||
tone_duration_rx = round(
|
||||
plan.calibration_sample_count / actual_tx_sample_rate_hz * actual_rx_sample_rate_hz
|
||||
)
|
||||
@@ -684,6 +746,7 @@ def split_calibration_and_bpsk(
|
||||
received[bpsk_start:bpsk_end],
|
||||
{
|
||||
"tone_start_sample": int(tone_start),
|
||||
"tone_start_locator": tone_start_locator,
|
||||
"calibration_start_sample": int(calibration_start),
|
||||
"calibration_end_sample": int(calibration_end),
|
||||
"bpsk_nominal_start_sample": int(bpsk_nominal_start),
|
||||
|
||||
@@ -41,12 +41,31 @@ class CapturedPrbsAnalysis:
|
||||
modes: dict[str, lab043.PrbsModeMetrics]
|
||||
|
||||
|
||||
def preflight_pluto() -> dict:
|
||||
def discover_pluto_usb_uri() -> str:
|
||||
"""Найти единственный Pluto+ через прямой USB-IIO без изменения сети."""
|
||||
|
||||
import iio
|
||||
|
||||
contexts = iio.scan_contexts()
|
||||
candidates = sorted(
|
||||
uri
|
||||
for uri, description in contexts.items()
|
||||
if uri.startswith("usb:") and "PlutoSDR" in description
|
||||
)
|
||||
if len(candidates) != 1:
|
||||
raise RuntimeError(
|
||||
"Ожидался ровно один USB-IIO контекст PlutoSDR, "
|
||||
f"получено {len(candidates)}: {candidates}"
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def preflight_pluto(uri: str = PLUTO_URI) -> dict:
|
||||
"""Подтвердить IIO-контекст, PHY и первый TX-канал без передачи."""
|
||||
|
||||
import iio
|
||||
|
||||
context = iio.Context(PLUTO_URI)
|
||||
context = iio.Context(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
|
||||
@@ -55,7 +74,7 @@ def preflight_pluto() -> dict:
|
||||
if tx_device is None or tx_channel is None:
|
||||
raise RuntimeError("IIO-контекст открыт, но первый TX-канал недоступен")
|
||||
return {
|
||||
"uri": PLUTO_URI,
|
||||
"uri": uri,
|
||||
"context_opened": True,
|
||||
"phy_name": str(phy.name),
|
||||
"tx_device_name": str(tx_device.name),
|
||||
@@ -100,16 +119,17 @@ def preflight_rtlsdr() -> dict:
|
||||
sdr.close()
|
||||
|
||||
|
||||
def configure_pluto() -> tuple[object, dict]:
|
||||
def configure_pluto(uri: str = PLUTO_URI) -> tuple[object, dict]:
|
||||
import adi
|
||||
|
||||
device = adi.Pluto(uri=PLUTO_URI)
|
||||
device = adi.Pluto(uri=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, {
|
||||
"uri": uri,
|
||||
"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),
|
||||
|
||||
Reference in New Issue
Block a user