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
|
||||
|
||||
891
tests/test_lab043_calibration.py
Normal file
891
tests/test_lab043_calibration.py
Normal 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
|
||||
Reference in New Issue
Block a user