192 lines
6.7 KiB
Python
192 lines
6.7 KiB
Python
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]
|