Lab050: model fresh-frame SDR video stream

This commit is contained in:
LittleSam129
2026-09-08 14:15:36 +03:00
parent cdf5396e80
commit 60691a09f1
9 changed files with 1769 additions and 12 deletions

View File

@@ -0,0 +1,163 @@
"""Lab050: независимый приём IQ-блоков с ограниченной памятью, без RF.
Использует корреляцию и строгий PHY/FEC/CRC Lab048. Принятые пакеты сразу
возвращаются вызывающему коду; история IQ/пакетов внутри не накапливается.
Адаптер констант старых лабораторий рассчитан на последовательный вызов.
"""
from __future__ import annotations
from collections import Counter
from contextlib import contextmanager
from dataclasses import dataclass
import math
import time
import numpy as np
from experiments import lab046c_offline_recovery as offline
from experiments import lab049_bpsk_symbol_rate as phy
from experiments.lab049_independent_iq import receiver_mode
@contextmanager
def streaming_mode(mode: phy.PhyMode):
"""Оставить не менее двух выборок/символ в грубом поиске."""
module = phy.lab045.lab044.lab043
original = module.DETECTION_DECIMATION
with receiver_mode(mode):
module.DETECTION_DECIMATION = min(20, mode.samples_per_symbol // 2)
try:
yield
finally:
module.DETECTION_DECIMATION = original
@dataclass(frozen=True)
class ReceivedPacket:
start_sample: int
end_sample: int
emitted_after_sample: int
payload: bytes
class StreamingReceiver:
"""push_iq_block выдаёт пакеты до конца потока; finish только опустошает хвост.
Размер кадра для удержания IQ определяется максимальной допустимой PHY
length (1024 байта), а не TX-границами/числом ожидаемых пакетов.
Порог Lab047 остаётся фиксированным. Источник и размеры объекта неизвестны.
"""
def __init__(self, symbol_rate: int, *, block_samples: int = 65_536):
self.mode = phy.PhyMode(symbol_rate)
if self.mode.samples_per_symbol < 4:
raise ValueError("Для этого адаптера нужно не менее четырёх выборок/символ")
if type(block_samples) is not int or block_samples < 1024:
raise ValueError("Размер блока должен быть целым и не меньше 1024")
self.block_samples = block_samples
self.decimation = min(20, self.mode.samples_per_symbol // 2)
self.core_samples = math.ceil(block_samples / self.decimation) * self.decimation
margin = offline.REFINEMENT_MARGIN_SAMPLES
self.margin = math.ceil(margin / self.decimation) * self.decimation
maximum_symbols = (phy.lab045.PREAMBLE_BIT_COUNT + phy.lab045.PHY_HEADER_BIT_COUNT
+ phy.lab045.conv.encoded_bit_count(phy.lab045.MAX_PACKET_BYTES * 8)
+ 2 * phy.lab045.GUARD_SYMBOL_COUNT + phy.lab045.RRC_SPAN_SYMBOLS)
self.lookahead = maximum_symbols * self.mode.samples_per_symbol + self.margin
self.buffer_limit_samples = self.core_samples + self.lookahead + self.margin + block_samples
self.buffer = np.empty(0, np.complex64)
self.base_sample = self.scan_sample = self.total_samples = self.accepted_until = 0
self.peak_buffer_samples = self.peak_candidate_count = 0
self.packet_count = self.candidate_count = self.blocks_processed = 0
self.processing_seconds = self.coarse_seconds = self.strict_seconds = 0.0
self.score_count = 0
self.score_sum = 0.0
self.rejected_reasons: Counter = Counter()
self.closed = False
with streaming_mode(self.mode):
self.reference = offline.lab046b._decimate_then_downconvert(
phy.lab045.lab044._preamble_reference_samples()).copy()
@property
def memory_estimate_bytes(self) -> int:
"""Консервативная оценка массивов, не измеренный RSS Python/SciPy."""
window_bytes = self.buffer_limit_samples * np.dtype(np.complex64).itemsize
# Два буфера при копировании + прежний коэффициент временных массивов
# Lab048 + запас для строгого matched filter и индексов кандидатов.
return 2 * window_bytes + 12 * window_bytes + 8 * window_bytes + self.reference.nbytes
def _scan(self, owned_end: int) -> list[ReceivedPacket]:
started = time.perf_counter()
scores = offline._correlation_scores(self.buffer, self.reference)
self.coarse_seconds += time.perf_counter() - started
indexes = offline._all_local_peak_indexes(scores)
offsets = self.base_sample + indexes * self.decimation
indexes = indexes[(offsets >= self.scan_sample) & (offsets < owned_end)]
self.score_count += len(indexes)
self.score_sum += float(np.sum(scores[indexes], dtype=np.float64))
indexes = indexes[scores[indexes] >= np.float32(phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD)]
self.peak_candidate_count = max(self.peak_candidate_count, len(indexes))
emitted = []
for index in indexes:
absolute = self.base_sample + int(index) * self.decimation
if absolute < self.accepted_until:
continue
self.candidate_count += 1
started = time.perf_counter()
outcome = offline.decode_candidate_from_memmap(
self.buffer.view(np.float32), self.buffer.size,
offline.CoarseCandidate(int(index) * self.decimation, float(scores[index])),
self.candidate_count,
read_iq_window=lambda raw, start, end: raw[2*start:2*end].view(np.complex64),
)
self.strict_seconds += time.perf_counter() - started
if outcome.accepted and outcome.crc == phy.lab045.CRC_PASS and outcome.frame is not None:
end = self.base_sample + outcome.next_sample
self.accepted_until = end
self.packet_count += 1
emitted.append(ReceivedPacket(
self.base_sample + outcome.refined_start_sample, end, self.total_samples,
bytes(outcome.frame.reception.payload_bytes),
))
else:
self.rejected_reasons[outcome.reason] += 1
self.blocks_processed += 1
self.scan_sample = owned_end
retain_start = max(0, self.scan_sample - self.margin)
trim = retain_start - self.base_sample
if trim > 0:
self.buffer = self.buffer[trim:].copy()
self.base_sample = retain_start
return emitted
def push_iq_block(self, samples: np.ndarray) -> list[ReceivedPacket]:
if self.closed:
raise RuntimeError("Поток уже закрыт")
samples = np.asarray(samples, dtype=np.complex64)
if samples.ndim != 1 or samples.size > self.block_samples or not np.all(np.isfinite(samples)):
raise ValueError("Нужен конечный одномерный IQ-блок не больше block_samples")
started = time.perf_counter()
self.total_samples += samples.size
self.buffer = np.concatenate((self.buffer, samples))
self.peak_buffer_samples = max(self.peak_buffer_samples, self.buffer.size)
if self.buffer.size > self.buffer_limit_samples:
raise RuntimeError("Превышен предел буфера IQ")
emitted = []
with streaming_mode(self.mode):
while self.total_samples >= self.scan_sample + self.core_samples + self.lookahead:
emitted.extend(self._scan(self.scan_sample + self.core_samples))
self.processing_seconds += time.perf_counter() - started
return emitted
def finish(self) -> list[ReceivedPacket]:
if self.closed:
raise RuntimeError("Поток уже закрыт")
started = time.perf_counter()
emitted = []
with streaming_mode(self.mode):
if self.scan_sample < self.total_samples:
emitted = self._scan(self.total_samples)
self.buffer = np.empty(0, np.complex64)
self.closed = True
self.processing_seconds += time.perf_counter() - started
return emitted