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

View File

@@ -0,0 +1,891 @@
"""Быстрые синтетические проверки программной части Lab043."""
from __future__ import annotations
import hashlib
import json
import math
import numpy as np
import pytest
from scipy.signal import fftconvolve
from experiments import lab043_pluto_to_rtlsdr as lab043
from experiments import lab043_prbs11_hardware as lab043_hardware
from protocol import bpsk_radio as radio
from protocol.image_fragments import split_image_bytes
from protocol.packet import CRCError, MESSAGE_TYPE_TEXT, build_packet, parse_packet
def test_receive_duration_is_derived_from_actual_transmit_length() -> None:
assert lab043.receive_duration_seconds(4_800_000, 2_400_000.0) == 3.0
assert lab043.receive_sample_count(4_800_000, 2_400_000.0, 2_399_900.0) == math.ceil(
3.0 * 2_399_900.0
)
def test_continuous_blocks_are_joined_once_and_trimmed_at_end() -> None:
blocks = (
np.asarray([0, 1, 2], dtype=np.complex64),
np.asarray([3, 4, 5], dtype=np.complex64),
)
combined = lab043.combine_continuous_blocks(blocks, expected_sample_count=5)
assert np.array_equal(combined.real, np.arange(5))
def test_short_continuous_capture_is_rejected() -> None:
with pytest.raises(ValueError, match="короче"):
lab043.combine_continuous_blocks((np.zeros(9),), expected_sample_count=10)
def test_async_collector_preserves_order_trims_target_and_records_boundaries() -> None:
collector = lab043.ContinuousAsyncIqCollector(
expected_sample_count=7,
warmup_callback_count=1,
)
assert not collector.add_callback(0, np.asarray([100, 101], dtype=complex))
assert not collector.add_callback(1, np.asarray([0, 1, 2], dtype=complex))
assert not collector.add_callback(2, np.asarray([3, 4, 5], dtype=complex))
assert collector.add_callback(3, np.asarray([6, 7, 8], dtype=complex))
combined = collector.finalize()
assert np.array_equal(combined.real, np.arange(7))
assert len(combined) == 7
boundaries = collector.callback_boundaries
assert [row.callback_index for row in boundaries] == [0, 1, 2, 3]
assert [row.accepted_start_sample for row in boundaries] == [0, 0, 3, 6]
assert [row.accepted_end_sample for row in boundaries] == [0, 3, 6, 7]
assert boundaries[0].warmup_discarded
assert boundaries[-1].trimmed_at_target
assert boundaries[-1].source_sample_count == 3
assert boundaries[-1].accepted_sample_count == 1
def test_async_collector_rejects_duplicate_callback() -> None:
collector = lab043.ContinuousAsyncIqCollector(4, warmup_callback_count=0)
collector.add_callback(0, np.asarray([0, 1], dtype=complex))
with pytest.raises(ValueError, match="продублирован"):
collector.add_callback(0, np.asarray([2, 3], dtype=complex))
def test_async_collector_rejects_missing_callback() -> None:
collector = lab043.ContinuousAsyncIqCollector(4, warmup_callback_count=0)
collector.add_callback(0, np.asarray([0, 1], dtype=complex))
with pytest.raises(ValueError, match="пропуск"):
collector.add_callback(2, np.asarray([2, 3], dtype=complex))
def test_async_collector_rejects_early_finalize_and_extra_callback() -> None:
collector = lab043.ContinuousAsyncIqCollector(2, warmup_callback_count=0)
collector.add_callback(0, np.asarray([0], dtype=complex))
with pytest.raises(RuntimeError, match="короче"):
collector.finalize()
assert collector.add_callback(1, np.asarray([1], dtype=complex))
with pytest.raises(RuntimeError, match="уже набрал"):
collector.add_callback(2, np.asarray([2], dtype=complex))
def test_frame_concatenation_adds_no_lab043_gap() -> None:
first = lab043.shape_frame_like_lab042(np.ones(16, dtype=complex), samples_per_symbol=8)
second = lab043.shape_frame_like_lab042(-np.ones(12, dtype=complex), samples_per_symbol=8)
continuous = lab043.concatenate_frames_without_new_gaps((first, second))
assert len(continuous) == len(first) + len(second)
assert np.array_equal(continuous[: len(first)], first)
assert np.array_equal(continuous[len(first) :], second)
def test_spectrum_method_finds_two_strong_tones_and_cfo() -> None:
sample_rate_hz = 2_400_000.0
sample_count = 2 * lab043.CALIBRATION_NFFT
indexes = np.arange(sample_count, dtype=np.float64)
carrier_offset_hz = 1_250.0
clock_scale = 1.0 + 100.0e-6
low_hz = carrier_offset_hz - lab043.F_CAL_HZ * clock_scale
high_hz = carrier_offset_hz + lab043.F_CAL_HZ * clock_scale
random_generator = np.random.default_rng(43)
samples = (
np.exp(1j * 2.0 * np.pi * low_hz * indexes / sample_rate_hz)
+ 0.8 * np.exp(1j * 2.0 * np.pi * high_hz * indexes / sample_rate_hz)
+ 0.002
* (
random_generator.standard_normal(sample_count)
+ 1j * random_generator.standard_normal(sample_count)
)
)
estimate = lab043.estimate_calibration(samples, sample_rate_hz)
assert estimate.valid
assert estimate.low_peak_excess_db > 10.0
assert estimate.high_peak_excess_db > 10.0
assert math.isclose(estimate.carrier_offset_hz, carrier_offset_hz, abs_tol=5.0)
assert math.isclose(estimate.sample_clock_error_ppm, 100.0, abs_tol=60.0)
def _synthetic_two_tones(
low_hz: float,
high_hz: float,
seed: int,
) -> tuple[np.ndarray, float]:
sample_rate_hz = 240_000.0
sample_count = 120_000
indexes = np.arange(sample_count, dtype=np.float64)
random_generator = np.random.default_rng(seed)
samples = (
np.exp(1j * 2.0 * np.pi * low_hz * indexes / sample_rate_hz)
+ 0.8 * np.exp(1j * 2.0 * np.pi * high_hz * indexes / sample_rate_hz)
+ 0.003
* (
random_generator.standard_normal(sample_count)
+ 1j * random_generator.standard_normal(sample_count)
)
)
return samples, sample_rate_hz
def _phase_refinement_from_spectrum(
samples: np.ndarray,
sample_rate_hz: float,
) -> tuple[lab043.CalibrationResult, radio.TwoTonePhaseRefinement]:
spectrum_estimate = lab043.estimate_calibration(samples, sample_rate_hz)
assert spectrum_estimate.valid
phase_estimate = radio.refine_two_tone_frequencies_from_phase(
samples,
sample_rate_hz,
spectrum_estimate.f_low_hz,
spectrum_estimate.f_high_hz,
block_samples=240,
hop_samples=120,
)
return spectrum_estimate, phase_estimate
@pytest.mark.parametrize(
"tone_shift_hz",
[-1.0, -0.5, -0.25, -0.1, 0.1, 0.25, 0.5, 1.0],
)
def test_phase_refinement_improves_known_sub_hertz_common_shift(
tone_shift_hz: float,
) -> None:
true_low_hz = -lab043.F_CAL_HZ + tone_shift_hz
true_high_hz = lab043.F_CAL_HZ + tone_shift_hz
samples, sample_rate_hz = _synthetic_two_tones(
true_low_hz,
true_high_hz,
seed=43_000 + int((tone_shift_hz + 2.0) * 100),
)
old, refined = _phase_refinement_from_spectrum(samples, sample_rate_hz)
old_max_error_hz = max(
abs(old.f_low_hz - true_low_hz),
abs(old.f_high_hz - true_high_hz),
)
refined_max_error_hz = max(
abs(refined.low_frequency_hz - true_low_hz),
abs(refined.high_frequency_hz - true_high_hz),
)
assert refined_max_error_hz < 0.001
assert refined_max_error_hz < old_max_error_hz
@pytest.mark.parametrize(
"half_spacing_shift_hz",
[-1.0, -0.5, -0.25, -0.1, 0.1, 0.25, 0.5, 1.0],
)
def test_phase_refinement_improves_known_sub_hertz_spacing_shift(
half_spacing_shift_hz: float,
) -> None:
true_low_hz = -lab043.F_CAL_HZ - half_spacing_shift_hz
true_high_hz = lab043.F_CAL_HZ + half_spacing_shift_hz
true_offsets = radio.estimate_two_tone_offsets(
true_low_hz,
true_high_hz,
lab043.F_CAL_HZ,
)
samples, sample_rate_hz = _synthetic_two_tones(
true_low_hz,
true_high_hz,
seed=44_000 + int((half_spacing_shift_hz + 2.0) * 100),
)
old, refined = _phase_refinement_from_spectrum(samples, sample_rate_hz)
refined_offsets = radio.estimate_two_tone_offsets(
refined.low_frequency_hz,
refined.high_frequency_hz,
lab043.F_CAL_HZ,
)
old_error_ppm = abs(
old.sample_clock_error_ppm - true_offsets["sample_clock_error_ppm"]
)
refined_error_ppm = abs(
refined_offsets["sample_clock_error_ppm"]
- true_offsets["sample_clock_error_ppm"]
)
assert refined_error_ppm < 0.001
assert refined_error_ppm < old_error_ppm
@pytest.mark.parametrize("sample_clock_error_ppm", [100.0, -100.0])
def test_public_refined_calibration_returns_complete_contract(
sample_clock_error_ppm: float,
) -> None:
carrier_offset_hz = 1_234.5
scale = 1.0 + sample_clock_error_ppm * 1e-6
samples, sample_rate_hz = _synthetic_two_tones(
carrier_offset_hz - lab043.F_CAL_HZ * scale,
carrier_offset_hz + lab043.F_CAL_HZ * scale,
seed=45_000 + int(sample_clock_error_ppm),
)
result = lab043.estimate_refined_calibration(samples, sample_rate_hz)
assert isinstance(result, lab043.CalibrationResult)
assert result.valid
assert result.invalid_reason == ""
assert math.isclose(result.carrier_offset_hz, carrier_offset_hz, abs_tol=0.01)
assert math.isclose(
result.sample_clock_error_ppm,
sample_clock_error_ppm,
abs_tol=0.01,
)
assert math.isfinite(result.residual_cfo_hz)
assert math.isfinite(result.phase_fit_rmse_rad)
assert result.peak_margin_low_db >= lab043.MINIMUM_TONE_EXCESS_DB
assert result.peak_margin_high_db >= lab043.MINIMUM_TONE_EXCESS_DB
def test_public_refined_calibration_rejects_weak_or_missing_tone_without_exception() -> None:
sample_rate_hz = 240_000.0
sample_count = 120_000
indexes = np.arange(sample_count, dtype=np.float64)
random_generator = np.random.default_rng(46_000)
noise = 0.003 * (
random_generator.standard_normal(sample_count)
+ 1j * random_generator.standard_normal(sample_count)
)
one_tone = np.exp(1j * 2.0 * np.pi * lab043.F_CAL_HZ * indexes / sample_rate_hz)
weak = lab043.estimate_refined_calibration(noise, sample_rate_hz)
missing = lab043.estimate_refined_calibration(one_tone + noise, sample_rate_hz)
for result in (weak, missing):
assert isinstance(result, lab043.CalibrationResult)
assert not result.valid
assert result.invalid_reason
assert math.isnan(result.f_low_hz)
assert math.isnan(result.f_high_hz)
assert math.isnan(result.carrier_offset_hz)
assert math.isnan(result.sample_clock_error_ppm)
def test_tones_below_ten_decibels_are_rejected_with_nan() -> None:
frequencies = np.linspace(-100_000.0, 100_000.0, 4001)
powers = np.ones_like(frequencies)
powers[np.argmin(np.abs(frequencies + lab043.F_CAL_HZ))] = 9.0
powers[np.argmin(np.abs(frequencies - lab043.F_CAL_HZ))] = 9.0
estimate = lab043.estimate_calibration_from_spectrum(frequencies, powers)
assert not estimate.valid
assert math.isnan(estimate.f_low_hz)
assert math.isnan(estimate.f_high_hz)
assert math.isnan(estimate.carrier_offset_hz)
assert estimate.failure_reason
def _estimate(carrier_offset_hz: float, valid: bool = True) -> lab043.CalibrationResult:
if not valid:
return lab043._invalid_calibration(
"нет тонов",
noise_power=1.0,
)
return lab043.CalibrationResult(
valid=True,
invalid_reason="",
f_low_hz=-50_000.0 + carrier_offset_hz,
f_high_hz=50_000.0 + carrier_offset_hz,
carrier_offset_hz=carrier_offset_hz,
carrier_offset_ppm=carrier_offset_hz / lab043.CARRIER_HZ * 1e6,
clock_scale=1.0,
sample_clock_error_ppm=0.0,
residual_cfo_hz=0.0,
phase_fit_rmse_rad=0.0,
peak_margin_low_db=20.0,
peak_margin_high_db=21.0,
noise_power=1.0,
)
def test_three_calibrations_keep_individual_failures_and_finite_statistics() -> None:
summary = lab043.summarize_calibrations((_estimate(10.0), _estimate(14.0), _estimate(0, False)))
assert summary["capture_count"] == 3
assert summary["failure_count"] == 1
carrier = summary["carrier_offset_hz"]
assert carrier["mean"] == 12.0
assert carrier["minimum"] == 10.0
assert carrier["maximum"] == 14.0
assert carrier["standard_deviation"] == 2.0
def test_all_invalid_calibrations_keep_nan_aggregates() -> None:
summary = lab043.summarize_calibrations((_estimate(0, False),) * 3)
assert summary["failure_count"] == 3
assert math.isnan(summary["carrier_offset_hz"]["mean"])
def test_calibration_summary_requires_exactly_three_captures() -> None:
with pytest.raises(ValueError, match="ровно три"):
lab043.summarize_calibrations((_estimate(10.0), _estimate(12.0)))
def test_prbs11_has_full_2047_bit_period() -> None:
sequence = lab043.prbs11(2 * lab043.PRBS11_LENGTH)
first = sequence[: lab043.PRBS11_LENGTH]
second = sequence[lab043.PRBS11_LENGTH :]
assert np.array_equal(first, second)
assert not np.array_equal(first, np.roll(first, 23))
assert not np.array_equal(first, np.roll(first, 89))
def test_separate_pilot_is_fixed_balanced_and_independent_from_prbs11() -> None:
pilot = lab043.pilot_prbs7()
first_period = pilot[:127]
second_period = pilot[127:254]
assert len(pilot) == 1_280
assert np.array_equal(first_period, second_period)
assert int(np.count_nonzero(first_period)) == 64
assert not np.array_equal(pilot, lab043.prbs11(len(pilot)))
longest_run = max(
len(group)
for group in np.split(pilot, np.flatnonzero(np.diff(pilot)) + 1)
)
assert longest_run <= 7
@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_fixed_pilot_estimator_statistics_match_hardware_s_phase_dispersion(
carrier_offset_hz: float,
) -> None:
known = radio.bpsk_modulate(lab043.pilot_prbs7())
indexes = np.arange(len(known), dtype=np.float64)
estimates: list[float] = []
for repetition in range(96):
random_generator = np.random.default_rng(
43_043_000
+ int(round((carrier_offset_hz + 20.0) * 1_000.0))
+ repetition
)
phase_noise = random_generator.normal(0.0, 0.4691, len(known))
received = known * np.exp(
1j
* (
2.0 * np.pi * carrier_offset_hz * indexes / lab043.SYMBOL_RATE
+ phase_noise
)
)
estimate = radio.estimate_known_pilot_carrier(received, known)
assert estimate.valid
estimates.append(estimate.frequency_hz)
errors = np.asarray(estimates) - carrier_offset_hz
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_fixed_pilot_estimator_rejects_noise_only_input() -> None:
random_generator = np.random.default_rng(43_043)
known = radio.bpsk_modulate(lab043.pilot_prbs7())
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 math.isnan(estimate.frequency_hz)
@pytest.mark.parametrize(
("label", "duration_seconds", "expected_bits"),
[("S", 0.25, 5_000), ("L", 2.0, 40_000)],
)
def test_prbs_transmission_plan_is_fixed_and_has_one_initial_marker(
label: str,
duration_seconds: float,
expected_bits: int,
) -> None:
plan = lab043.build_prbs11_transmission_plan(label, duration_seconds)
assert len(plan.payload_bits) == expected_bits
assert np.array_equal(
plan.payload_bits,
lab043.prbs11(expected_bits, lab043.PRBS11_INITIAL_STATE),
)
assert plan.bpsk_start_sample == (
plan.calibration_sample_count + plan.fixed_guard_sample_count
)
assert plan.calibration_sample_count == 600_000
assert plan.fixed_guard_sample_count == 240_000
assert np.max(np.abs(plan.tx_samples)) <= 1.0
if label == "S":
assert len(plan.pilot_bits) == lab043.PILOT_SYMBOL_COUNT
assert len(plan.pilot_bits) / lab043.SYMBOL_RATE == 0.064
else:
assert len(plan.pilot_bits) == 0
def test_noncyclic_tx_buffer_is_held_for_full_waveform_duration() -> None:
events: list[object] = []
class FakePluto:
def tx_destroy_buffer(self) -> None:
events.append("destroy")
def tx(self, samples: np.ndarray) -> None:
events.append(("tx", samples.copy()))
samples = np.arange(12, dtype=np.float32).astype(np.complex64)
duration = lab043_hardware.transmit_noncyclic_buffer_once(
FakePluto(),
samples,
actual_sample_rate_hz=48.0,
sleep_function=lambda seconds: events.append(("sleep", seconds)),
)
assert duration == 0.25
assert events[0] == "destroy"
assert events[1][0] == "tx"
assert np.array_equal(events[1][1], samples)
assert events[2] == ("sleep", 0.25)
assert events[3] == "destroy"
def _synthetic_prbs_channel(
transmitted: np.ndarray,
sample_rate_hz: float,
carrier_offset_hz: float,
clock_scale: float,
) -> np.ndarray:
received_length = max(1, math.floor(len(transmitted) / clock_scale))
receive_indexes = np.arange(received_length, dtype=np.float64)
source_positions = np.minimum(receive_indexes * clock_scale, len(transmitted) - 1.0)
source_indexes = np.arange(len(transmitted), dtype=np.float64)
received = np.interp(source_positions, source_indexes, transmitted.real) + 1j * np.interp(
source_positions,
source_indexes,
transmitted.imag,
)
return received * np.exp(
1j * 2.0 * np.pi * carrier_offset_hz * receive_indexes / sample_rate_hz
)
def test_prbs_modes_measure_drift_and_resampling_reduces_it() -> None:
samples_per_symbol = 16
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
clock_scale = 1.0 - 100.0e-6
carrier_offset_hz = 730.0
plan = lab043.build_prbs11_transmission_plan(
"S",
0.25,
sample_rate_hz=sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
transmitted_bpsk = plan.tx_samples[plan.bpsk_start_sample :]
received = _synthetic_prbs_channel(
transmitted_bpsk,
sample_rate_hz,
carrier_offset_hz,
clock_scale,
)
modes = lab043.analyze_prbs_modes(
received,
plan.payload_bits,
carrier_offset_hz,
clock_scale,
sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
assert modes["B"].detected
assert modes["C"].detected
assert modes["D"].detected
assert modes["B"].matched_bit_count == len(plan.payload_bits)
assert modes["D"].bit_error_rate == 0.0
assert modes["B"].timing_sro_ppm < 0.0
assert math.isclose(modes["B"].timing_sro_ppm, -100.0, abs_tol=40.0)
assert abs(modes["D"].accumulated_timing_drift_samples) < abs(
modes["B"].accumulated_timing_drift_samples
)
def test_marker_pilot_correction_and_unknown_prbs_follow_one_software_path() -> None:
samples_per_symbol = 16
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
coarse_cfo_hz = 700.0
residual_cfo_hz = 1.2
plan = lab043.build_prbs11_transmission_plan(
"S",
0.25,
sample_rate_hz=sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
transmitted_bpsk = plan.tx_samples[plan.bpsk_start_sample :]
received = _synthetic_prbs_channel(
transmitted_bpsk,
sample_rate_hz,
coarse_cfo_hz + residual_cfo_hz,
1.0,
)
modes = lab043.analyze_prbs_modes(
received,
plan.payload_bits,
coarse_cfo_hz,
1.0,
sample_rate_hz,
samples_per_symbol=samples_per_symbol,
known_pilot_bits=plan.pilot_bits,
)
wrong_reference_modes = lab043.analyze_prbs_modes(
received,
np.zeros_like(plan.payload_bits),
coarse_cfo_hz,
1.0,
sample_rate_hz,
samples_per_symbol=samples_per_symbol,
known_pilot_bits=plan.pilot_bits,
)
assert set(modes) == {"A", "B", "C", "D"}
assert modes["C"].pilot_estimate_valid
assert modes["C"].pilot_cfo_applied
assert math.isclose(
modes["C"].estimated_pilot_cfo_hz,
residual_cfo_hz,
abs_tol=0.25,
)
assert abs(modes["C"].residual_pilot_cfo_hz) < 0.05
assert modes["C"].bit_error_rate < modes["B"].bit_error_rate
assert modes["C"].bit_error_rate == 0.0
assert math.isclose(
wrong_reference_modes["C"].estimated_pilot_cfo_hz,
modes["C"].estimated_pilot_cfo_hz,
abs_tol=1e-12,
)
assert wrong_reference_modes["C"].bit_error_rate > 0.4
def test_calibration_and_bpsk_are_split_from_the_same_capture() -> None:
samples_per_symbol = 16
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
plan = lab043.build_prbs11_transmission_plan(
"S",
0.025,
sample_rate_hz=sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
leading = np.zeros(round(0.5 * sample_rate_hz), dtype=np.complex128)
trailing = np.zeros(round(0.5 * sample_rate_hz), dtype=np.complex128)
capture = np.concatenate((leading, plan.tx_samples, trailing))
calibration, bpsk, sections = lab043.split_calibration_and_bpsk(
capture,
plan,
sample_rate_hz,
sample_rate_hz,
)
assert len(calibration) > 0.15 * sample_rate_hz
assert len(bpsk) > len(plan.tx_samples) - plan.bpsk_start_sample
assert sections["calibration_end_sample"] < sections["bpsk_nominal_start_sample"]
def _continuous_synthetic_capture(
plan: lab043.PrbsTransmissionPlan,
tx_samples: np.ndarray | None = None,
) -> np.ndarray:
leading = np.zeros(
round(lab043.RX_LEADING_MARGIN_SECONDS * lab043.SAMPLE_RATE_HZ),
dtype=np.complex64,
)
trailing = np.zeros(
round(lab043.RX_TRAILING_MARGIN_SECONDS * lab043.SAMPLE_RATE_HZ),
dtype=np.complex64,
)
waveform = plan.tx_samples if tx_samples is None else tx_samples
return np.concatenate((leading, waveform.astype(np.complex64), trailing))
def test_full_short_s_processing_path_returns_calibration_and_modes() -> None:
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
capture = _continuous_synthetic_capture(plan)
result = lab043_hardware.analyze_captured_prbs(
capture,
plan,
lab043.SAMPLE_RATE_HZ,
lab043.SAMPLE_RATE_HZ,
)
assert result.processing_success
assert result.calibration.valid
assert set(result.modes) == {"A", "B", "C", "D"}
assert result.modes["D"].detected
assert result.modes["D"].matched_bit_count == len(plan.payload_bits)
assert result.modes["D"].bit_error_rate == 0.0
assert math.isfinite(result.modes["D"].evm_percent)
def test_full_short_s_missing_prbs_is_not_reported_as_zero_ber() -> None:
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
without_prbs = plan.tx_samples.copy()
without_prbs[plan.bpsk_start_sample :] = 0.0
capture = _continuous_synthetic_capture(plan, without_prbs)
result = lab043_hardware.analyze_captured_prbs(
capture,
plan,
lab043.SAMPLE_RATE_HZ,
lab043.SAMPLE_RATE_HZ,
)
assert result.calibration.valid
assert not result.processing_success
assert not result.modes["D"].detected
assert math.isnan(result.modes["D"].bit_error_rate)
def test_full_short_s_invalid_calibration_makes_all_modes_na() -> None:
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
one_tone_waveform = plan.tx_samples.copy()
indexes = np.arange(plan.calibration_sample_count, dtype=np.float64)
one_tone_waveform[: plan.calibration_sample_count] = 0.35 * np.exp(
1j * 2.0 * np.pi * lab043.F_CAL_HZ * indexes / lab043.SAMPLE_RATE_HZ
)
capture = _continuous_synthetic_capture(plan, one_tone_waveform)
result = lab043_hardware.analyze_captured_prbs(
capture,
plan,
lab043.SAMPLE_RATE_HZ,
lab043.SAMPLE_RATE_HZ,
)
assert not result.calibration.valid
assert result.calibration.invalid_reason
assert not result.processing_success
for mode in result.modes.values():
assert not mode.detected
assert math.isnan(mode.bit_error_rate)
def test_raw_survives_processing_exception_and_json_records_failure(tmp_path) -> None:
samples = np.asarray([0.25 + 0.5j, -0.5 + 0.25j], dtype=np.complex64)
metadata = {
"capture_status": "captured",
"processing_status": "pending",
"processing_error": None,
"capture_started_utc": "2026-08-19T12:00:00Z",
"tx_parameters": {"gain_db": -30.0},
"rx_parameters": {"gain_db": None},
"waveform_sha256": "a" * 64,
"callback_count": 2,
"callback_boundaries": [{"callback_index": 0}, {"callback_index": 1}],
"tx_waveform_duration_seconds": 0.25,
"expected_intervals": {"calibration": [0.0, 0.1]},
}
iq_path, json_path, result, error = lab043.save_then_process_capture(
tmp_path / "failed_after_rx",
samples,
metadata,
lambda _samples: (_ for _ in ()).throw(RuntimeError("synthetic failure")),
)
document = json.loads(json_path.read_text(encoding="utf-8"))
assert iq_path.exists()
assert json_path.exists()
assert result is None
assert error == "RuntimeError: synthetic failure"
assert document["capture_status"] == "captured"
assert document["processing_status"] == "processing_failed"
assert document["processing_error"] == error
assert document["raw_verified"]
assert document["iq_sha256"] == hashlib.sha256(iq_path.read_bytes()).hexdigest()
def test_ber_is_measured_before_crc() -> None:
expected = lab043.prbs11()
received = expected.copy()
received[[0, 100, 1000]] ^= 1
errors, bit_error_rate = lab043.bit_error_rate(expected, received)
assert errors == 3
assert bit_error_rate == 3 / lab043.PRBS11_LENGTH
def test_ber_is_nan_for_truncated_known_sequence() -> None:
expected = lab043.prbs11()
errors, bit_error_rate = lab043.bit_error_rate(expected, expected[:-1])
assert errors == 0
assert math.isnan(bit_error_rate)
@pytest.mark.parametrize("initial_sample_phase", [0, 3, 7, 15])
def test_symbol_sample_phase_is_selected_separately(initial_sample_phase: int) -> None:
samples_per_symbol = 16
_, marker_symbols = radio.build_frame_marker()
taps = radio.root_raised_cosine_taps(0.35, samples_per_symbol, 10)
upsampled = np.zeros(len(marker_symbols) * samples_per_symbol, dtype=complex)
upsampled[::samples_per_symbol] = marker_symbols
transmitted = fftconvolve(upsampled, taps, mode="full")
shifted = np.concatenate((np.zeros(initial_sample_phase), transmitted))
matched = fftconvolve(shifted, taps, mode="full")
found = radio.find_radio_frame(matched, marker_symbols, samples_per_symbol)
assert found["sample_phase"] == initial_sample_phase
assert found["score"] > 0.99
def test_one_control_packet_passes_crc() -> None:
assert lab043.control_packet_crc_roundtrip(b"known payload")
def test_corrupted_control_packet_fails_crc() -> None:
packet = bytearray(build_packet(b"known payload", MESSAGE_TYPE_TEXT, 43))
packet[-1] ^= 1
bits, _, _ = radio.build_radio_frame(bytes(packet))
recovered = radio.parse_radio_frame(bits)
assert recovered is not None
with pytest.raises(CRCError):
parse_packet(recovered)
def test_modes_a_b_c_process_the_same_capture_and_c_recovers_prbs() -> None:
clock_scale = 1.0 + 100.0e-6
capture, expected = lab043.synthesize_known_bpsk_capture(
carrier_offset_hz=730.0,
clock_scale=clock_scale,
samples_per_symbol=16,
)
results = lab043.process_bpsk_modes(
capture,
expected,
coarse_carrier_offset_hz=700.0,
clock_scale=clock_scale,
samples_per_symbol=16,
)
assert set(results) == {"A", "B", "C"}
assert results["C"].bit_error_rate == 0.0
assert results["C"].fine_cfo_applied
assert not math.isfinite(results["A"].bit_error_rate) or (
results["A"].bit_error_rate > results["C"].bit_error_rate
)
assert results["B"].bit_error_rate > results["C"].bit_error_rate
def test_missing_jpeg_fragment_never_produces_an_image() -> None:
fragments = split_image_bytes(bytes(range(250)) * 5, image_id=43, fragment_data_size=128)
assert len(fragments) == 10
assert lab043.try_reassemble_complete_image(fragments[:-1]) is None
def _complete_acceptance() -> lab043.AcceptanceResult:
return lab043.AcceptanceResult(10, 10, 10, 10, True, True, True, True)
@pytest.mark.parametrize(
"result",
[
lab043.AcceptanceResult(9, 10, 10, 10, True, True, True, True),
lab043.AcceptanceResult(10, 10, 9, 10, True, True, True, True),
lab043.AcceptanceResult(10, 10, 10, 9, False, False, False, True),
lab043.AcceptanceResult(10, 10, 10, 10, True, True, True, False),
],
)
def test_exit_zero_requires_every_acceptance_layer(result: lab043.AcceptanceResult) -> None:
assert lab043.exit_code_for_acceptance(result) == 1
def test_exit_zero_is_allowed_for_complete_acceptance() -> None:
result = _complete_acceptance()
assert result.radio_passed
assert result.application_passed
assert lab043.exit_code_for_acceptance(result) == 0
def test_reference_iq_format_contains_reprocessing_metadata(tmp_path) -> None:
metadata = {
"actual_sample_rate_hz": 2_400_000.0,
"center_frequency_hz": 435_000_000.0,
"rx_gain_db": 19.7,
"capture_started_utc": "2026-08-19T12:00:00Z",
"capture_order": 1,
"tx_parameters": {"gain_db": -10.0},
"calibration": {"carrier_offset_hz": 123.0, "clock_scale": 1.00002},
}
samples = np.asarray([1 + 2j, 3 + 4j], dtype=np.complex64)
iq_path, metadata_path = lab043.save_reference_iq_capture(
tmp_path / "reference",
samples,
metadata,
)
restored = np.load(iq_path, allow_pickle=False)
document = json.loads(metadata_path.read_text(encoding="utf-8"))
assert np.array_equal(restored, samples)
assert document["sample_count"] == 2
assert document["dtype"] == "complex64"
assert len(document["iq_sha256"]) == 64
assert document["calibration"]["clock_scale"] == 1.00002
def test_reference_iq_rejects_incomplete_metadata(tmp_path) -> None:
with pytest.raises(ValueError, match="Не хватает метаданных"):
lab043.save_reference_iq_capture(tmp_path / "reference", np.zeros(4), {})
def test_diagnostic_csv_txt_and_png_are_created_from_supplied_results(tmp_path) -> None:
calibrations = (_estimate(10.0), _estimate(12.0), _estimate(14.0))
capture, expected = lab043.synthesize_known_bpsk_capture(30.0, 1.0, 16)
modes = lab043.process_bpsk_modes(capture, expected, 0.0, 1.0, 16)
paths = lab043.save_diagnostic_artifacts(
tmp_path,
calibrations,
modes,
_complete_acceptance(),
)
assert len(paths) == 5
assert all(path.exists() and path.stat().st_size > 0 for path in paths)
summary = (tmp_path / "lab043_summary.csv").read_text(encoding="utf-8")
assert "radio_passed" in summary
assert summary.rstrip().endswith(",0")
assert (tmp_path / "lab043_calibration.png").stat().st_size > 1000
def test_all_embedded_lab043_checks_pass_without_hardware() -> None:
results = lab043.run_functional_tests()
assert len(results) == 10
assert all(result.passed for result in results), results