Lab053: achieve real-time 5 fps software video receiver
This commit is contained in:
213
experiments/lab053_packet_processing.py
Normal file
213
experiments/lab053_packet_processing.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user