Lab043: validate pilot-aided short BPSK link
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
import pathlib
|
||||
import struct
|
||||
|
||||
@@ -210,3 +211,158 @@ def test_frame_search_finds_the_start_in_a_shaped_signal() -> None:
|
||||
recovered = found["symbol_samples"][start : start + len(bits)]
|
||||
|
||||
assert radio.parse_radio_frame(radio.bpsk_demodulate(recovered)) == packet
|
||||
|
||||
|
||||
# ------------------------------------------------------ Lab043: общие примитивы
|
||||
|
||||
|
||||
def test_known_tone_peak_search_finds_both_windows() -> None:
|
||||
frequencies = np.linspace(-100_000.0, 100_000.0, 4001)
|
||||
powers = np.ones_like(frequencies)
|
||||
powers[np.argmin(np.abs(frequencies + 51_200.0))] = 100.0
|
||||
powers[np.argmin(np.abs(frequencies - 49_300.0))] = 80.0
|
||||
|
||||
peaks = radio.find_known_tone_peaks(frequencies, powers, 50_000.0, 30_000.0)
|
||||
|
||||
assert math.isclose(peaks["low_frequency_hz"], -51_200.0, abs_tol=1.0)
|
||||
assert math.isclose(peaks["high_frequency_hz"], 49_300.0, abs_tol=1.0)
|
||||
assert peaks["low_power"] == 100.0
|
||||
assert peaks["high_power"] == 80.0
|
||||
|
||||
|
||||
def test_known_tone_peak_search_returns_nan_without_bins() -> None:
|
||||
frequencies = np.linspace(-1_000.0, 1_000.0, 101)
|
||||
powers = np.ones_like(frequencies)
|
||||
peaks = radio.find_known_tone_peaks(frequencies, powers, 50_000.0, 1_000.0)
|
||||
|
||||
assert math.isnan(peaks["low_frequency_hz"])
|
||||
assert math.isnan(peaks["high_frequency_hz"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ppm", [20.0, -20.0, 100.0, -100.0])
|
||||
def test_two_tone_clock_estimate_has_correct_sign_and_magnitude(ppm: float) -> None:
|
||||
scale = 1.0 + ppm * 1e-6
|
||||
carrier_offset_hz = -1_250.0
|
||||
low = carrier_offset_hz - 50_000.0 * scale
|
||||
high = carrier_offset_hz + 50_000.0 * scale
|
||||
|
||||
estimate = radio.estimate_two_tone_offsets(low, high, 50_000.0)
|
||||
|
||||
assert math.isclose(estimate["carrier_offset_hz"], carrier_offset_hz, abs_tol=1e-9)
|
||||
assert math.isclose(estimate["clock_scale"], scale, abs_tol=1e-12)
|
||||
assert math.isclose(estimate["sample_clock_error_ppm"], ppm, abs_tol=1e-6)
|
||||
|
||||
|
||||
def test_two_tone_invalid_estimate_is_nan_not_zero() -> None:
|
||||
estimate = radio.estimate_two_tone_offsets(float("nan"), 50_000.0, 50_000.0)
|
||||
assert all(math.isnan(value) for value in estimate.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carrier_offset_hz", [730.0, -730.0])
|
||||
def test_coarse_frequency_correction_handles_both_signs(carrier_offset_hz: float) -> None:
|
||||
sample_rate_hz = 20_000.0
|
||||
indexes = np.arange(20_000, dtype=np.float64)
|
||||
impaired = np.exp(1j * 2.0 * np.pi * carrier_offset_hz * indexes / sample_rate_hz)
|
||||
|
||||
corrected = radio.apply_coarse_frequency_correction(
|
||||
impaired,
|
||||
carrier_offset_hz,
|
||||
sample_rate_hz,
|
||||
)
|
||||
|
||||
assert np.max(np.abs(corrected - 1.0)) < 1e-9
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"carrier_offset_hz",
|
||||
[-10.0, -5.0, -2.0, -1.2, -1.0, -0.5, 0.5, 1.0, 1.2, 2.0, 5.0, 10.0],
|
||||
)
|
||||
def test_known_pilot_estimator_resolves_sub_hertz_cfo_with_hardware_like_phase_noise(
|
||||
carrier_offset_hz: float,
|
||||
) -> None:
|
||||
symbol_count = 1_280
|
||||
indexes = np.arange(symbol_count, dtype=np.float64)
|
||||
known = np.where((indexes.astype(np.int64) * 73 + 19) % 127 < 64, 1.0, -1.0).astype(
|
||||
np.complex128
|
||||
)
|
||||
estimates: list[float] = []
|
||||
valid_flags: list[bool] = []
|
||||
for repetition in range(96):
|
||||
random_generator = np.random.default_rng(
|
||||
43_000_000
|
||||
+ int(round((carrier_offset_hz + 20.0) * 1_000.0))
|
||||
+ repetition
|
||||
)
|
||||
phase_noise = random_generator.normal(0.0, 0.4691, symbol_count)
|
||||
received = known * np.exp(
|
||||
1j
|
||||
* (
|
||||
2.0 * np.pi * carrier_offset_hz * indexes / radio.SYMBOL_RATE
|
||||
+ phase_noise
|
||||
)
|
||||
)
|
||||
estimate = radio.estimate_known_pilot_carrier(received, known)
|
||||
valid_flags.append(estimate.valid)
|
||||
estimates.append(estimate.frequency_hz)
|
||||
|
||||
errors = np.asarray(estimates) - carrier_offset_hz
|
||||
assert all(valid_flags)
|
||||
assert np.all(np.sign(estimates) == np.sign(carrier_offset_hz))
|
||||
assert abs(float(np.mean(errors))) < 0.08
|
||||
assert float(np.std(errors, ddof=1)) < 0.18
|
||||
assert float(np.percentile(np.abs(errors), 95.0)) < 0.35
|
||||
|
||||
|
||||
def test_known_pilot_estimator_rejects_noise_instead_of_reporting_false_cfo() -> None:
|
||||
random_generator = np.random.default_rng(43_043)
|
||||
known = np.resize(np.asarray([-1.0, 1.0], dtype=np.complex128), 1_280)
|
||||
noise = (
|
||||
random_generator.normal(0.0, 1.0, len(known))
|
||||
+ 1j * random_generator.normal(0.0, 1.0, len(known))
|
||||
)
|
||||
|
||||
estimate = radio.estimate_known_pilot_carrier(noise, known)
|
||||
|
||||
assert not estimate.valid
|
||||
assert estimate.invalid_reason
|
||||
assert math.isnan(estimate.frequency_hz)
|
||||
assert math.isnan(estimate.phase_increment_rad_per_symbol)
|
||||
|
||||
|
||||
def _sample_at_positions(signal: np.ndarray, positions: np.ndarray) -> np.ndarray:
|
||||
indexes = np.arange(len(signal), dtype=np.float64)
|
||||
real = np.interp(positions, indexes, signal.real)
|
||||
imaginary = np.interp(positions, indexes, signal.imag)
|
||||
return real + 1j * imaginary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ppm", [20.0, -20.0, 100.0, -100.0])
|
||||
def test_clock_resampling_direction_reduces_timing_error(ppm: float) -> None:
|
||||
scale = 1.0 + ppm * 1e-6
|
||||
sample_count = 200_000
|
||||
indexes = np.arange(sample_count, dtype=np.float64)
|
||||
reference = np.exp(1j * 2.0 * np.pi * 0.071 * indexes)
|
||||
|
||||
received_length = math.floor(sample_count / scale)
|
||||
received_positions = np.arange(received_length, dtype=np.float64) * scale
|
||||
received = _sample_at_positions(reference, received_positions)
|
||||
|
||||
corrected = radio.resample_for_clock_scale(received, scale)
|
||||
wrong_direction = radio.resample_for_clock_scale(received, 1.0 / scale)
|
||||
|
||||
uncorrected_count = min(len(received), len(reference))
|
||||
corrected_count = min(len(corrected), len(reference)) - 2
|
||||
wrong_count = min(len(wrong_direction), len(reference)) - 2
|
||||
uncorrected_error = float(
|
||||
np.mean(np.abs(received[:uncorrected_count] - reference[:uncorrected_count]) ** 2)
|
||||
)
|
||||
corrected_error = float(
|
||||
np.mean(np.abs(corrected[:corrected_count] - reference[:corrected_count]) ** 2)
|
||||
)
|
||||
wrong_error = float(
|
||||
np.mean(np.abs(wrong_direction[:wrong_count] - reference[:wrong_count]) ** 2)
|
||||
)
|
||||
|
||||
assert len(corrected) == round(len(received) * scale)
|
||||
assert corrected_error < uncorrected_error
|
||||
assert corrected_error < wrong_error
|
||||
|
||||
Reference in New Issue
Block a user