VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
106
protocol/video005_timing.py
Normal file
106
protocol/video005_timing.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Поиск преамбулы и восстановление дробного момента выборки из IQ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy.signal import fftconvolve, find_peaks
|
||||
|
||||
from protocol.video003_modem import PREAMBLE_SYMBOLS, bpsk_symbols, known_preamble_bits
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreambleCandidate:
|
||||
start_sample: float
|
||||
score: float
|
||||
|
||||
|
||||
def search_preamble_grid(
|
||||
matched: np.ndarray,
|
||||
samples_per_symbol: int,
|
||||
minimum_score: float = 0.5,
|
||||
) -> tuple[PreambleCandidate, ...]:
|
||||
"""Ищет пилот во всём сигнале, не получая границ кадров от передатчика."""
|
||||
if matched.ndim != 1 or samples_per_symbol < 2 or not 0 < minimum_score < 1:
|
||||
raise ValueError("неверные параметры поиска преамбулы")
|
||||
known = bpsk_symbols(known_preamble_bits())
|
||||
all_peaks: list[PreambleCandidate] = []
|
||||
# Две тактовые фазы дают грубый поиск; дробный пик уточняется отдельно.
|
||||
phase_step = max(1, samples_per_symbol // 2)
|
||||
for phase in range(0, samples_per_symbol, phase_step):
|
||||
series = matched[phase::samples_per_symbol]
|
||||
if series.size < PREAMBLE_SYMBOLS:
|
||||
continue
|
||||
correlation = fftconvolve(series, known[::-1].conj(), mode="valid")
|
||||
power = np.abs(series) ** 2
|
||||
running = np.concatenate(([0.0], np.cumsum(power, dtype=np.float64)))
|
||||
window_power = running[PREAMBLE_SYMBOLS:] - running[:-PREAMBLE_SYMBOLS]
|
||||
score = np.abs(correlation) / np.sqrt(
|
||||
np.maximum(window_power * PREAMBLE_SYMBOLS, 1e-18)
|
||||
)
|
||||
peaks, _ = find_peaks(
|
||||
score, height=minimum_score, distance=PREAMBLE_SYMBOLS // 2
|
||||
)
|
||||
for index in peaks:
|
||||
all_peaks.append(PreambleCandidate(float(phase + index * samples_per_symbol),
|
||||
float(score[index])))
|
||||
all_peaks.sort(key=lambda item: item.start_sample)
|
||||
merged: list[PreambleCandidate] = []
|
||||
for candidate in all_peaks:
|
||||
if merged and candidate.start_sample - merged[-1].start_sample < 2 * samples_per_symbol:
|
||||
if candidate.score > merged[-1].score:
|
||||
merged[-1] = candidate
|
||||
else:
|
||||
merged.append(candidate)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
def refine_timing(
|
||||
matched: np.ndarray,
|
||||
candidate: PreambleCandidate,
|
||||
samples_per_symbol: int,
|
||||
) -> PreambleCandidate:
|
||||
"""Уточняет пик корреляции до доли отсчёта по известной преамбуле."""
|
||||
known = bpsk_symbols(known_preamble_bits())
|
||||
center = int(candidate.start_sample)
|
||||
half_phase_step = max(2, samples_per_symbol // 4)
|
||||
if (center < half_phase_step + 1
|
||||
or center + (PREAMBLE_SYMBOLS - 1) * samples_per_symbol
|
||||
+ half_phase_step + 1 >= matched.size):
|
||||
return candidate
|
||||
indexes = center + np.arange(PREAMBLE_SYMBOLS) * samples_per_symbol
|
||||
shifts = range(-half_phase_step - 1, half_phase_step + 2)
|
||||
scores: list[float] = [float(abs(np.vdot(known, matched[indexes + shift])))
|
||||
for shift in shifts]
|
||||
best = int(np.argmax(scores))
|
||||
if best in (0, len(scores) - 1):
|
||||
fractional = 0.0
|
||||
else:
|
||||
curvature = scores[best - 1] - 2 * scores[best] + scores[best + 1]
|
||||
fractional = (0.0 if curvature >= -1e-12 else
|
||||
0.5 * (scores[best - 1] - scores[best + 1]) / curvature)
|
||||
fractional = float(np.clip(fractional, -0.5, 0.5))
|
||||
return PreambleCandidate(candidate.start_sample + shifts[best] + fractional,
|
||||
candidate.score)
|
||||
|
||||
|
||||
def sample_symbols(
|
||||
matched: np.ndarray,
|
||||
start_sample: float,
|
||||
samples_per_symbol: int,
|
||||
maximum_symbols: int,
|
||||
) -> np.ndarray:
|
||||
"""Интерполирует найденные такты без входного oracle timing."""
|
||||
if not math.isfinite(start_sample) or start_sample < 0 or maximum_symbols < 1:
|
||||
raise ValueError("неверные границы выборки символов")
|
||||
available = int((matched.size - 2 - start_sample) // samples_per_symbol) + 1
|
||||
count = min(maximum_symbols, max(0, available))
|
||||
if count == 0:
|
||||
return np.empty(0, dtype=np.complex64)
|
||||
positions = start_sample + np.arange(count, dtype=np.float64) * samples_per_symbol
|
||||
lower = np.floor(positions).astype(np.int64)
|
||||
fraction = positions - lower
|
||||
return (matched[lower] * (1 - fraction)
|
||||
+ matched[lower + 1] * fraction).astype(np.complex64)
|
||||
Reference in New Issue
Block a user