"""Проверки причинности и математики Lab060.""" from __future__ import annotations import numpy as np import pytest from experiments import lab060_decision_directed_carrier_tracking as lab060 def synthetic_symbols(count=4096, symbol_rate=500_000, frequency_hz=4.0, phase_rad=0.3): indexes = np.arange(count) expected = np.resize(lab060.lab057.EXPECTED.real, count).astype(np.float64) values = expected * np.exp( 1j * (phase_rad + 2 * np.pi * frequency_hz * (indexes + 0.5) / symbol_rate) ) return values, expected def test_normalized_detector_does_not_depend_on_amplitude(): angle = 0.27 assert lab060.normalized_phase_detector(np.exp(1j * angle)) == pytest.approx(angle) assert lab060.normalized_phase_detector(50 * np.exp(1j * angle)) == pytest.approx(angle) assert lab060.normalized_phase_detector(-3 * np.exp(1j * angle)) == pytest.approx(angle) def test_detector_sign_and_outlier_limit(): assert lab060.normalized_phase_detector(np.exp(0.2j)) > 0 assert lab060.normalized_phase_detector(np.exp(-0.2j)) < 0 assert lab060.normalized_phase_detector(np.exp(1.5j)) == pytest.approx(lab060.DETECTOR_CLIP_RAD) def test_loop_coefficients_and_units_are_rate_independent(): first = lab060.loop_parameters("T1", 0.001) second = lab060.loop_parameters("T2", 0.001) assert first.phase_coefficient > 0 and first.frequency_coefficient == 0 assert second.phase_coefficient > 0 and second.frequency_coefficient > 0 assert second == lab060.loop_parameters("T2", 500 / 500_000) def test_phase_and_frequency_state_update_matches_equations(): values, _expected = synthetic_symbols(count=lab060.PREAMBLE_SYMBOLS + 2, frequency_hz=0.0, phase_rad=0.2) parameters = lab060.loop_parameters("T2", 0.001) result = lab060.track_symbols(values, 500_000, 0.0, 1 + 0j, "T2", parameters) first = lab060.PREAMBLE_SYMBOLS error = lab060.normalized_phase_detector(result.corrected[first]) expected_next_phase = result.phase_rad[first] + parameters.phase_coefficient * error expected_next_frequency = parameters.frequency_coefficient * error * 500_000 / (2 * np.pi) assert result.phase_rad[first + 1] == pytest.approx(expected_next_phase) assert result.frequency_hz[first + 1] == pytest.approx(expected_next_frequency) def test_t0_reproduces_p3_fixed_compensation(): values, _expected = synthetic_symbols(frequency_hz=3.5, phase_rad=-0.4) estimate = lab060.lab059.estimate_from_burst(values, 500_000, "P3") result = lab060.track_symbols( values, 500_000, estimate.frequency_hz, estimate.gain, "T0", lab060.loop_parameters("T0", 0.0), ) reference = lab060.lab059.compensate(values, 500_000, estimate) assert np.max(abs(result.corrected - reference)) < 1e-12 @pytest.mark.parametrize("initial_error", [-10.0, -2.0, 0.0, 2.0, 10.0]) @pytest.mark.parametrize("tracker", ["T1", "T2"]) def test_decision_directed_tracker_converges_without_payload_truth(initial_error, tracker): values, expected = synthetic_symbols(count=30_000, frequency_hz=2.75, phase_rad=0.4) parameters = lab060.loop_parameters(tracker, 0.001 if tracker == "T1" else 0.0002) result = lab060.track_symbols( values, 500_000, 2.75 + initial_error, np.exp(0.4j), tracker, parameters, ) decisions = result.hard_decisions[lab060.PREAMBLE_SYMBOLS:] truth = np.where(expected[lab060.PREAMBLE_SYMBOLS:] >= 0, 1, -1) assert np.mean(decisions != truth) < 0.01 def test_payload_truth_reference_cannot_change_tracker_trajectory(): values, expected = synthetic_symbols(count=6000, frequency_hz=4.0, phase_rad=0.2) parameters = lab060.loop_parameters("T2", 0.0005) first = lab060.track_symbols(values, 500_000, 1.0, np.exp(0.2j), "T2", parameters) changed_truth = expected.copy() changed_truth[lab060.PREAMBLE_SYMBOLS:] *= -1 first_metrics, _ = lab060.evaluate_tracking(first, expected, 500_000) second = lab060.track_symbols(values, 500_000, 1.0, np.exp(0.2j), "T2", parameters) second_metrics, _ = lab060.evaluate_tracking(second, changed_truth, 500_000) assert np.array_equal(first.hard_decisions, second.hard_decisions) assert np.array_equal(first.phase_rad, second.phase_rad) assert np.array_equal(first.frequency_hz, second.frequency_hz) assert first_metrics["symbol_error_rate"] != second_metrics["symbol_error_rate"] def test_loss_of_lock_and_recovery_criterion_is_crc_independent(): block_ser = np.zeros(16) block_ser[4:6] = 0.5 block_ser[8:10] = 0.05 metrics = lab060.loss_metrics(block_ser, 0.01) assert metrics["loss_of_lock"] assert metrics["self_recovered"] assert metrics["longest_bad_state_seconds"] == pytest.approx(0.02) def test_background_noise_has_no_stable_receiver_lock(): random_generator = np.random.default_rng(6009) count = 20_000 noise = random_generator.normal(size=count) + 1j * random_generator.normal(size=count) result = lab060.track_symbols( noise, 500_000, 0.0, 1 + 0j, "T1", lab060.loop_parameters("T1", 0.0002) ) assert not lab060.receiver_lock_metrics(result.detector_error_rad)["stable_lock"] def test_synthetic_seed_is_deterministic(): first, expected_first = lab060.synthetic_received(20_000, 0.55, 123, 2.75, 0.4) second, expected_second = lab060.synthetic_received(20_000, 0.55, 123, 2.75, 0.4) assert np.array_equal(first, second) assert np.array_equal(expected_first, expected_second) def test_candidate_bandwidths_are_small_fixed_set(): assert lab060.NORMALIZED_BANDWIDTHS == (0.0002, 0.0005, 0.001, 0.002, 0.004) assert len(lab060.NORMALIZED_BANDWIDTHS) == 5