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]