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

View File

@@ -0,0 +1,191 @@
import numpy as np
import pytest
from experiments import lab043_hackrf_gain_ladder as ladder
def _tone(
sample_rate: float,
seconds: float,
amplitude: float,
noise: float = 0.0,
second: float = 0.0,
third: float = 0.0,
frequency: float = 50_123.0,
) -> np.ndarray:
rng = np.random.default_rng(43043)
count = round(sample_rate * seconds)
indexes = np.arange(count, dtype=np.float64)
result = amplitude * np.exp(1j * 2.0 * np.pi * frequency * indexes / sample_rate)
result += second * np.exp(1j * 2.0 * np.pi * 2.0 * frequency * indexes / sample_rate)
result += third * np.exp(1j * 2.0 * np.pi * 3.0 * frequency * indexes / sample_rate)
if noise:
result += noise * (rng.normal(size=count) + 1j * rng.normal(size=count))
return result.astype(np.complex64)
def test_fixed_gain_ladder_is_valid_and_monotonic() -> None:
assert ladder.GAIN_LADDER == (
(0, 0), (8, 8), (16, 8), (16, 16), (24, 16), (24, 24)
)
assert [sum(point) for point in ladder.GAIN_LADDER] == sorted(
sum(point) for point in ladder.GAIN_LADDER
)
def test_measure_tone_finds_frequency_margin_and_no_overload() -> None:
sample_rate = 240_000.0
samples = _tone(sample_rate, 3.0, amplitude=0.20, noise=0.002)
metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536)
assert metrics["tone_frequency_hz"] == pytest.approx(50_123.0, abs=2.0)
assert metrics["tone_over_noise_db"] > 35.0
assert metrics["rms_amplitude"] == pytest.approx(0.20, abs=0.01)
assert metrics["clipped_component_fraction"] == 0.0
assert not metrics["second_harmonic"]["credible"]
assert not metrics["third_harmonic"]["credible"]
assert not metrics["stop_gain_escalation"]
def test_strong_tone_without_harmonics_is_not_nonlinear() -> None:
sample_rate = 240_000.0
samples = _tone(sample_rate, 3.0, amplitude=0.55, noise=0.002)
metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536)
assert not metrics["second_harmonic"]["credible"]
assert not metrics["third_harmonic"]["credible"]
assert not metrics["harmonic_nonlinearity_detected"]
def test_credible_second_harmonic_is_detected() -> None:
sample_rate = 240_000.0
samples = _tone(sample_rate, 3.0, amplitude=0.30, noise=0.001, second=0.03)
metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536)
assert metrics["second_harmonic"]["credible"]
assert metrics["second_harmonic"]["nonlinear"]
assert metrics["second_harmonic"]["relative_to_fundamental_db"] == pytest.approx(-20.0, abs=1.0)
assert metrics["stop_gain_escalation"]
def test_credible_third_harmonic_is_detected() -> None:
sample_rate = 240_000.0
samples = _tone(sample_rate, 3.0, amplitude=0.30, noise=0.001, third=0.015)
metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536)
assert metrics["third_harmonic"]["credible"]
assert metrics["third_harmonic"]["nonlinear"]
assert metrics["third_harmonic"]["relative_to_fundamental_db"] == pytest.approx(-26.0, abs=1.0)
assert metrics["stop_gain_escalation"]
def test_peak_high_relative_to_weak_tone_but_below_local_margin_is_not_credible() -> None:
sample_rate = 1_000_000.0
fft_samples = 10_000
frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate)
powers = np.full(fft_samples, 1e-5, dtype=np.float64)
expected = 100_000.0
peak_index = int(np.argmin(np.abs(frequencies - expected)))
powers[peak_index] = 3e-5
result = ladder.analyze_harmonic(
powers,
frequencies,
fundamental_frequency_hz=50_000.0,
fundamental_power=1e-6,
order=2,
sample_rate_hz=sample_rate,
)
assert result["relative_to_fundamental_db"] > 10.0
assert result["above_local_noise_db"] < ladder.HARMONIC_DETECTION_MARGIN_DB
assert not result["credible"]
assert not result["nonlinear"]
def test_measure_tone_stops_on_near_limit_samples() -> None:
sample_rate = 240_000.0
samples = _tone(sample_rate, 2.0, amplitude=0.95)
metrics = ladder.measure_tone_segment(samples, sample_rate, fft_samples=65_536)
assert metrics["near_limit_component_fraction"] > 0.0
assert metrics["stop_gain_escalation"]
def test_harmonic_frequency_aliases_into_nyquist_band() -> None:
assert ladder.alias_frequency_to_nyquist(900_000.0, 1_000_000.0) == pytest.approx(-100_000.0)
assert ladder.alias_frequency_to_nyquist(1_350_000.0, 1_000_000.0) == pytest.approx(350_000.0)
sample_rate = 1_000_000.0
fft_samples = 100_000
frequencies = np.fft.fftfreq(fft_samples, d=1.0 / sample_rate)
powers = np.full(fft_samples, 1e-10, dtype=np.float64)
powers[int(np.argmin(np.abs(frequencies + 100_000.0)))] = 1e-3
powers[int(np.argmin(np.abs(frequencies - 350_000.0)))] = 2.5e-4
second = ladder.analyze_harmonic(
powers, frequencies, 450_000.0, 0.1, 2, sample_rate
)
third = ladder.analyze_harmonic(
powers, frequencies, 450_000.0, 0.1, 3, sample_rate
)
assert second["expected_frequency_hz"] == pytest.approx(-100_000.0)
assert third["expected_frequency_hz"] == pytest.approx(350_000.0)
assert second["credible"]
assert third["credible"]
def test_choose_preferred_gain_uses_lowest_gain_reaching_desirable_margin() -> None:
def row(index: int, lna: int, vga: int, margin: float) -> dict:
return {
"step_index": index,
"lna_gain_db": lna,
"vga_gain_db": vga,
"metrics": {
"tone_over_noise_db": margin,
"maximum_component": 0.4,
"stop_gain_escalation": False,
},
}
rows = [row(0, 0, 0, 20.0), row(1, 8, 8, 38.5), row(2, 16, 8, 40.0)]
selected = ladder.choose_preferred_gain(rows)
assert selected is rows[1]
def test_choose_preferred_gain_rejects_unsafe_rows() -> None:
row = {
"step_index": 0,
"lna_gain_db": 0,
"vga_gain_db": 0,
"metrics": {
"tone_over_noise_db": 60.0,
"maximum_component": 0.95,
"stop_gain_escalation": True,
},
}
assert ladder.choose_preferred_gain([row]) is None
def test_choose_preferred_gain_uses_near_best_when_target_not_reached() -> None:
def row(index: int, lna: int, vga: int, margin: float) -> dict:
return {
"step_index": index,
"lna_gain_db": lna,
"vga_gain_db": vga,
"metrics": {
"tone_over_noise_db": margin,
"maximum_component": 0.4,
"stop_gain_escalation": False,
},
}
rows = [row(0, 0, 0, 20.0), row(1, 8, 8, 31.5), row(2, 16, 8, 33.0)]
assert ladder.choose_preferred_gain(rows) is rows[1]