Files
SDR-Rover/experiments/lab043_hackrf_continuity.py

619 lines
24 KiB
Python
Raw 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.
"""Однотонная проверка непрерывности 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())