Lab043: add hardware diagnostics and HackRF comparison tools
This commit is contained in:
428
tests/test_lab043_hackrf_zero_if_compare.py
Normal file
428
tests/test_lab043_hackrf_zero_if_compare.py
Normal file
@@ -0,0 +1,428 @@
|
||||
import ast
|
||||
import inspect
|
||||
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_hackrf_zero_if_compare as comparison
|
||||
from experiments import lab043_prbs11_hardware as pluto_hardware
|
||||
|
||||
|
||||
def test_comparison_uses_dedicated_output_directory() -> None:
|
||||
assert comparison.PROCESSED_DIRECTORY == Path(
|
||||
"data/processed/lab043_hackrf_zero_if_compare"
|
||||
)
|
||||
|
||||
|
||||
def test_composite_waveform_has_seven_fixed_sections_and_identical_counters(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sample_rate = 240_000.0
|
||||
base_counter = np.asarray(
|
||||
[0.35 + 0.0j, 0.0 + 0.35j, -0.35 + 0.0j, 0.0 - 0.35j],
|
||||
dtype=np.complex64,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
counter,
|
||||
"build_counter_waveform",
|
||||
lambda _sample_rate: (base_counter.copy(), {"samples_per_symbol": 120}),
|
||||
)
|
||||
|
||||
waveform, metadata = comparison.build_composite_waveform(sample_rate)
|
||||
|
||||
names = [row["name"] for row in metadata["sections"]]
|
||||
assert names == [
|
||||
"leading_zero",
|
||||
"tone_plus_50khz",
|
||||
"guard_before_shifted_counter",
|
||||
"counter_plus_50khz",
|
||||
"guard_after_shifted_counter",
|
||||
"counter_zero_if",
|
||||
"trailing_zero",
|
||||
]
|
||||
sections = {row["name"]: row for row in metadata["sections"]}
|
||||
shifted_row = sections["counter_plus_50khz"]
|
||||
centered_row = sections["counter_zero_if"]
|
||||
shifted = waveform[
|
||||
shifted_row["start_sample"] : shifted_row["end_sample_exclusive"]
|
||||
]
|
||||
centered = waveform[
|
||||
centered_row["start_sample"] : centered_row["end_sample_exclusive"]
|
||||
]
|
||||
indexes = np.arange(len(shifted), dtype=np.float64)
|
||||
restored = shifted * np.exp(
|
||||
-2j * np.pi * comparison.TONE_OFFSET_HZ * indexes / sample_rate
|
||||
)
|
||||
assert np.allclose(restored, centered, atol=2e-7)
|
||||
assert metadata["digital_sequences_bit_identical"] is True
|
||||
assert (
|
||||
metadata["counter_symbol_ids_sha256_shifted"]
|
||||
== metadata["counter_symbol_ids_sha256_centered"]
|
||||
)
|
||||
assert metadata["counter_amplitude"] == metadata["tone_amplitude"] == 0.35
|
||||
for name in (
|
||||
"leading_zero",
|
||||
"guard_before_shifted_counter",
|
||||
"guard_after_shifted_counter",
|
||||
"trailing_zero",
|
||||
):
|
||||
row = sections[name]
|
||||
assert np.count_nonzero(waveform[row["start_sample"] : row["end_sample_exclusive"]]) == 0
|
||||
|
||||
|
||||
def test_pluto_scaling_is_in_place_and_does_not_clip() -> None:
|
||||
waveform = np.asarray([0.35 + 0.0j, 0.0 - 0.35j], dtype=np.complex64)
|
||||
identity = id(waveform)
|
||||
|
||||
result = comparison.scale_pluto_samples_in_place(waveform)
|
||||
|
||||
assert id(result) == identity
|
||||
assert result.dtype == np.complex64
|
||||
assert np.max(np.abs(result)) == pytest.approx(0.35 * 2**14, rel=1e-6)
|
||||
|
||||
|
||||
def test_pluto_validation_includes_rf_bandwidth() -> None:
|
||||
parameters = {
|
||||
"actual_center_frequency_hz": 435_000_000,
|
||||
"actual_sample_rate_hz": 2_400_000,
|
||||
"actual_gain_db": -30.0,
|
||||
"actual_rf_bandwidth_hz": 200_000,
|
||||
}
|
||||
comparison.validate_pluto_working_point(parameters)
|
||||
|
||||
parameters["actual_rf_bandwidth_hz"] = 100_000
|
||||
with pytest.raises(RuntimeError, match="RF bandwidth"):
|
||||
comparison.validate_pluto_working_point(parameters)
|
||||
|
||||
|
||||
def test_dual_raw_is_hashed_reread_and_exactly_converted(tmp_path: Path) -> None:
|
||||
raw = np.asarray([-128, 127, -64, 32, 0, -1], dtype=np.int8).tobytes()
|
||||
samples = hackrf.signed_iq_bytes_to_complex64(raw)
|
||||
|
||||
int8_path, iq_path, metadata_path, document = comparison.save_dual_raw_before_processing(
|
||||
tmp_path / "capture",
|
||||
raw,
|
||||
samples,
|
||||
{"capture_status": "captured", "processing_status": "pending"},
|
||||
)
|
||||
|
||||
assert int8_path.read_bytes() == raw
|
||||
assert np.array_equal(np.load(iq_path, allow_pickle=False), samples)
|
||||
assert document["raw_verified"] is True
|
||||
assert document["int8_to_complex64"]["verified"] is True
|
||||
assert document["int8_to_complex64"]["byte_order"] == "interleaved I then Q"
|
||||
assert document["int8_sha256"] == comparison._hash_file(int8_path)
|
||||
assert document["iq_sha256"] == comparison._hash_file(iq_path)
|
||||
assert metadata_path.exists()
|
||||
|
||||
|
||||
def test_background_metrics_keep_dc_and_signed_code_distributions() -> None:
|
||||
codes = np.asarray([[-2, 4], [-2, 4], [-1, 3], [-2, 4]], dtype=np.int8)
|
||||
samples = hackrf.signed_iq_bytes_to_complex64(codes.tobytes())
|
||||
|
||||
metrics = comparison.background_metrics(samples, codes, 2_400_000.0)
|
||||
|
||||
assert metrics["i_code_distribution"] == {"-2": 3, "-1": 1}
|
||||
assert metrics["q_code_distribution"] == {"3": 1, "4": 3}
|
||||
assert metrics["mean_i"] == pytest.approx(-1.75 / 128.0)
|
||||
assert metrics["mean_q"] == pytest.approx(3.75 / 128.0)
|
||||
assert metrics["dc_correction_applied"] is False
|
||||
|
||||
|
||||
def test_known_counter_analysis_recovers_shifted_synthetic_without_dc_correction(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sample_rate = 240_000.0
|
||||
symbol_count = 1_000
|
||||
samples_per_symbol = 120
|
||||
carrier_hz = 50_200.0
|
||||
expected_start = 80_037
|
||||
monkeypatch.setattr(counter, "TRANSMITTED_SYMBOL_COUNT", symbol_count)
|
||||
monkeypatch.setattr(comparison, "MATCH_REFERENCE_START_SYMBOL", 100)
|
||||
monkeypatch.setattr(comparison, "MATCH_REFERENCE_SYMBOL_COUNT", 300)
|
||||
symbols = counter.counter_qpsk_symbols(0, symbol_count)
|
||||
base = np.repeat(symbols, samples_per_symbol)
|
||||
indexes = np.arange(len(base), dtype=np.float64)
|
||||
signal = (0.02 * base * np.exp(2j * np.pi * carrier_hz * indexes / sample_rate)).astype(
|
||||
np.complex64
|
||||
)
|
||||
raw = np.full(320_000, 0.01 + 0.02j, dtype=np.complex64)
|
||||
raw[expected_start : expected_start + len(signal)] += signal
|
||||
|
||||
result = comparison.analyze_counter_section(
|
||||
raw,
|
||||
expected_start + 40,
|
||||
carrier_hz,
|
||||
sample_rate,
|
||||
background_power=0.01**2 + 0.02**2,
|
||||
)
|
||||
|
||||
assert result["start_sample"] == expected_start
|
||||
assert result["measured_carrier_hz"] == pytest.approx(carrier_hz, abs=0.1)
|
||||
assert result["symbol_error_rate"] == 0.0
|
||||
assert result["dc_correction_applied"] is False
|
||||
assert result["new_filter_applied"] is False
|
||||
assert result["equalizer_applied"] is False
|
||||
|
||||
|
||||
def test_hardware_runner_contains_one_tx_call_and_no_forbidden_experiment_calls() -> None:
|
||||
tree = ast.parse(inspect.getsource(comparison.run_one_zero_if_comparison))
|
||||
calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)]
|
||||
attributes = [node.func.attr for node in calls if isinstance(node.func, ast.Attribute)]
|
||||
|
||||
assert attributes.count("transmit_noncyclic_buffer_once") == 1
|
||||
forbidden = {
|
||||
"run_one_hackrf_s",
|
||||
"run_one_hardware_counter",
|
||||
"run_one_hardware_prbs11",
|
||||
"run_packet",
|
||||
"run_jpeg",
|
||||
}
|
||||
assert forbidden.isdisjoint(attributes)
|
||||
|
||||
|
||||
def test_hardware_orchestration_transmits_once_and_saves_both_raw_before_analysis(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
transmitted: list[np.ndarray] = []
|
||||
|
||||
class FakeLibrary:
|
||||
@staticmethod
|
||||
def hackrf_is_streaming(_device: object) -> int:
|
||||
return 1
|
||||
|
||||
class FakeReceiver:
|
||||
library = FakeLibrary()
|
||||
device = object()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_arguments: object) -> None:
|
||||
return None
|
||||
|
||||
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:
|
||||
assert (lna_gain_db, vga_gain_db) == (8, 8)
|
||||
return {
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"lna_gain_db": lna_gain_db,
|
||||
"vga_gain_db": vga_gain_db,
|
||||
}
|
||||
|
||||
def capture(self, target_sample_count: int, timeout_seconds: float):
|
||||
events.append("capture")
|
||||
assert timeout_seconds == comparison.RX_TIMEOUT_SECONDS
|
||||
raw = bytes(2 * target_sample_count)
|
||||
return SimpleNamespace(
|
||||
samples=hackrf.signed_iq_bytes_to_complex64(raw),
|
||||
raw_bytes=raw,
|
||||
boundaries=(),
|
||||
diagnostics={
|
||||
"callback_count_total": 1,
|
||||
"stream_started_monotonic": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(comparison, "RX_LEADING_SECONDS", 0.0)
|
||||
monkeypatch.setattr(comparison, "RX_TRAILING_SECONDS", 0.0)
|
||||
monkeypatch.setattr(comparison, "TX_UPLOAD_MARGIN_SECONDS", 0.0)
|
||||
monkeypatch.setattr(hackrf, "HACKRF_EXPECTED_CALLBACK_BYTES", 0)
|
||||
monkeypatch.setattr(hackrf, "HackrfRxDevice", FakeReceiver)
|
||||
monkeypatch.setattr(
|
||||
hackrf,
|
||||
"preflight_hackrf",
|
||||
lambda lna, vga: {
|
||||
"set_center_frequency_hz": 435_000_000,
|
||||
"set_sample_rate_hz": 2_400_000.0,
|
||||
"rf_amp_enabled": False,
|
||||
"lna_gain_db": lna,
|
||||
"vga_gain_db": vga,
|
||||
"antenna_bias_enabled": False,
|
||||
"serial_number": "serial",
|
||||
"firmware_version": "firmware",
|
||||
"libhackrf_version": "library",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(pluto_hardware, "discover_pluto_usb_uri", lambda: "usb:1.2.3")
|
||||
monkeypatch.setattr(
|
||||
pluto_hardware,
|
||||
"preflight_pluto",
|
||||
lambda uri: {"uri": uri, "context_opened": True},
|
||||
)
|
||||
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,
|
||||
"actual_rf_bandwidth_hz": 200_000,
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
comparison,
|
||||
"build_composite_waveform",
|
||||
lambda _sample_rate: (
|
||||
np.asarray([0.1 + 0.1j] * 4, dtype=np.complex64),
|
||||
{"total_duration_seconds": 4 / 2_400_000.0, "sections": [], "total_sample_count": 4},
|
||||
),
|
||||
)
|
||||
|
||||
def fake_transmit(_device: object, samples: np.ndarray, _sample_rate: float) -> float:
|
||||
events.append("transmit")
|
||||
transmitted.append(np.asarray(samples).copy())
|
||||
return 0.0
|
||||
|
||||
monkeypatch.setattr(pluto_hardware, "transmit_noncyclic_buffer_once", fake_transmit)
|
||||
real_save = comparison.save_dual_raw_before_processing
|
||||
|
||||
def save_raw(*arguments: object, **keyword_arguments: object):
|
||||
events.append("save_raw")
|
||||
return real_save(*arguments, **keyword_arguments)
|
||||
|
||||
monkeypatch.setattr(comparison, "save_dual_raw_before_processing", save_raw)
|
||||
|
||||
def analyze(*_arguments: object) -> dict:
|
||||
events.append("analyze")
|
||||
return {
|
||||
"counter_plus_50khz": {},
|
||||
"counter_zero_if": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(comparison, "analyze_saved_capture", analyze)
|
||||
monkeypatch.setattr(comparison, "save_processed_artifacts", lambda *_arguments: ())
|
||||
|
||||
result = comparison.run_one_zero_if_comparison(tmp_path, tmp_path / "processed")
|
||||
|
||||
assert result["processing_status"] == "success"
|
||||
assert events.count("transmit") == 1
|
||||
assert len(transmitted) == 1
|
||||
assert events.index("save_raw") < events.index("analyze")
|
||||
assert Path(result["int8_path"]).suffix == ".int8"
|
||||
assert Path(result["iq_path"]).suffix == ".npy"
|
||||
|
||||
|
||||
def test_rx_failure_before_stream_confirmation_prevents_tx(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
transmit_calls = 0
|
||||
|
||||
class FailedLibrary:
|
||||
@staticmethod
|
||||
def hackrf_is_streaming(_device: object) -> int:
|
||||
return 0
|
||||
|
||||
class FailedReceiver:
|
||||
library = FailedLibrary()
|
||||
device = object()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_arguments: object) -> None:
|
||||
return None
|
||||
|
||||
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:
|
||||
return {
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"lna_gain_db": lna_gain_db,
|
||||
"vga_gain_db": vga_gain_db,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def capture(_target_sample_count: int, timeout_seconds: float):
|
||||
raise RuntimeError(f"synthetic RX failure before streaming ({timeout_seconds})")
|
||||
|
||||
monkeypatch.setattr(hackrf, "HackrfRxDevice", FailedReceiver)
|
||||
monkeypatch.setattr(
|
||||
hackrf,
|
||||
"preflight_hackrf",
|
||||
lambda lna, vga: {
|
||||
"set_center_frequency_hz": 435_000_000,
|
||||
"set_sample_rate_hz": 2_400_000.0,
|
||||
"rf_amp_enabled": False,
|
||||
"lna_gain_db": lna,
|
||||
"vga_gain_db": vga,
|
||||
"antenna_bias_enabled": False,
|
||||
"serial_number": "serial",
|
||||
"firmware_version": "firmware",
|
||||
"libhackrf_version": "library",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(pluto_hardware, "discover_pluto_usb_uri", lambda: "usb:1.2.3")
|
||||
monkeypatch.setattr(
|
||||
pluto_hardware,
|
||||
"preflight_pluto",
|
||||
lambda uri: {"uri": uri, "context_opened": True},
|
||||
)
|
||||
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,
|
||||
"actual_rf_bandwidth_hz": 200_000,
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
comparison,
|
||||
"build_composite_waveform",
|
||||
lambda _sample_rate: (
|
||||
np.asarray([0.1 + 0.1j], dtype=np.complex64),
|
||||
{"total_duration_seconds": 1 / 2_400_000.0},
|
||||
),
|
||||
)
|
||||
|
||||
def forbidden_transmit(*_arguments: object) -> float:
|
||||
nonlocal transmit_calls
|
||||
transmit_calls += 1
|
||||
return 0.0
|
||||
|
||||
monkeypatch.setattr(
|
||||
pluto_hardware,
|
||||
"transmit_noncyclic_buffer_once",
|
||||
forbidden_transmit,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="до TX; Pluto TX не включён"):
|
||||
comparison.run_one_zero_if_comparison(tmp_path, tmp_path / "processed")
|
||||
|
||||
assert transmit_calls == 0
|
||||
Reference in New Issue
Block a user