"""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()