Files
SDR-Rover/experiments/lab053_packet_processing.py
2026-09-14 11:07:40 +03:00

290 lines
13 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
from protocol import bpsk_carrier_tracking as carrier_tracking
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, sample_rate_hz: int = SAMPLE_RATE) -> np.ndarray:
indexes = np.arange(length)
values = np.exp(
-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / sample_rate_hz
).astype(np.complex64)
values.flags.writeable = False
return values
class PacketProcessor:
"""Параметры фиксированы; состояние кандидата принадлежит ContextVar.
Уточнение использует прежние ±2000 отсчётов, timing — все 1601 смещение.
Полное тело по-прежнему запрашивается только после валидного PHY header.
"""
def __init__(
self,
*,
viterbi=viterbi_decode_soft,
carrier_tracking_mode: str = "legacy",
sample_rate_hz: int = SAMPLE_RATE,
symbol_rate: int = SYMBOL_RATE,
):
if carrier_tracking_mode not in ("legacy", "t2"):
raise ValueError("carrier_tracking_mode должен быть legacy или t2")
if type(sample_rate_hz) is not int or type(symbol_rate) is not int:
raise TypeError("Fs и Rs должны быть целыми")
if sample_rate_hz <= 0 or symbol_rate <= 0 or sample_rate_hz % symbol_rate:
raise ValueError("Fs должен быть положительным целым кратным Rs")
self.carrier_tracking_mode = carrier_tracking_mode
self.sample_rate_hz = sample_rate_hz
self.symbol_rate = symbol_rate
self.samples_per_symbol = sample_rate_hz // symbol_rate
self.startup = warmup() if viterbi is viterbi_decode_soft else {"backend": "CUSTOM", "warmup_seconds": 0}
constants = {
"SAMPLES_PER_SYMBOL": self.samples_per_symbol,
"SYMBOL_RATE": self.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) * self.samples_per_symbol + 1, np.complex64
)
self.kernel[::self.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=self.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):
if self.carrier_tracking_mode == "legacy":
dc_reference = segment
else:
# Последний raw-отсчёт, способный попасть через причинный RRC в
# последнюю P3-точку преамбулы. Header в оценку DC не попадает.
fixed_prefix = (
16 * self.samples_per_symbol
+ self.taps.size
+ (carrier_tracking.PREAMBLE_SYMBOLS - 1) * self.samples_per_symbol
)
dc_reference = segment[:min(len(segment), fixed_prefix)]
return (segment - np.mean(dc_reference)) * rotation(
len(segment), self.sample_rate_hz
)
@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 * self.samples_per_symbol:
self.samples_per_symbol
].astype(np.complex128)
if symbols.size != count:
return None
if self.carrier_tracking_mode == "legacy":
symbol_time = (
np.arange(count, dtype=np.float64) + 0.5
) / self.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
if count < carrier_tracking.PREAMBLE_SYMBOLS:
return None
estimate = carrier_tracking.estimate_p3(
symbols[:carrier_tracking.PREAMBLE_SYMBOLS],
self.expected,
self.symbol_rate,
)
carrier.update({
"cfo_hz": estimate.frequency_hz,
"abs_h": float(abs(estimate.gain)),
"phase_rad": float(np.angle(estimate.gain)),
"polarity": "resolved from P3 full preamble",
})
result = carrier_tracking.track_t2(
symbols,
self.symbol_rate,
estimate,
start_index=carrier_tracking.PREAMBLE_SYMBOLS,
)
context = CURRENT.get()
if context is not None:
context.cache.setdefault("carrier_tracking_traces", []).append(result)
return result.corrected.real
@measured("extract_total")
def extract(self, samples, frame_start, symbol_count):
length = (symbol_count + 42) * self.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 * self.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) % self.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,
"carrier_tracking_mode": self.carrier_tracking_mode,
"carrier_tracking_traces": tuple(
context.cache.get("carrier_tracking_traces", ())
)}
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 processor.carrier_tracking_mode == "legacy"
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()