Lab043: add hardware diagnostics and HackRF comparison tools

This commit is contained in:
LittleSam129
2026-08-21 17:33:57 +03:00
parent f0fa7e8a46
commit 42b0120eea
42 changed files with 8763 additions and 6 deletions

File diff suppressed because it is too large Load Diff

View 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())

File diff suppressed because it is too large Load Diff

View 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())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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),

View File

@@ -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),