290 lines
13 KiB
Python
290 lines
13 KiB
Python
"""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()
|