Files
SDR-Rover/tests/test_lab043_calibration.py
2026-08-19 18:14:13 +03:00

892 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Быстрые синтетические проверки программной части 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