Files
SDR-Rover/tests/test_lab046_bpsk_object_transport.py

468 lines
18 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.
"""Быстрые проверки Lab046: фрагментация большого объекта и fail-closed сборка.
Тесты не трогают оборудование, не создают файлов и не запускают RF. Полный
тракт PHY (кадр Lab045 -> канал -> приём -> сборка) проверяется на маленьких
объектах, чтобы оставаться быстрым.
"""
from __future__ import annotations
import inspect
import struct
import numpy as np
from experiments import lab046_bpsk_object_transport as lab046
from protocol import image_fragments as fragments
# ------------------------------------------------------------ Детерминированный объект
def test_deterministic_object_is_reproducible_and_hashable() -> None:
first = lab046.make_deterministic_object(4096, seed=46)
second = lab046.make_deterministic_object(4096, seed=46)
assert first == second
assert len(first) == 4096
other = lab046.make_deterministic_object(4096, seed=47)
assert other != first
assert len(lab046.object_sha256(first)) == 64
# ------------------------------------------------------------ Фрагмент туда-обратно
def test_fragment_round_trip_exact_without_channel() -> None:
obj = lab046.make_deterministic_object(3000, seed=1)
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
assert fragments.reassemble_image(pieces) == obj
def test_last_short_fragment_recovered_by_header_length() -> None:
# 3000 не кратно 244, значит последний фрагмент короче.
obj = lab046.make_deterministic_object(3000, seed=2)
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
last = pieces[-1]
assert len(last.data) < lab046.FRAGMENT_DATA_SIZE
padded = lab046._pad_payload_to_packet(fragments.encode_image_fragment(last))
assert len(padded) == lab046.PACKET_PAYLOAD_SIZE
recovered = lab046.recover_fragment_from_payload(padded)
assert recovered is not None
assert recovered.data == last.data
def _make_payload(object_id: int, index: int, count: int, original_size: int, data: bytes) -> bytes:
header = struct.pack(
fragments.FRAGMENT_HEADER_FORMAT,
object_id,
index,
count,
original_size,
)
return lab046._pad_payload_to_packet(header + data)
# ------------------------------------------------------------ recover: fail-closed
def test_recover_rejects_truncated_metadata() -> None:
# Короче заголовка -> декод не проходит, объект не собирается.
assert lab046.recover_fragment_from_payload(b"\x00\x03") is None
def test_recover_rejects_impossible_index() -> None:
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=5, count=3, original_size=600, data=b"\x01" * 200)
assert lab046.recover_fragment_from_payload(payload) is None
def test_recover_rejects_inconsistent_count_and_size() -> None:
# count=1, но original_size больше одного фрагмента -> несогласовано.
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=0, count=1, original_size=10_000, data=b"\x01" * 244)
assert lab046.recover_fragment_from_payload(payload) is None
def test_recover_rejects_absurd_original_size_without_huge_alloc() -> None:
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=0, count=2, original_size=2**31, data=b"\x01" * 244)
assert lab046.recover_fragment_from_payload(payload) is None
def test_expected_length_returns_none_on_bad_fields() -> None:
assert lab046.expected_fragment_data_length(0, 0, 100) is None
assert lab046.expected_fragment_data_length(-1, 3, 100) is None
assert lab046.expected_fragment_data_length(0, 1, 0) is None
# ------------------------------------------------------------ Сборщик: fail-closed
def test_assembler_rejects_foreign_object_id() -> None:
obj = lab046.make_deterministic_object(600, seed=3)
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
assembler = lab046.ObjectAssembler()
assert assembler.offer(pieces[0]) == "accepted"
foreign = fragments.ImageFragment(
image_id=0xDEADBEEF,
fragment_index=pieces[1].fragment_index,
fragment_count=pieces[1].fragment_count,
original_size=pieces[1].original_size,
data=pieces[1].data,
)
assert assembler.offer(foreign) == "rejected"
assert not assembler.object_complete()
def test_assembler_identical_duplicate_ignored_conflicting_rejected() -> None:
obj = lab046.make_deterministic_object(600, seed=4)
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
assembler = lab046.ObjectAssembler()
assert assembler.offer(pieces[0]) == "accepted"
assert assembler.offer(pieces[0]) == "duplicate"
conflicting = fragments.ImageFragment(
image_id=pieces[0].image_id,
fragment_index=pieces[0].fragment_index,
fragment_count=pieces[0].fragment_count,
original_size=pieces[0].original_size,
data=bytes(len(pieces[0].data)),
)
assert assembler.offer(conflicting) == "conflicting"
def test_assembler_none_is_rejected_not_raised() -> None:
assembler = lab046.ObjectAssembler()
assert assembler.offer(None) == "rejected"
assert assembler.recovered_object() is None
def test_assembler_incomplete_returns_no_object() -> None:
obj = lab046.make_deterministic_object(600, seed=5)
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
assembler = lab046.ObjectAssembler()
assembler.offer(pieces[0]) # намеренно не все
assert not assembler.object_complete()
assert assembler.recovered_object() is None
# ------------------------------------------------------------ Полный тракт PHY: сценарии
def test_scenario_a_ideal_channel_exact_object() -> None:
obj = lab046.make_deterministic_object(2048, seed=46)
result = lab046.run_object_transport(obj, channel=lab046.ideal_channel)
assert result.object_complete
assert result.sha_match
assert result.object_byte_errors == 0
assert result.crc_fail_count == 0
assert result.recovered_object_sha256 == result.object_sha256
def test_scenario_b_distributed_bit_errors_corrected() -> None:
# Честный гауссов шум: до FEC ошибки есть, после FEC ноль, объект точен.
obj = lab046.make_deterministic_object(2048, seed=46)
channel = lab046.make_noisy_channel(noise_sigma=0.40, base_seed=46000)
result = lab046.run_object_transport(obj, channel=channel)
assert result.pre_fec_coded_bit_errors > 0
assert result.post_fec_protocol_bit_errors == 0
assert result.crc_fail_count == 0
assert result.object_complete
assert result.sha_match
assert result.object_byte_errors == 0
def test_scenario_c_out_of_order_delivery_still_exact() -> None:
obj = lab046.make_deterministic_object(2048, seed=46)
frames = lab046.build_object_frames(obj)
order = list(reversed(range(len(frames))))
result = lab046.run_object_transport(obj, channel=lab046.ideal_channel, delivery_order=order)
assert result.object_complete
assert result.sha_match
def test_scenario_d_duplicate_fragments_still_exact() -> None:
obj = lab046.make_deterministic_object(2048, seed=46)
result = lab046.run_object_transport(
obj,
channel=lab046.ideal_channel,
duplicate_indexes=(0, 2, 2),
)
assert result.duplicates >= 1
assert result.object_complete
assert result.sha_match
def test_scenario_e_missing_fragment_means_not_complete() -> None:
obj = lab046.make_deterministic_object(2048, seed=46)
result = lab046.run_object_transport(
obj,
channel=lab046.ideal_channel,
drop_indexes=(1,),
)
assert not result.object_complete
assert 1 in result.missing_indexes
assert result.recovered_object_sha256 is None
assert not result.sha_match
def test_scenario_f_corrupted_packet_rejected_no_fake_object() -> None:
obj = lab046.make_deterministic_object(2048, seed=46)
result = lab046.run_object_transport(
obj,
channel=lab046.ideal_channel,
corrupt_indexes=(0,),
)
assert result.crc_fail_count >= 1
assert not result.object_complete
assert result.recovered_object_sha256 is None
# ------------------------------------------------------------ Метрики кадра
def test_object_metrics_match_lab045_frame() -> None:
obj = lab046.make_deterministic_object(16384, seed=46)
frames = lab046.build_object_frames(obj)
assert len(frames) == 68 # ceil(16384 / 244)
assert all(f.plan.frame_symbol_count == 5724 for f in frames)
assert all(len(f.plan.packet) == 268 for f in frames)
# ------------------------------------------------------------ Нет оракула
def test_receiver_ignores_original_object_on_corruption() -> None:
# Если бы приёмник подглядывал в оригинал, повреждённый кадр «восстановился»
# бы. Проверяем, что этого не происходит: объект остаётся неполным.
obj = lab046.make_deterministic_object(2048, seed=46)
result = lab046.run_object_transport(
obj,
channel=lab046.ideal_channel,
corrupt_indexes=tuple(range(len(lab046.build_object_frames(obj)))),
)
assert result.crc_pass_count == 0
assert not result.object_complete
assert result.recovered_object_sha256 is None
def test_functional_self_check_runs() -> None:
lab046.run_functional_tests()
# ------------------------------------------------------------ Lab046A: continuous stream
def _small_continuous_stream(
*,
leading: int = 0,
gaps: dict[int, int] | None = None,
trailing: int = 0,
) -> tuple[bytes, lab046.ContinuousSoftStreamPlan]:
obj = lab046.make_deterministic_object(300, seed=460)
plan = lab046.build_continuous_soft_stream(
lab046.build_object_frames(obj),
leading_background_symbols=leading,
inter_frame_background=gaps,
trailing_background_symbols=trailing,
seed=461,
)
return obj, plan
def test_two_sequential_frames_found_without_known_offsets() -> None:
obj, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.valid_frame_count == 2
assert received.recovered_object == obj
# Сравнение с TX offsets разрешено только здесь, после независимого receive.
assert received.candidate_starts == stream_plan.frame_starts
def test_many_sequential_frames_are_found() -> None:
obj = lab046.make_deterministic_object(244 * 10, seed=462)
result = lab046.run_continuous_object_transport(obj)
assert result.receive.valid_frame_count == 10
assert result.receive.preamble_candidates == 10
assert result.sha_match
def test_first_frame_need_not_start_at_zero() -> None:
obj, stream_plan = _small_continuous_stream(leading=37)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.candidate_starts[0] == 37
assert received.recovered_object == obj
def test_trailing_noise_is_ignored() -> None:
obj, stream_plan = _small_continuous_stream(trailing=43)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.valid_frame_count == 2
assert received.preamble_candidates == 2
assert received.recovered_object == obj
def test_inter_frame_background_does_not_hide_next_frame() -> None:
obj, stream_plan = _small_continuous_stream(gaps={0: 19})
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.candidate_starts[1] - received.candidate_starts[0] == 5724 + 19
assert received.recovered_object == obj
def test_same_frame_is_not_decoded_twice() -> None:
_, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert len(received.candidate_starts) == len(set(received.candidate_starts))
assert received.preamble_candidates == received.valid_frame_count == 2
def test_malformed_candidate_rejected_and_later_good_frame_found() -> None:
obj = lab046.make_deterministic_object(300, seed=463)
result = lab046.run_continuous_object_transport(
obj,
prepend_malformed_candidate=True,
)
assert result.receive.rejected_candidates > 0
assert result.receive.valid_frame_count == 2
assert result.receive.candidate_starts[0] == 0
assert result.sha_match
def test_stronger_false_peak_does_not_delete_overlapping_later_frame() -> None:
obj = lab046.make_deterministic_object(100, seed=999)
frame = lab046.build_object_frames(obj)[0]
preamble = lab046._bits_to_float32_soft(lab046.lab045.lab044.preamble_bits())
true_frame = lab046._bits_to_float32_soft(frame.plan.frame_bits)
true_start = 508 # четыре периода PRBS7 после ложного старта
stream = np.zeros(true_start + true_frame.size, dtype=np.float32)
stream[:preamble.size] = preamble
stream[true_start:true_start + true_frame.size] = true_frame
stream[preamble.size:true_start + preamble.size] *= np.float32(0.7)
scores = lab046._normalized_soft_preamble_correlation(stream)
assert scores[0] > scores[true_start]
received = lab046.scan_continuous_soft_stream(stream)
assert received.rejected_candidates > 0
assert true_start in received.candidate_starts
assert received.valid_frame_count == 1
assert received.recovered_object == obj
def test_validated_header_drives_candidate_body_length() -> None:
obj, stream_plan = _small_continuous_stream()
packet_bytes = lab046.lab045.MIN_PACKET_BYTES
coded_symbols = lab046.lab045.conv.encoded_bit_count(packet_bytes * 8)
prefix_bits = np.concatenate((
lab046.lab045.lab044.preamble_bits(),
lab046.lab045.encode_phy_header(packet_bytes),
np.zeros(coded_symbols, dtype=np.uint8),
))
stream = np.concatenate((
lab046._bits_to_float32_soft(prefix_bits),
stream_plan.soft_symbols,
))
received = lab046.scan_continuous_soft_stream(stream)
assert received.frame_receptions[0].packet_bytes == packet_bytes
assert received.frame_receptions[0].frame_end == len(prefix_bits)
assert received.rejected_candidates >= 1
assert received.recovered_object == obj
def test_cursor_is_strictly_monotonic() -> None:
_, stream_plan = _small_continuous_stream(leading=7, gaps={0: 13}, trailing=5)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert all(
later > earlier
for earlier, later in zip(received.cursor_history, received.cursor_history[1:])
)
def test_max_search_iterations_is_fail_closed() -> None:
_, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(
stream_plan.soft_symbols,
max_search_iterations=1,
)
assert received.max_search_iterations_reached
assert received.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED"
assert received.valid_frame_count == 1
assert not received.object_complete
def test_max_frames_is_fail_closed() -> None:
_, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(
stream_plan.soft_symbols,
max_frames=1,
)
assert received.max_frames_reached
assert received.termination_reason == "MAX_FRAMES_REACHED"
assert received.valid_frame_count == 1
assert not received.object_complete
def test_continuous_ideal_16_kib_object_is_exact() -> None:
obj = lab046.make_deterministic_object()
result = lab046.run_continuous_object_transport(obj)
assert result.receive.preamble_candidates == 68
assert result.receive.valid_phy_headers == 68
assert result.receive.fec_pass_count == 68
assert result.receive.crc_pass_count == 68
assert result.receive.valid_fragments == 68
assert result.receive.object_complete
assert result.sha_match
assert result.object_byte_errors == 0
assert result.stream_plan.soft_symbols.dtype == np.float32
assert result.stream_plan.soft_symbols.nbytes < 2_000_000
def test_continuous_background_16_kib_object_is_exact() -> None:
obj = lab046.make_deterministic_object()
result = lab046.run_continuous_object_transport(
obj,
leading_background_symbols=37,
inter_frame_background={7: 11, 23: 19, 51: 7},
trailing_background_symbols=43,
seed=464,
)
assert result.stream_plan.leading_background_symbols == 37
assert result.stream_plan.inter_frame_background_symbols == 37
assert result.stream_plan.trailing_background_symbols == 43
assert result.receive.valid_frame_count == 68
assert result.receive.object_complete
assert result.sha_match
def test_continuous_noisy_16_kib_object_is_exact_after_fec() -> None:
obj = lab046.make_deterministic_object()
result = lab046.run_continuous_object_transport(
obj,
noise_sigma=0.40,
seed=46001,
)
assert result.receive.valid_frame_count == 68
assert result.pre_fec_coded_bit_errors > 0
assert result.post_fec_protocol_bit_errors == 0
assert result.receive.crc_pass_count == 68
assert result.receive.crc_fail_count == 0
assert result.receive.object_complete
assert result.sha_match
def test_continuous_receiver_has_no_tx_oracle_parameters(monkeypatch) -> None:
obj, stream_plan = _small_continuous_stream(leading=5)
parameters = set(inspect.signature(lab046.scan_continuous_soft_stream).parameters)
forbidden = {
"frame_plans",
"frame_starts",
"frame_count",
"fragment_indexes",
"packets",
"coded_bits",
"original_object",
}
assert parameters.isdisjoint(forbidden)
def forbidden_tx_lookup(*args, **kwargs):
raise AssertionError("scanner обратился к TX-side plan")
monkeypatch.setattr(lab046, "build_object_frames", forbidden_tx_lookup)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.recovered_object == obj