Lab043: validate pilot-aided short BPSK link

This commit is contained in:
LittleSam129
2026-08-19 18:14:13 +03:00
parent fa2e473c6d
commit f0fa7e8a46
6 changed files with 4416 additions and 0 deletions

View File

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