Lab043: add hardware diagnostics and HackRF comparison tools
This commit is contained in:
203
tests/test_lab043_hackrf_hardware.py
Normal file
203
tests/test_lab043_hackrf_hardware.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import ctypes
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from experiments import lab043_hackrf_hardware as hackrf
|
||||
from experiments import lab043_hackrf_continuity as continuity
|
||||
|
||||
|
||||
def _block(first: int, complex_samples: int) -> bytes:
|
||||
values = (np.arange(2 * complex_samples, dtype=np.int16) + first) % 255 - 127
|
||||
return values.astype(np.int8).tobytes()
|
||||
|
||||
|
||||
def test_signed_iq_bytes_to_complex64_preserves_iq_order() -> None:
|
||||
raw = np.asarray([-128, 127, -64, 32], dtype=np.int8).tobytes()
|
||||
|
||||
result = hackrf.signed_iq_bytes_to_complex64(raw)
|
||||
|
||||
assert result.dtype == np.complex64
|
||||
assert np.allclose(result, [-1.0 + 127j / 128.0, -0.5 + 0.25j])
|
||||
|
||||
|
||||
def test_signed_iq_bytes_rejects_incomplete_complex_sample() -> None:
|
||||
with pytest.raises(ValueError, match="чётное"):
|
||||
hackrf.signed_iq_bytes_to_complex64(b"\x01")
|
||||
|
||||
|
||||
def test_callback_copy_uses_only_valid_length_and_survives_source_reuse() -> None:
|
||||
source = (ctypes.c_uint8 * 8)(1, 2, 3, 4, 90, 91, 92, 93)
|
||||
|
||||
copied = hackrf.copy_valid_callback_bytes(source, buffer_length=8, valid_length=4)
|
||||
for index in range(8):
|
||||
source[index] = 0
|
||||
|
||||
assert copied == b"\x01\x02\x03\x04"
|
||||
|
||||
|
||||
def test_callback_copy_rejects_odd_valid_length() -> None:
|
||||
source = (ctypes.c_uint8 * 4)(1, 2, 3, 4)
|
||||
|
||||
with pytest.raises(ValueError, match="чётным"):
|
||||
hackrf.copy_valid_callback_bytes(source, buffer_length=4, valid_length=3)
|
||||
|
||||
|
||||
def test_callback_assembler_discards_fixed_warmup_and_keeps_order() -> None:
|
||||
blocks = [_block(0, 4), _block(20, 4), _block(40, 4), _block(60, 4)]
|
||||
|
||||
samples, boundaries, diagnostics = hackrf.assemble_callback_blocks(
|
||||
blocks, target_sample_count=9, warmup_callback_count=1
|
||||
)
|
||||
|
||||
expected = hackrf.signed_iq_bytes_to_complex64(
|
||||
blocks[1] + blocks[2] + blocks[3][:2]
|
||||
)
|
||||
assert np.array_equal(samples, expected)
|
||||
assert [row.callback_index for row in boundaries] == [1, 2, 3]
|
||||
assert [row.accepted_end_sample for row in boundaries] == [4, 8, 9]
|
||||
assert [row.accepted_bytes for row in boundaries] == [8, 8, 2]
|
||||
assert [row.buffer_length for row in boundaries] == [8, 8, 8]
|
||||
assert diagnostics["callback_indexes_sequential"]
|
||||
assert diagnostics["program_queue_missing_callback_count"] == 0
|
||||
assert diagnostics["accepted_sample_count"] == 9
|
||||
|
||||
|
||||
def test_callback_assembler_keeps_distinct_blocks_without_duplication() -> None:
|
||||
blocks = [b"\x01\x02\x03\x04", b"\x05\x06\x07\x08"]
|
||||
|
||||
samples, boundaries, diagnostics = hackrf.assemble_callback_blocks(
|
||||
blocks,
|
||||
target_sample_count=4,
|
||||
warmup_callback_count=0,
|
||||
buffer_lengths=[16, 16],
|
||||
)
|
||||
|
||||
expected = hackrf.signed_iq_bytes_to_complex64(blocks[0] + blocks[1])
|
||||
assert np.array_equal(samples, expected)
|
||||
assert [row.callback_index for row in boundaries] == [0, 1]
|
||||
assert [row.valid_bytes for row in boundaries] == [4, 4]
|
||||
assert [row.buffer_length for row in boundaries] == [16, 16]
|
||||
assert diagnostics["accepted_bytes"] == sum(map(len, blocks))
|
||||
|
||||
|
||||
def test_capture_contract_keeps_original_int8_bytes_separate_from_complex64() -> None:
|
||||
raw = np.asarray([-128, 127, -64, 32], dtype=np.int8).tobytes()
|
||||
capture = hackrf.HackrfCapture(
|
||||
samples=hackrf.signed_iq_bytes_to_complex64(raw),
|
||||
boundaries=(),
|
||||
diagnostics={},
|
||||
raw_bytes=raw,
|
||||
)
|
||||
|
||||
assert capture.raw_bytes == raw
|
||||
assert capture.samples.dtype == np.complex64
|
||||
assert np.array_equal(
|
||||
capture.samples,
|
||||
hackrf.signed_iq_bytes_to_complex64(capture.raw_bytes),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout_seconds", [0.0, -1.0, float("nan")])
|
||||
def test_capture_rejects_invalid_timeout_before_hardware_access(
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
receiver = hackrf.HackrfRxDevice()
|
||||
|
||||
with pytest.raises(ValueError, match="Тайм-аут"):
|
||||
receiver.capture(1, timeout_seconds=timeout_seconds)
|
||||
|
||||
|
||||
def test_callback_assembler_rejects_short_stream() -> None:
|
||||
with pytest.raises(RuntimeError, match="короче"):
|
||||
hackrf.assemble_callback_blocks(
|
||||
[_block(0, 4), _block(20, 4)],
|
||||
target_sample_count=9,
|
||||
warmup_callback_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_callback_assembler_rejects_odd_callback() -> None:
|
||||
with pytest.raises(ValueError, match="нечётное"):
|
||||
hackrf.assemble_callback_blocks(
|
||||
[b"\x00\x01", b"\x02"],
|
||||
target_sample_count=1,
|
||||
warmup_callback_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_hackrf_configuration_is_fixed_and_rx_only() -> None:
|
||||
assert hackrf.HACKRF_CENTER_HZ == 435_000_000
|
||||
assert hackrf.HACKRF_SAMPLE_RATE_HZ == 2_400_000.0
|
||||
assert hackrf.HACKRF_RF_AMP == 0
|
||||
assert hackrf.HACKRF_ANTENNA_BIAS == 0
|
||||
assert hackrf.HACKRF_LNA_GAIN_DB == 8
|
||||
assert hackrf.HACKRF_VGA_GAIN_DB == 8
|
||||
assert not hasattr(hackrf.HackrfRxDevice, "transmit")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lna", "vga"),
|
||||
[(0, 0), (8, 8), (16, 8), (16, 16), (24, 16), (24, 24), (40, 62)],
|
||||
)
|
||||
def test_hackrf_rx_gain_steps_accept_official_values(lna: int, vga: int) -> None:
|
||||
assert hackrf.validate_hackrf_rx_gains(lna, vga) == (lna, vga)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lna", [-8, 4, 48])
|
||||
def test_hackrf_rx_gain_steps_reject_invalid_lna(lna: int) -> None:
|
||||
with pytest.raises(ValueError, match="LNA"):
|
||||
hackrf.validate_hackrf_rx_gains(lna, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vga", [-2, 1, 64])
|
||||
def test_hackrf_rx_gain_steps_reject_invalid_vga(vga: int) -> None:
|
||||
with pytest.raises(ValueError, match="VGA"):
|
||||
hackrf.validate_hackrf_rx_gains(0, vga)
|
||||
|
||||
|
||||
def test_periodic_continuity_tone_has_identical_wrap_phase() -> None:
|
||||
tone, frequency = continuity.build_periodic_tone(2_400_000.0)
|
||||
|
||||
phase_step = np.exp(1j * 2.0 * np.pi * frequency / 2_400_000.0)
|
||||
assert len(tone) == 48 * continuity.CYCLIC_PERIOD_COUNT
|
||||
assert np.allclose(tone[0], tone[-1] * phase_step, atol=2e-6)
|
||||
|
||||
|
||||
def test_boundary_analysis_finds_known_phase_step_at_callback_boundary() -> None:
|
||||
sample_rate = 240_000.0
|
||||
frequency = 5_000.0
|
||||
sample_count = 240_000
|
||||
boundary_sample = sample_count // 2
|
||||
indexes = np.arange(sample_count, dtype=np.float64)
|
||||
samples = np.exp(1j * 2.0 * np.pi * frequency * indexes / sample_rate)
|
||||
samples[boundary_sample:] *= np.exp(0.8j)
|
||||
boundary = hackrf.HackrfBoundary(
|
||||
callback_index=1,
|
||||
valid_bytes=2 * boundary_sample,
|
||||
accepted_bytes=2 * boundary_sample,
|
||||
accepted_start_sample=0,
|
||||
accepted_end_sample=boundary_sample,
|
||||
monotonic_seconds=0.5,
|
||||
block_sha256="synthetic",
|
||||
buffer_length=2 * boundary_sample,
|
||||
)
|
||||
|
||||
rows, summary = continuity.analyze_callback_boundaries(
|
||||
samples.astype(np.complex64),
|
||||
(boundary,),
|
||||
sample_rate,
|
||||
frequency,
|
||||
stable_start_sample=0,
|
||||
stable_end_sample=sample_count,
|
||||
side_samples=32_768,
|
||||
block_samples=8_192,
|
||||
hop_samples=2_048,
|
||||
)
|
||||
|
||||
assert summary["confirmed_jump_count"] == 1
|
||||
assert rows[0]["additional_phase_jump_rad"] == pytest.approx(0.8, abs=0.01)
|
||||
assert rows[0]["equivalent_sample_shift_principal"] == pytest.approx(
|
||||
0.8 / (2.0 * np.pi * frequency / sample_rate), abs=0.05
|
||||
)
|
||||
assert rows[0]["relation_to_callback_boundary"] == "consistent_with_exact_boundary"
|
||||
Reference in New Issue
Block a user