Lab043: add hardware diagnostics and HackRF comparison tools

This commit is contained in:
LittleSam129
2026-08-21 17:33:57 +03:00
parent f0fa7e8a46
commit 42b0120eea
42 changed files with 8763 additions and 6 deletions

View File

@@ -0,0 +1,538 @@
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from experiments import lab043_hackrf_counter as counter
from experiments import lab043_hackrf_hardware as hackrf
from experiments import lab043_pluto_to_rtlsdr as lab043
from experiments import lab043_prbs11_hardware as pluto_hardware
class _FakeHackrfReceiver:
def __init__(self) -> None:
self.gain_calls: list[tuple[int, int]] = []
def configure_rx(self) -> dict:
return {
"set_center_frequency_hz": 435_000_000,
"set_sample_rate_hz": 2_400_000.0,
"rf_amp_enabled": False,
"lna_gain_db": 8,
"vga_gain_db": 8,
"antenna_bias_enabled": False,
}
def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict:
self.gain_calls.append((lna_gain_db, vga_gain_db))
return {
"rf_amp_enabled": False,
"antenna_bias_enabled": False,
"lna_gain_db": lna_gain_db,
"vga_gain_db": vga_gain_db,
"gain_readback_available": False,
"set_calls_succeeded": True,
}
class _FakeHackrfModule:
def __init__(self) -> None:
self.preflight_calls: list[tuple[int, int]] = []
def preflight_hackrf(self, lna_gain_db: int, vga_gain_db: int) -> dict:
self.preflight_calls.append((lna_gain_db, vga_gain_db))
return _FakeHackrfReceiver().configure_rx()
class _FakePlutoModule:
def __init__(self) -> None:
self.preflight_uris: list[str] = []
@staticmethod
def discover_pluto_usb_uri() -> str:
return "usb:1.2.3"
def preflight_pluto(self, uri: str) -> dict:
self.preflight_uris.append(uri)
return {"uri": uri, "context_opened": True}
def test_counter_uses_dedicated_output_directory() -> None:
assert counter.PROCESSED_DIRECTORY == Path(
"data/processed/lab043_hackrf_counter"
)
@pytest.mark.parametrize(
"script_name",
["lab043_hackrf_hardware.py", "lab043_hackrf_counter.py"],
)
def test_hackrf_scripts_support_direct_help_invocation(script_name: str) -> None:
project_root = Path(__file__).resolve().parents[1]
completed = subprocess.run(
[sys.executable, str(project_root / "experiments" / script_name), "--help"],
cwd=project_root,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
assert completed.returncode == 0, completed.stdout + completed.stderr
def test_counter_receiver_is_forced_to_working_point_8_8() -> None:
receiver = _FakeHackrfReceiver()
parameters = counter.configure_counter_receiver(receiver)
assert receiver.gain_calls == [(8, 8)]
assert parameters["lna_gain_db"] == 8
assert parameters["vga_gain_db"] == 8
assert parameters["rf_amp_enabled"] is False
assert parameters["antenna_bias_enabled"] is False
def test_counter_and_hackrf_module_share_working_point_8_8() -> None:
assert (counter.HACKRF_LNA_GAIN_DB, counter.HACKRF_VGA_GAIN_DB) == (8, 8)
assert (hackrf.HACKRF_LNA_GAIN_DB, hackrf.HACKRF_VGA_GAIN_DB) == (8, 8)
def test_counter_preflight_explicitly_requests_working_point_8_8() -> None:
module = _FakeHackrfModule()
parameters = counter.preflight_counter_receiver(module)
assert module.preflight_calls == [(8, 8)]
assert parameters["lna_gain_db"] == 8
assert parameters["vga_gain_db"] == 8
def test_counter_hardware_preflight_uses_discovered_usb_pluto() -> None:
hackrf_module = _FakeHackrfModule()
pluto_module = _FakePlutoModule()
result = counter.preflight_counter_hardware(hackrf_module, pluto_module)
assert pluto_module.preflight_uris == ["usb:1.2.3"]
assert result["pluto"]["uri"] == "usb:1.2.3"
assert hackrf_module.preflight_calls == [(8, 8)]
def test_pluto_working_point_is_checked_before_tx() -> None:
counter.validate_pluto_working_point(
{
"actual_center_frequency_hz": 435_000_000,
"actual_sample_rate_hz": 2_400_000,
"actual_gain_db": -30.0,
}
)
counter.validate_pluto_working_point(
{
"actual_center_frequency_hz": 435_000_000,
"actual_sample_rate_hz": 2_399_998,
"actual_gain_db": -30.0,
}
)
with pytest.raises(RuntimeError, match=r"Рабочая точка Pluto\+ не подтверждена"):
counter.validate_pluto_working_point(
{
"actual_center_frequency_hz": 435_000_000,
"actual_sample_rate_hz": 2_400_000,
"actual_gain_db": -20.0,
}
)
with pytest.raises(RuntimeError, match=r"Рабочая точка Pluto\+ не подтверждена"):
counter.validate_pluto_working_point(
{
"actual_center_frequency_hz": 435_000_000,
"actual_sample_rate_hz": 2_399_000,
"actual_gain_db": -30.0,
}
)
@pytest.mark.parametrize(
("field", "bad_value"),
[
("set_center_frequency_hz", 434_999_999),
("set_sample_rate_hz", 2_399_999.0),
("lna_gain_db", 0),
("vga_gain_db", 0),
("rf_amp_enabled", True),
("antenna_bias_enabled", True),
],
)
def test_counter_rejects_any_other_hackrf_working_point(
field: str,
bad_value: object,
) -> None:
parameters = _FakeHackrfReceiver().configure_rx()
parameters[field] = bad_value
with pytest.raises(RuntimeError, match="Рабочая точка HackRF не подтверждена"):
counter.validate_counter_working_point(parameters)
def test_event_rows_use_null_and_explicit_status_for_unknown_iq_count() -> None:
events = [
{
"received_symbol_index": 0,
"type": "пропуск",
}
]
boundaries = (
SimpleNamespace(callback_index=1, accepted_end_sample=100),
)
rows, _ = counter._event_hardware_rows(
events,
np.asarray([100.0]),
boundaries,
np.zeros(200, dtype=np.complex64),
)
assert rows[0]["exact_lost_or_repeated_iq_samples"] is None
assert rows[0]["exact_iq_sample_count_status"] == "not_computed"
assert rows[0]["exact_iq_sample_count_reason"]
def test_hardware_orchestration_saves_raw_before_analysis_and_transmits_once(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
events: list[str] = []
transmitted: list[np.ndarray] = []
configured_gains: list[tuple[int, int]] = []
class FakeLibrary:
@staticmethod
def hackrf_is_streaming(_device: object) -> int:
return 1
class FakeDevice:
library = FakeLibrary()
device = object()
def __enter__(self) -> "FakeDevice":
return self
def __exit__(self, *_arguments: object) -> None:
return None
def configure_rx(self) -> dict:
events.append("configure_hackrf")
return _FakeHackrfReceiver().configure_rx()
def set_rx_gains(self, lna_gain_db: int, vga_gain_db: int) -> dict:
configured_gains.append((lna_gain_db, vga_gain_db))
return {
"rf_amp_enabled": False,
"antenna_bias_enabled": False,
"lna_gain_db": lna_gain_db,
"vga_gain_db": vga_gain_db,
"gain_readback_available": False,
"set_calls_succeeded": True,
}
def capture(self, target_sample_count: int) -> SimpleNamespace:
events.append("capture")
assert target_sample_count == 4
return SimpleNamespace(
samples=np.zeros(target_sample_count, dtype=np.complex64),
boundaries=(),
diagnostics={"callback_count_total": 1},
)
monkeypatch.setattr(counter, "RX_LEADING_SECONDS", 0.0)
monkeypatch.setattr(counter, "RX_TRAILING_SECONDS", 0.0)
monkeypatch.setattr(counter, "FIXED_TX_UPLOAD_MARGIN_SECONDS", 0.0)
monkeypatch.setattr(hackrf, "HACKRF_EXPECTED_CALLBACK_BYTES", 0)
monkeypatch.setattr(hackrf, "HackrfRxDevice", FakeDevice)
monkeypatch.setattr(
hackrf,
"preflight_hackrf",
lambda lna, vga: {
**_FakeHackrfReceiver().configure_rx(),
"lna_gain_db": lna,
"vga_gain_db": vga,
"serial_number": "test-serial",
"firmware_version": "test-firmware",
"libhackrf_version": "test-library",
},
)
monkeypatch.setattr(
pluto_hardware,
"discover_pluto_usb_uri",
lambda: "usb:1.2.3",
)
monkeypatch.setattr(
pluto_hardware,
"preflight_pluto",
lambda uri: {"context_opened": True, "uri": uri},
)
monkeypatch.setattr(
pluto_hardware,
"configure_pluto",
lambda uri: (
object(),
{
"uri": uri,
"actual_center_frequency_hz": 435_000_000,
"actual_sample_rate_hz": 2_400_000,
"actual_gain_db": -30.0,
},
),
)
def fake_transmit(
_device: object,
samples: np.ndarray,
_sample_rate_hz: float,
) -> float:
events.append("transmit")
transmitted.append(np.asarray(samples).copy())
return 0.0
monkeypatch.setattr(
pluto_hardware,
"transmit_noncyclic_buffer_once",
fake_transmit,
)
monkeypatch.setattr(
counter,
"build_counter_waveform",
lambda _sample_rate_hz: (
np.asarray([0.25 + 0.25j] * 4, dtype=np.complex64),
{"total_duration_seconds": 4 / counter.NOMINAL_SAMPLE_RATE},
),
)
real_save_raw = lab043.save_raw_iq_before_processing
def save_raw(*arguments: object, **keyword_arguments: object):
events.append("save_raw")
return real_save_raw(*arguments, **keyword_arguments)
monkeypatch.setattr(lab043, "save_raw_iq_before_processing", save_raw)
def fake_analyze(*_arguments: object) -> tuple[dict, dict]:
events.append("analyze")
return {"classification": "A"}, {}
monkeypatch.setattr(counter, "analyze_hardware_counter", fake_analyze)
monkeypatch.setattr(
counter,
"save_counter_artifacts",
lambda *_arguments, **_keyword_arguments: events.append("save_processed"),
)
result = counter.run_one_hardware_counter(
raw_directory=tmp_path / "raw",
processed_directory=tmp_path / "processed",
)
assert result["processing_status"] == "success"
assert configured_gains == [(8, 8)]
assert len(transmitted) == 1
assert events.count("transmit") == 1
assert events.index("save_raw") < events.index("analyze")
assert result["waveform"]["purpose"] == (
"цифровой счётчик времени, не измерение BER"
)
def _reference(count: int = 20_000, start: int = 12_345) -> np.ndarray:
return counter.counter_symbol_ids(start, count)
def test_prbs20_period_and_unique_state_map() -> None:
bits = counter.prbs20_period_bits()
positions = counter.bit_state_to_position()
assert len(bits) == (1 << 20) - 1
assert positions[0] == -1
assert np.count_nonzero(positions >= 0) == (1 << 20) - 1
assert len(np.unique(positions[positions >= 0])) == (1 << 20) - 1
def test_qpsk_alphabet_has_four_separated_unit_states() -> None:
states = counter.QPSK_STATES
assert len(states) == 4
assert np.allclose(np.abs(states), 1.0)
distances = np.abs(states[:, None] - states[None, :])
assert np.min(distances[distances > 0]) == pytest.approx(np.sqrt(2.0))
def test_hardware_waveform_parameters_are_fixed_before_tx() -> None:
waveform, parameters = counter.build_counter_waveform(2_399_998.0)
assert counter.SYMBOL_RATE == 20_000.0
assert parameters["samples_per_symbol"] == 120
assert parameters["useful_symbol_count"] == 100_000
assert parameters["transmitted_symbol_count"] == 110_000
assert parameters["margin_symbol_count_each_side"] == 5_000
assert len(waveform) == 13_200_000
assert parameters["useful_duration_seconds"] == pytest.approx(5.00000416667)
def test_zero_cfo_exclusion_is_reproduced_and_diagnostic_override_recovers() -> None:
waveform, parameters = counter.build_counter_waveform(2_399_998.0)
default = counter.recover_counter_from_iq(waveform, 2_399_998.0)
diagnostic = counter.recover_counter_from_iq(
waveform,
2_399_998.0,
minimum_fourth_power_hz=0.0,
)
assert default["carrier"]["carrier_offset_hz"] == pytest.approx(44.99, abs=0.1)
assert (
default["counter_analysis"]["recovered_symbol_count"]
< parameters["transmitted_symbol_count"]
)
assert diagnostic["carrier"]["carrier_offset_hz"] == pytest.approx(0.0, abs=0.1)
assert (
diagnostic["counter_analysis"]["recovered_symbol_count"]
== parameters["transmitted_symbol_count"]
)
assert diagnostic["counter_analysis"]["stable_run_count"] == 1
def test_ideal_counter_is_continuous() -> None:
result = counter.analyze_counter_symbols(_reference())
assert result["classification"] == "A"
assert result["events"] == []
assert result["ordinary_symbol_error_count"] == 0
@pytest.mark.parametrize("deleted", [1, 2, 10])
def test_counter_detects_deleted_symbols(deleted: int) -> None:
reference = _reference()
received = np.delete(reference, np.s_[8_000 : 8_000 + deleted])
result = counter.analyze_counter_symbols(received)
assert result["classification"] == "B"
assert any(
event["type"] == "пропуск"
and event["minimum_confirmed_gap_symbols"] == deleted
for event in result["events"]
)
@pytest.mark.parametrize("repeated", [1, 4])
def test_counter_detects_repeated_symbols(repeated: int) -> None:
reference = _reference()
at = 8_000
received = np.insert(reference, at, reference[at - repeated : at])
result = counter.analyze_counter_symbols(received)
assert result["classification"] == "B"
assert any(
event["type"] == "повтор"
and event["minimum_confirmed_gap_symbols"] == repeated
for event in result["events"]
)
def test_counter_detects_foreign_insertion() -> None:
reference = _reference()
received = np.insert(reference, 8_000, np.asarray([0, 0, 0], dtype=np.uint8))
result = counter.analyze_counter_symbols(received)
assert result["classification"] == "B"
assert any(event["type"] == "вставка" for event in result["events"])
def test_counter_detects_reordered_neighboring_blocks() -> None:
reference = _reference()
first = reference[7_000:7_200]
second = reference[7_200:7_450]
received = np.concatenate((reference[:7_000], second, first, reference[7_450:]))
result = counter.analyze_counter_symbols(received)
assert result["classification"] == "B"
assert len(result["events"]) >= 2
def test_partial_capture_from_middle_locates_position_without_false_jump() -> None:
received = counter.counter_symbol_ids(456_789, 12_000)
result = counter.analyze_counter_symbols(received)
assert result["classification"] == "A"
assert result["runs"][0]["first_sequence_position"] == 456_789
def test_capture_ending_early_is_not_a_discontinuity() -> None:
reference = _reference()
result = counter.analyze_counter_symbols(reference[:9_321])
assert result["classification"] == "A"
assert result["events"] == []
def test_single_symbol_error_is_not_a_large_position_jump() -> None:
received = _reference()
received[8_000] = (received[8_000] + 1) % 4
result = counter.analyze_counter_symbols(received)
assert result["classification"] == "A"
assert result["events"] == []
assert result["ordinary_symbol_error_count"] == 1
@pytest.mark.parametrize(
("carrier_offset_hz", "clock_error_ppm", "noise"),
[
(1_000.0, 0.0, 0.0),
(1_000.0, 35.0, 0.0),
(1_000.0, 0.0, 0.12),
(-750.0, -30.0, 0.10),
],
)
def test_iq_recovery_handles_frequency_clock_error_and_noise(
carrier_offset_hz: float,
clock_error_ppm: float,
noise: float,
) -> None:
sample_rate = 120_000.0
samples_per_symbol = 12
received = counter.synthesize_counter_iq(
start_position=345_678,
symbol_count=10_000,
samples_per_symbol=samples_per_symbol,
sample_rate_hz=sample_rate,
carrier_offset_hz=carrier_offset_hz,
sample_clock_error_ppm=clock_error_ppm,
noise_standard_deviation=noise,
)
recovered = counter.recover_counter_from_iq(
received,
sample_rate,
nominal_samples_per_symbol=samples_per_symbol,
fft_samples=65_536,
minimum_fourth_power_hz=200.0,
maximum_fourth_power_hz=10_000.0,
)
assert recovered["counter_analysis"]["classification"] == "A"
assert recovered["counter_analysis"]["anchor_count"] > 8_000
assert recovered["counter_analysis"]["events"] == []
assert recovered["carrier"]["carrier_offset_hz"] == pytest.approx(
carrier_offset_hz, abs=2.0
)