Files
SDR-Rover/experiments/lab053_packet_processing.py

214 lines
10 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.
"""Lab053: локальные вычисления настоящего пакета с неизменным PHY.
Старые функции получают частный словарь зависимостей: их Python-код не
переписывается и globals старых лабораторий не изменяются. Это позволяет
одновременно декодировать пакеты с одним набором неизменяемых констант.
"""
from __future__ import annotations
from collections import Counter
from contextvars import ContextVar
from dataclasses import dataclass, field
from functools import lru_cache
import math
from pathlib import Path
import sys
import time
from types import FunctionType, SimpleNamespace
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from scipy.signal import oaconvolve
from experiments import lab052_candidate_detection as detection
from experiments.lab053_viterbi import viterbi_decode_soft, warmup
previous = detection.previous
physical = previous.lab045
lab043 = previous.lab043
SAMPLE_RATE = 8000000
SYMBOL_RATE = 500000
SAMPLES_PER_SYMBOL = 16
def bind(function, **dependencies):
"""Связать тот же code object с частными константами и зависимостями.
Без exec/eval и setattr модулей. Неподменённые protocol/parser helpers
остаются штатными. Копируется словарь ссылок, а не массивы или IQ.
"""
function = getattr(function, "__wrapped__", function)
namespace = dict(function.__globals__, **dependencies)
result = FunctionType(function.__code__, namespace, function.__name__, function.__defaults__, function.__closure__)
result.__kwdefaults__ = function.__kwdefaults__
return result
@dataclass
class PacketMeasure:
seconds: Counter = field(default_factory=Counter)
exclusive_seconds: Counter = field(default_factory=Counter)
calls: Counter = field(default_factory=Counter)
stack: list = field(default_factory=list)
cache: dict = field(default_factory=dict)
CURRENT: ContextVar[PacketMeasure | None] = ContextVar("lab053_packet_measure", default=None)
def measured(name):
def decorate(function):
def call(*args, **kwargs):
context = CURRENT.get()
if context is None:
return function(*args, **kwargs)
entry = [0.0]
context.stack.append(entry)
started = time.perf_counter()
try:
return function(*args, **kwargs)
finally:
elapsed = time.perf_counter() - started
context.stack.pop()
if context.stack:
context.stack[-1][0] += elapsed
context.seconds[name] += elapsed
context.exclusive_seconds[name] += elapsed - entry[0]
context.calls[name] += 1
return call
return decorate
@lru_cache(maxsize=16)
def rotation(length: int) -> np.ndarray:
indexes = np.arange(length)
values = np.exp(-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE).astype(np.complex64)
values.flags.writeable = False
return values
class PacketProcessor:
"""Параметры фиксированы; состояние кандидата принадлежит ContextVar.
Уточнение использует прежние ±2000 отсчётов, timing — все 1601 смещение.
Полное тело по-прежнему запрашивается только после валидного PHY header.
"""
def __init__(self, *, viterbi=viterbi_decode_soft):
self.startup = warmup() if viterbi is viterbi_decode_soft else {"backend": "CUSTOM", "warmup_seconds": 0}
constants = {"SAMPLES_PER_SYMBOL": SAMPLES_PER_SYMBOL, "SYMBOL_RATE": SYMBOL_RATE}
scale = bind(physical.lab044._scale_and_carrier, **constants)
self.bits = physical.lab044.preamble_bits().copy()
waveform = scale(previous.iq.radio.bpsk_modulate(self.bits))
self.reference = physical.lab044.signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
self.taps = bind(lab043._matched_filter_taps, **constants)()
self.expected = previous.iq.radio.bpsk_modulate(self.bits)
self.kernel = np.zeros((len(self.bits) - 1) * SAMPLES_PER_SYMBOL + 1, np.complex64)
self.kernel[::SAMPLES_PER_SYMBOL] = np.conj(self.expected)
self.kernel = self.kernel[::-1].copy()
for values in (self.bits, self.reference, self.taps, self.expected, self.kernel):
values.flags.writeable = False
slow_offsets = bind(lab043._timing_offsets, **constants)
slow_timing = bind(previous.ORIGINAL_TIMING, _timing_offsets=slow_offsets, **constants)
self.timing = measured("timing")(bind(previous.fast_preamble_timing,
lab045=SimpleNamespace(SAMPLES_PER_SYMBOL=SAMPLES_PER_SYMBOL),
timing_kernel=lambda unused: self.kernel, ORIGINAL_TIMING=slow_timing))
estimate = measured("cfo_estimation")(bind(lab043._estimate_cfo_gain, **constants))
self.carrier = measured("phase_polarity")(bind(lab043._evaluate_symbols,
_estimate_cfo_gain=estimate, **constants))
self.refine = measured("candidate_refinement")(bind(previous.ORIGINAL_REFINE, **constants))
header = measured("phy_header")(physical.decode_phy_header_soft)
body = bind(physical.analyze_post_preamble_soft, decode_phy_header_soft=header,
conv=SimpleNamespace(encoded_bit_count=physical.conv.encoded_bit_count,
viterbi_decode_soft=measured("viterbi")(viterbi)),
parse_packet=measured("protocol_crc")(physical.parse_packet))
physical_namespace = SimpleNamespace(**dict(vars(physical), **constants))
physical_namespace.decode_phy_header_soft = header
physical_namespace.analyze_post_preamble_soft = measured("body_processing")(body)
preamble_namespace = SimpleNamespace(_preamble_reference_samples=lambda: self.reference,
preamble_bits=lambda: self.bits,
lab043=SimpleNamespace(_refine_frame_start=self.refine))
self.strict = bind(previous.offline.decode_candidate_from_memmap,
lab046c=SimpleNamespace(lab044=preamble_namespace, lab045=physical_namespace),
lab046b=SimpleNamespace(_extract_soft_window=self.extract,
IqFrameReception=previous.iq.IqFrameReception))
@measured("iq_window_preparation")
def read_window(self, raw, low, high):
return raw[2 * low:2 * high].view(np.complex64)
@measured("frequency_shift")
def shift(self, segment):
return (segment - np.mean(segment)) * rotation(len(segment))
@measured("matched_rrc_filtering")
def filter(self, values):
return oaconvolve(values, self.taps, mode="full")
@measured("soft_symbol_extraction")
def normalize(self, matched, nominal, timing, count, carrier):
symbols = matched[nominal + timing:nominal + timing + count * SAMPLES_PER_SYMBOL:SAMPLES_PER_SYMBOL].astype(np.complex128)
if symbols.size != count:
return None
symbol_time = (np.arange(count, dtype=np.float64) + .5) / SYMBOL_RATE
normalized = symbols * np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time)
normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
return normalized.real
@measured("extract_total")
def extract(self, samples, frame_start, symbol_count):
length = (symbol_count + 42) * SAMPLES_PER_SYMBOL
if frame_start < 0 or frame_start + length > samples.size:
return previous.iq.SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW")
segment = samples[frame_start:frame_start + length]
matched = self.filter(self.shift(segment))
nominal = 16 * SAMPLES_PER_SYMBOL + self.taps.size - 1
timing = self.timing(matched, nominal, self.expected)
carrier = self.carrier(matched, nominal, timing, self.bits, len(self.bits))
if carrier["abs_h"] == 0:
return previous.iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "ZERO_GAIN")
values = self.normalize(matched, nominal, timing, symbol_count, carrier)
if values is None:
return previous.iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS")
return previous.iq.SoftWindow(True, values, int(timing),
int((frame_start + nominal + timing) % SAMPLES_PER_SYMBOL), nominal, carrier, None)
def decode(self, samples, candidate, number=1):
context = PacketMeasure()
token = CURRENT.set(context)
started = time.perf_counter()
cpu_started = time.thread_time()
try:
outcome = self.strict(samples.view(np.float32), samples.size, candidate, number,
read_iq_window=self.read_window)
elapsed = time.perf_counter() - started
row = {"accepted": outcome.accepted, "reason": outcome.reason, "seconds": elapsed,
"thread_cpu_seconds": time.thread_time() - cpu_started,
"stages": dict(context.seconds), "exclusive_stages": dict(context.exclusive_seconds),
"calls": dict(context.calls), "coarse": candidate.sample_offset,
"refined": outcome.refined_start_sample}
if outcome.frame is not None:
row.update(timing=outcome.frame.timing_offset_samples, cfo_hz=outcome.frame.estimated_cfo_hz,
phase_rad=outcome.frame.estimated_phase_rad, polarity=outcome.frame.bpsk_polarity)
return outcome, row
finally:
CURRENT.reset(token)
def run_functional_tests() -> None:
processor = PacketProcessor()
assert len(processor.bits) == 1280 and len(processor.taps) == 161
assert not processor.reference.flags.writeable
def main() -> None:
run_functional_tests()
print("Lab053 packet processing functional checks PASS; RF TX=0")
if __name__ == "__main__":
main()