"""Быстрые синтетические проверки программной части 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