Lab048: accelerate BPSK receiver to real-time

This commit is contained in:
LittleSam129
2026-09-03 14:42:02 +03:00
parent 6fb7e4ec54
commit ac6cc792b8
11 changed files with 1047 additions and 9 deletions

View File

@@ -25,6 +25,7 @@ BPSK (Binary Phase Shift Keying) — двоичная фазовая манип
from __future__ import annotations
import argparse
from functools import lru_cache
import hashlib
import json
import math
@@ -41,7 +42,7 @@ if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from scipy.signal import fftconvolve, resample_poly
from scipy.signal import fftconvolve, oaconvolve, resample_poly
from experiments.lab043_hackrf_hardware import (
signed_iq_bytes_to_complex64,
@@ -362,14 +363,36 @@ def _refine_frame_start(samples: np.ndarray, tx_samples: np.ndarray, coarse_star
return low + int(np.argmax(np.abs(correlation)))
@lru_cache(maxsize=1)
def _matched_filter_taps() -> np.ndarray:
"""Вернуть неизменяемые RRC-коэффициенты доказанной рабочей точки."""
taps = radio.root_raised_cosine_taps(
0.35,
SAMPLES_PER_SYMBOL,
10,
).astype(np.float32)
taps.flags.writeable = False
return taps
@lru_cache(maxsize=4)
def _carrier_downconversion_vector(start: int, length: int) -> np.ndarray:
"""Один раз вычислить неизменяемый перенос несущей для размера окна."""
indexes = start + np.arange(length)
rotation = np.exp(
-2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ
).astype(np.complex64)
rotation.flags.writeable = False
return rotation
def _matched_waveform(samples: np.ndarray, start: int, tx_length: int) -> tuple[np.ndarray, int]:
segment = samples[start : start + tx_length] - np.mean(samples)
indexes = start + np.arange(len(segment))
baseband = segment * np.exp(
-2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ
)
taps = radio.root_raised_cosine_taps(0.35, SAMPLES_PER_SYMBOL, 10)
matched = fftconvolve(baseband, taps, mode="full")
baseband = segment * _carrier_downconversion_vector(start, len(segment))
taps = _matched_filter_taps()
matched = oaconvolve(baseband, taps, mode="full")
nominal = 16 * SAMPLES_PER_SYMBOL + 2 * ((len(taps) - 1) // 2)
return matched, nominal
@@ -400,6 +423,34 @@ def _timing_offsets(matched: np.ndarray, nominal: int, expected_symbols: np.ndar
return preamble_offset, oracle_offset
def _preamble_timing_offset(
matched: np.ndarray,
nominal: int,
expected_symbols: np.ndarray,
) -> int:
"""Найти то же преамбульное смещение без второй одинаковой оценки."""
expected = np.asarray(expected_symbols)
offsets = np.arange(-800, 801)
scores = np.zeros(offsets.size, dtype=np.float64)
first_sample = nominal + int(offsets[0])
for phase in range(SAMPLES_PER_SYMBOL):
score_indexes = np.arange(phase, offsets.size, SAMPLES_PER_SYMBOL)
sequence_length = expected.size + score_indexes.size - 1
start = first_sample + phase
stop = start + sequence_length * SAMPLES_PER_SYMBOL
sequence = matched[start:stop:SAMPLES_PER_SYMBOL]
if start < 0 or sequence.size != sequence_length:
return _timing_offsets(matched, nominal, expected)[0]
windows = np.lib.stride_tricks.sliding_window_view(sequence, expected.size)
correlation = windows @ np.conj(expected)
energy = np.sum(np.abs(windows) ** 2, axis=1)
scores[score_indexes] = np.abs(correlation) / np.sqrt(
expected.size * energy + 1e-30
)
return int(offsets[int(np.argmax(scores))])
def _estimate_cfo_gain(
symbols: np.ndarray,
expected_symbols: np.ndarray,

View File

@@ -8,6 +8,7 @@
from __future__ import annotations
import argparse
from functools import lru_cache
import hashlib
import json
import math
@@ -253,9 +254,14 @@ def validate_transmission_plan(plan: TransmissionPlan) -> None:
# ============================================================ Независимый RX
@lru_cache(maxsize=1)
def _preamble_reference_samples() -> np.ndarray:
"""Вернуть неизменяемую опорную преамбулу без повторного формирования."""
waveform = _scale_and_carrier(radio.bpsk_modulate(preamble_bits()))
return signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
reference = signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
reference.flags.writeable = False
return reference
def _detect_preamble(samples: np.ndarray) -> dict:

View File

@@ -357,7 +357,7 @@ def _extract_soft_window(
)
preamble_bits = lab045.lab044.preamble_bits()
preamble_symbols = radio.bpsk_modulate(preamble_bits)
timing, _ = lab045.lab044.lab043._timing_offsets(
timing = lab045.lab044.lab043._preamble_timing_offset(
matched,
nominal,
preamble_symbols,

View File

@@ -0,0 +1,319 @@
"""Lab048: измерение ускоренного автономного BPSK-приёмника без RF.
Единственный основной режим обрабатывает сохранённую RX-запись Lab047 с уже
зафиксированным порогом. Независимый приёмник не получает данные передатчика.
JPEG сравнивается с TX только после завершённого независимого вердикта.
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
from dataclasses import asdict, dataclass
from functools import wraps
import json
import math
from pathlib import Path
import sys
import time
from typing import Callable, Iterator
import numpy as np
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from experiments import lab043_hackrf_bpsk as lab043
from experiments import lab045_hackrf_bpsk_fec as lab045
from experiments import lab046c_offline_recovery as offline
from experiments import lab047_jpeg_object_transport as lab047
OUTPUT_DIRECTORY = Path("data/processed/lab048")
ATTEMPT_NAME = "final_processing_attempt.json"
RESULT_NAME = "result.json"
RECOVERED_IMAGE_NAME = "recovered_image.jpg"
BASELINE_CAPTURE_SECONDS = 15.0
BASELINE_FULL_SECONDS = 37.423910000012256
BASELINE_STRICT_SECONDS = 32.45996900013415
BASELINE_AVERAGE_STRICT_SECONDS = 0.47043433333527757
BASELINE_APPROXIMATE_PEAK_BYTES = 1_545_755_352
FIXED_THRESHOLD = lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD
@dataclass
class StageMeasurement:
calls: int = 0
cumulative_seconds: float = 0.0
def _timed_wrapper(
function: Callable,
measurement: StageMeasurement,
) -> Callable:
@wraps(function)
def wrapped(*args: object, **kwargs: object) -> object:
started = time.perf_counter()
try:
return function(*args, **kwargs)
finally:
measurement.calls += 1
measurement.cumulative_seconds += time.perf_counter() - started
return wrapped
@contextmanager
def measure_receiver_stages() -> Iterator[dict[str, StageMeasurement]]:
"""Временно измерить крупные стадии, не изменяя их входы и результаты."""
measurements = {
"cs8_to_complex64": StageMeasurement(),
"refine_start": StageMeasurement(),
"matched_filter": StageMeasurement(),
"timing": StageMeasurement(),
"cfo_phase_soft": StageMeasurement(),
"phy_header": StageMeasurement(),
"post_preamble_fec_crc": StageMeasurement(),
"viterbi": StageMeasurement(),
}
patches = (
(offline.lab046c, "_cs8_window", "cs8_to_complex64"),
(lab043, "_refine_frame_start", "refine_start"),
(lab043, "_matched_waveform", "matched_filter"),
(lab043, "_preamble_timing_offset", "timing"),
(lab043, "_evaluate_symbols", "cfo_phase_soft"),
(lab045, "decode_phy_header_soft", "phy_header"),
(lab045, "analyze_post_preamble_soft", "post_preamble_fec_crc"),
(lab045.conv, "viterbi_decode_soft", "viterbi"),
)
originals: list[tuple[object, str, Callable]] = []
for owner, name, measurement_name in patches:
original = getattr(owner, name)
originals.append((owner, name, original))
setattr(owner, name, _timed_wrapper(original, measurements[measurement_name]))
try:
yield measurements
finally:
for owner, name, original in reversed(originals):
setattr(owner, name, original)
def run_profiled_receiver(
capture_path: Path,
original_result_path: Path,
) -> tuple[offline.IndependentRecoveryResult, dict[str, StageMeasurement]]:
"""Обработать RX без TX-данных и измерить крупные стадии."""
rule, calibration, control = lab047.load_fixed_hardware_candidate_rule(
capture_path,
original_result_path,
)
if rule.threshold != FIXED_THRESHOLD:
raise RuntimeError("Порог Lab047 был изменён")
with measure_receiver_stages() as measurements:
started = time.perf_counter()
independent = offline._run_with_fixed_candidate_rule(
capture_path,
rule,
calibration,
control,
full_started=started,
)
return independent, measurements
def memory_array_inventory(
result: offline.IndependentRecoveryResult,
) -> list[dict]:
"""Описать крупнейшие явные массивы и консервативную оценку пика."""
coarse_samples = result.coarse.maximum_window_samples
decimation = offline.lab046c.lab044.lab043.DETECTION_DECIMATION
decimated_samples = math.ceil(coarse_samples / decimation)
reference_samples = offline.lab046c.lab044._preamble_reference_samples().size
reference_decimated = math.ceil(reference_samples / decimation)
score_samples = max(0, decimated_samples - reference_decimated + 1)
strict_samples = result.strict.maximum_local_window_samples
matched_samples = strict_samples + lab043._matched_filter_taps().size - 1
definitions = (
("coarse_iq_window", "complex64", coarse_samples),
("coarse_decimated", "complex64", decimated_samples),
("coarse_downconversion_vector", "complex64", decimated_samples),
("coarse_correlation_scores", "float32", score_samples),
("strict_full_iq_window", "complex64", strict_samples),
("strict_cached_carrier_vector", "complex64", strict_samples),
("strict_baseband", "complex64", strict_samples),
("strict_matched_output", "complex64", matched_samples),
("matched_filter_taps", "float32", lab043._matched_filter_taps().size),
)
inventory = []
for name, dtype, count in definitions:
size_bytes = int(count * np.dtype(dtype).itemsize)
inventory.append(
{
"name": name,
"dtype": dtype,
"shape": [int(count)],
"bytes": size_bytes,
"mib": size_bytes / (1024**2),
}
)
return inventory
def result_summary(
independent: offline.IndependentRecoveryResult,
measurements: dict[str, StageMeasurement],
diagnostics: dict,
) -> dict:
"""Собрать машинно-читаемый итог после разрешённой TX-диагностики."""
return {
"stage": "LAB048_OFFLINE_RECEIVER_PERFORMANCE",
"baseline": {
"capture_seconds": BASELINE_CAPTURE_SECONDS,
"full_processing_seconds": BASELINE_FULL_SECONDS,
"strict_stage_seconds": BASELINE_STRICT_SECONDS,
"average_strict_candidate_seconds": BASELINE_AVERAGE_STRICT_SECONDS,
"approximate_peak_bytes": BASELINE_APPROXIMATE_PEAK_BYTES,
},
"optimized": {
"capture_seconds": independent.capture_duration_seconds,
"full_processing_seconds": independent.full_elapsed_seconds,
"coarse_search_seconds": independent.coarse.elapsed_seconds,
"strict_stage_seconds": independent.strict.elapsed_seconds,
"average_strict_candidate_seconds": independent.strict.average_candidate_seconds,
"approximate_peak_bytes": independent.coarse.estimated_maximum_temporary_bytes,
"stage_measurements": {
name: asdict(value) for name, value in measurements.items()
},
"memory_arrays": memory_array_inventory(independent),
},
"speedup": BASELINE_FULL_SECONDS / independent.full_elapsed_seconds,
"processing_to_capture_ratio": (
independent.full_elapsed_seconds / independent.capture_duration_seconds
),
"real_time_capable": (
independent.full_elapsed_seconds < independent.capture_duration_seconds
),
"threshold": independent.rule.threshold,
"threshold_changed": independent.rule.threshold != FIXED_THRESHOLD,
"coarse": {
"total_local_peaks": independent.coarse.total_local_peaks,
"first_stage_candidates": independent.coarse.candidate_count,
"skipped_as_owned_event": (
independent.coarse.candidates_skipped_as_same_local_event
),
"exact_duplicates": independent.coarse.exact_duplicate_candidates_skipped,
},
"strict": asdict(independent.strict.counters),
"object": {
"valid_fragments": independent.object_verdict.valid_fragments,
"missing_fragments": independent.object_verdict.missing_fragments,
"object_complete": independent.object_verdict.object_complete,
},
"after_independent_verdict": diagnostics,
"receiver_oracles": lab047.receiver_oracle_declaration(),
"phy_changed": False,
"fec_changed": False,
"bpsk_changed": False,
"rf_tx": 0,
}
def run_and_write(
capture_path: Path,
original_result_path: Path,
tx_image_path: Path,
output_directory: Path,
) -> dict:
"""Однократно выполнить итоговый проход и после вердикта сравнить JPEG."""
output_directory = Path(output_directory)
output_directory.mkdir(parents=True, exist_ok=True)
attempt_path = output_directory / ATTEMPT_NAME
result_path = output_directory / RESULT_NAME
recovered_path = output_directory / RECOVERED_IMAGE_NAME
if attempt_path.exists() or result_path.exists() or recovered_path.exists():
raise FileExistsError("Итоговый проход Lab048 уже запускался")
rule, _, _ = lab047.load_fixed_hardware_candidate_rule(
capture_path,
original_result_path,
)
attempt_path.write_text(
json.dumps(
{
"capture_path": str(Path(capture_path)),
"original_result_path": str(Path(original_result_path)),
"threshold": rule.threshold,
"automatic_retry": False,
"receiver_oracles": lab047.receiver_oracle_declaration(),
"rf_tx": 0,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
independent, measurements = run_profiled_receiver(
capture_path,
original_result_path,
)
if not independent.completed or not independent.object_verdict.object_complete:
raise RuntimeError("Итоговый независимый проход Lab048 не завершён")
recovered = independent.object_verdict.recovered_object
if recovered is None:
raise RuntimeError("OBJECT COMPLETE без восстановленного JPEG")
recovered_path.write_bytes(recovered)
diagnostics = lab047.diagnose_hardware_jpeg_after_independent_verdict(
independent,
tx_image_path,
recovered_path,
)
summary = result_summary(independent, measurements, diagnostics)
result_path.write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return summary
def run_functional_tests() -> None:
assert BASELINE_FULL_SECONDS > BASELINE_CAPTURE_SECONDS
assert BASELINE_STRICT_SECONDS < BASELINE_FULL_SECONDS
assert BASELINE_APPROXIMATE_PEAK_BYTES > 1024**3
assert FIXED_THRESHOLD == 0.030964473262429237
assert not any(lab047.receiver_oracle_declaration().values())
def main() -> int:
parser = argparse.ArgumentParser(description="Lab048 offline receiver performance")
parser.add_argument("--run-saved-capture", action="store_true")
parser.add_argument("--capture", type=Path)
parser.add_argument("--original-result", type=Path)
parser.add_argument("--tx-image", type=Path)
parser.add_argument("--output-directory", type=Path, default=OUTPUT_DIRECTORY)
arguments = parser.parse_args()
if arguments.run_saved_capture:
if arguments.capture is None or arguments.original_result is None:
parser.error("Требуются --capture и --original-result")
if arguments.tx_image is None:
parser.error("TX reference требуется только для послерезультатной диагностики")
summary = run_and_write(
arguments.capture,
arguments.original_result,
arguments.tx_image,
arguments.output_directory,
)
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
run_functional_tests()
print("Lab048 functional tests: PASS; RF TX = 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())