1398 lines
58 KiB
Python
1398 lines
58 KiB
Python
"""Диагностический цифровой счётчик непрерывности Pluto+ -> HackRF.
|
||
|
||
Это отдельный четырёхсостоянийный сигнал. Он не использует BPSK, PRBS11,
|
||
маркер, пилот, пакет, CRC или JPEG из основного тракта Lab043.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import functools
|
||
import gc
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import sys
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
if __package__ in {None, ""}:
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
|
||
LFSR_DEGREE = 20
|
||
LFSR_POLYNOMIAL = "x^20 + x^3 + 1"
|
||
LFSR_INITIAL_STATE = (1 << LFSR_DEGREE) - 1
|
||
LFSR_PERIOD_BITS = (1 << LFSR_DEGREE) - 1
|
||
SYMBOL_PERIOD = LFSR_PERIOD_BITS
|
||
MINIMUM_POSITION_SYMBOLS = 10
|
||
VERIFY_WINDOW_SYMBOLS = 32
|
||
MAXIMUM_VERIFY_MISMATCHES = 2
|
||
MINIMUM_STABLE_ANCHORS = 8
|
||
MAXIMUM_ANCHOR_GAP = 64
|
||
|
||
SYMBOL_RATE = 20_000.0
|
||
NOMINAL_SAMPLE_RATE = 2_400_000.0
|
||
HACKRF_CENTER_HZ = 435_000_000
|
||
PLUTO_SAMPLE_RATE_TOLERANCE_HZ = 10.0
|
||
NOMINAL_SAMPLES_PER_SYMBOL = 120
|
||
USEFUL_SECONDS = 5.0
|
||
USEFUL_SYMBOL_COUNT = int(round(SYMBOL_RATE * USEFUL_SECONDS))
|
||
MARGIN_SYMBOL_COUNT = 5_000
|
||
TRANSMITTED_SYMBOL_COUNT = USEFUL_SYMBOL_COUNT + 2 * MARGIN_SYMBOL_COUNT
|
||
|
||
QPSK_STATES = np.asarray(
|
||
[
|
||
(1.0 + 1.0j) / math.sqrt(2.0),
|
||
(1.0 - 1.0j) / math.sqrt(2.0),
|
||
(-1.0 + 1.0j) / math.sqrt(2.0),
|
||
(-1.0 - 1.0j) / math.sqrt(2.0),
|
||
],
|
||
dtype=np.complex64,
|
||
)
|
||
TX_AMPLITUDE = 0.35
|
||
FOURTH_POWER_MINIMUM_HZ = 200.0
|
||
FOURTH_POWER_MAXIMUM_HZ = 40_000.0
|
||
TIMING_WINDOW_SYMBOLS = 2_000
|
||
TIMING_WINDOW_COUNT = 25
|
||
PHASE_TRACK_BLOCK_SYMBOLS = 128
|
||
HACKRF_LNA_GAIN_DB = 8
|
||
HACKRF_VGA_GAIN_DB = 8
|
||
SIGNAL_POWER_BLOCK_SAMPLES = 600_000
|
||
RX_LEADING_SECONDS = 0.75
|
||
RX_TRAILING_SECONDS = 0.75
|
||
FIXED_TX_UPLOAD_MARGIN_SECONDS = 2.0
|
||
RAW_DIRECTORY = Path("data/raw/lab043_hackrf")
|
||
PROCESSED_DIRECTORY = Path("data/processed/lab043_hackrf_counter")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AnchorRun:
|
||
offset: int
|
||
first_anchor: int
|
||
last_anchor: int
|
||
anchor_count: int
|
||
first_sequence_position: int
|
||
last_sequence_position: int
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def prbs20_period_bits() -> np.ndarray:
|
||
"""Вернуть один полный период рекуррентной последовательности PRBS20."""
|
||
|
||
bits = np.empty(LFSR_PERIOD_BITS + LFSR_DEGREE, dtype=np.uint8)
|
||
bits[:LFSR_DEGREE] = 1
|
||
for index in range(LFSR_DEGREE, len(bits)):
|
||
bits[index] = bits[index - 17] ^ bits[index - 20]
|
||
if not np.array_equal(bits[LFSR_PERIOD_BITS:], bits[:LFSR_DEGREE]):
|
||
raise RuntimeError("Полином PRBS20 не вернулся в начальное состояние")
|
||
period = bits[:LFSR_PERIOD_BITS].copy()
|
||
period.flags.writeable = False
|
||
return period
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def counter_symbol_period() -> np.ndarray:
|
||
"""Сгруппировать пары битов PRBS20 в четыре диагностических состояния."""
|
||
|
||
bits = prbs20_period_bits()
|
||
symbol_indexes = np.arange(SYMBOL_PERIOD, dtype=np.int64)
|
||
first = bits[(2 * symbol_indexes) % LFSR_PERIOD_BITS]
|
||
second = bits[(2 * symbol_indexes + 1) % LFSR_PERIOD_BITS]
|
||
symbols = ((first << 1) | second).astype(np.uint8)
|
||
symbols.flags.writeable = False
|
||
return symbols
|
||
|
||
|
||
def counter_symbol_ids(start_position: int, count: int) -> np.ndarray:
|
||
if count <= 0:
|
||
raise ValueError("Число символов должно быть положительным")
|
||
positions = (int(start_position) + np.arange(count, dtype=np.int64)) % SYMBOL_PERIOD
|
||
return counter_symbol_period()[positions].copy()
|
||
|
||
|
||
def counter_qpsk_symbols(start_position: int, count: int) -> np.ndarray:
|
||
return QPSK_STATES[counter_symbol_ids(start_position, count)]
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def bit_state_to_position() -> np.ndarray:
|
||
"""Построить прямую таблицу: 20-битное состояние -> битовая позиция."""
|
||
|
||
bits = prbs20_period_bits()
|
||
mask = (1 << LFSR_DEGREE) - 1
|
||
state = 0
|
||
for bit in bits[:LFSR_DEGREE]:
|
||
state = (state << 1) | int(bit)
|
||
positions = np.full(1 << LFSR_DEGREE, -1, dtype=np.int32)
|
||
for position in range(LFSR_PERIOD_BITS):
|
||
positions[state] = position
|
||
state = ((state << 1) & mask) | int(
|
||
bits[(position + LFSR_DEGREE) % LFSR_PERIOD_BITS]
|
||
)
|
||
positions.flags.writeable = False
|
||
return positions
|
||
|
||
|
||
def locate_counter_anchors(
|
||
received_symbol_ids: np.ndarray,
|
||
verification_symbols: int = VERIFY_WINDOW_SYMBOLS,
|
||
maximum_mismatches: int = MAXIMUM_VERIFY_MISMATCHES,
|
||
) -> dict[str, np.ndarray]:
|
||
"""Найти достоверные локальные позиции по длинным окнам счётчика."""
|
||
|
||
received = np.asarray(received_symbol_ids, dtype=np.uint8)
|
||
if received.ndim != 1 or np.any(received > 3):
|
||
raise ValueError("Диагностические символы должны быть одномерными числами 0..3")
|
||
if verification_symbols < MINIMUM_POSITION_SYMBOLS:
|
||
raise ValueError("Проверочное окно короче минимального состояния PRBS20")
|
||
candidate_count = len(received) - verification_symbols + 1
|
||
if candidate_count <= 0:
|
||
return {
|
||
"received_index": np.empty(0, dtype=np.int64),
|
||
"sequence_position": np.empty(0, dtype=np.int64),
|
||
"mismatches": np.empty(0, dtype=np.int16),
|
||
}
|
||
|
||
states = np.zeros(candidate_count, dtype=np.uint32)
|
||
for offset in range(MINIMUM_POSITION_SYMBOLS):
|
||
states = (states << 2) | received[offset : offset + candidate_count]
|
||
bit_positions = bit_state_to_position()[states]
|
||
valid = bit_positions >= 0
|
||
inverse_two = (LFSR_PERIOD_BITS + 1) // 2
|
||
sequence_positions = (
|
||
bit_positions.astype(np.int64) * inverse_two
|
||
) % SYMBOL_PERIOD
|
||
mismatches = np.zeros(candidate_count, dtype=np.int16)
|
||
reference = counter_symbol_period()
|
||
for offset in range(verification_symbols):
|
||
expected = reference[(sequence_positions + offset) % SYMBOL_PERIOD]
|
||
mismatches += received[offset : offset + candidate_count] != expected
|
||
accepted = valid & (mismatches <= maximum_mismatches)
|
||
return {
|
||
"received_index": np.flatnonzero(accepted).astype(np.int64),
|
||
"sequence_position": sequence_positions[accepted].astype(np.int64),
|
||
"mismatches": mismatches[accepted],
|
||
}
|
||
|
||
|
||
def stable_anchor_runs(anchors: dict[str, np.ndarray]) -> list[AnchorRun]:
|
||
indexes = anchors["received_index"]
|
||
positions = anchors["sequence_position"]
|
||
if len(indexes) == 0:
|
||
return []
|
||
offsets = (positions - indexes) % SYMBOL_PERIOD
|
||
starts = [0]
|
||
for index in range(1, len(indexes)):
|
||
if (
|
||
offsets[index] != offsets[index - 1]
|
||
or indexes[index] - indexes[index - 1] > MAXIMUM_ANCHOR_GAP
|
||
):
|
||
starts.append(index)
|
||
starts.append(len(indexes))
|
||
runs = []
|
||
for begin, end in zip(starts[:-1], starts[1:]):
|
||
if end - begin < MINIMUM_STABLE_ANCHORS:
|
||
continue
|
||
runs.append(
|
||
AnchorRun(
|
||
offset=int(offsets[begin]),
|
||
first_anchor=int(indexes[begin]),
|
||
last_anchor=int(indexes[end - 1]),
|
||
anchor_count=end - begin,
|
||
first_sequence_position=int(positions[begin]),
|
||
last_sequence_position=int(positions[end - 1]),
|
||
)
|
||
)
|
||
return runs
|
||
|
||
|
||
def _signed_period_delta(after: int, before: int) -> int:
|
||
delta = (int(after) - int(before)) % SYMBOL_PERIOD
|
||
if delta > SYMBOL_PERIOD // 2:
|
||
delta -= SYMBOL_PERIOD
|
||
return int(delta)
|
||
|
||
|
||
def _best_breakpoint(
|
||
received: np.ndarray,
|
||
before_offset: int,
|
||
after_offset: int,
|
||
lower: int,
|
||
upper: int,
|
||
) -> int:
|
||
reference = counter_symbol_period()
|
||
start = max(0, lower - VERIFY_WINDOW_SYMBOLS)
|
||
stop = min(len(received), upper + VERIFY_WINDOW_SYMBOLS)
|
||
indexes = np.arange(start, stop, dtype=np.int64)
|
||
before_errors = received[start:stop] != reference[(indexes + before_offset) % SYMBOL_PERIOD]
|
||
after_errors = received[start:stop] != reference[(indexes + after_offset) % SYMBOL_PERIOD]
|
||
before_prefix = np.concatenate(([0], np.cumsum(before_errors, dtype=np.int64)))
|
||
after_prefix = np.concatenate(([0], np.cumsum(after_errors, dtype=np.int64)))
|
||
candidates = np.arange(max(start, lower), min(stop, upper) + 1, dtype=np.int64)
|
||
local = candidates - start
|
||
costs = before_prefix[local] + (after_prefix[-1] - after_prefix[local])
|
||
return int(candidates[int(np.argmin(costs))])
|
||
|
||
|
||
def _event_type(
|
||
received: np.ndarray,
|
||
breakpoint: int,
|
||
delta: int,
|
||
before_offset: int,
|
||
) -> str:
|
||
if delta > 0:
|
||
return "пропуск"
|
||
count = -delta
|
||
reference = counter_symbol_period()
|
||
search_start = max(0, breakpoint - VERIFY_WINDOW_SYMBOLS)
|
||
search_stop = min(
|
||
len(received) - count - VERIFY_WINDOW_SYMBOLS,
|
||
breakpoint + VERIFY_WINDOW_SYMBOLS,
|
||
)
|
||
after_offset = (before_offset + delta) % SYMBOL_PERIOD
|
||
for insertion_start in range(search_start, search_stop + 1):
|
||
before_begin = max(0, insertion_start - VERIFY_WINDOW_SYMBOLS)
|
||
before_indexes = np.arange(before_begin, insertion_start, dtype=np.int64)
|
||
after_begin = insertion_start + count
|
||
after_end = min(len(received), after_begin + VERIFY_WINDOW_SYMBOLS)
|
||
after_indexes = np.arange(after_begin, after_end, dtype=np.int64)
|
||
before_mismatches = np.sum(
|
||
received[before_begin:insertion_start]
|
||
!= reference[(before_indexes + before_offset) % SYMBOL_PERIOD]
|
||
)
|
||
after_mismatches = np.sum(
|
||
received[after_begin:after_end]
|
||
!= reference[(after_indexes + after_offset) % SYMBOL_PERIOD]
|
||
)
|
||
if (
|
||
before_mismatches > MAXIMUM_VERIFY_MISMATCHES
|
||
or after_mismatches > MAXIMUM_VERIFY_MISMATCHES
|
||
):
|
||
continue
|
||
expected_position = (insertion_start + before_offset) % SYMBOL_PERIOD
|
||
repeated_positions = (
|
||
expected_position - count + np.arange(count, dtype=np.int64)
|
||
) % SYMBOL_PERIOD
|
||
if np.array_equal(
|
||
received[insertion_start : insertion_start + count],
|
||
reference[repeated_positions],
|
||
):
|
||
return "повтор"
|
||
return "вставка"
|
||
|
||
|
||
def analyze_counter_symbols(received_symbol_ids: np.ndarray) -> dict:
|
||
"""Построить карту цифровой позиции и отделить ошибки от скачков."""
|
||
|
||
received = np.asarray(received_symbol_ids, dtype=np.uint8)
|
||
anchors = locate_counter_anchors(received)
|
||
runs = stable_anchor_runs(anchors)
|
||
if not runs:
|
||
return {
|
||
"classification": "D",
|
||
"reason": "Нет достоверного участка цифрового счётчика",
|
||
"anchor_count": int(len(anchors["received_index"])),
|
||
"stable_run_count": 0,
|
||
"recovered_symbol_count": 0,
|
||
"ordinary_symbol_error_count": None,
|
||
"events": [],
|
||
"runs": [],
|
||
}
|
||
|
||
events = []
|
||
for before, after in zip(runs[:-1], runs[1:]):
|
||
delta = _signed_period_delta(after.offset, before.offset)
|
||
if delta == 0:
|
||
continue
|
||
breakpoint = _best_breakpoint(
|
||
received,
|
||
before.offset,
|
||
after.offset,
|
||
before.last_anchor,
|
||
after.first_anchor + VERIFY_WINDOW_SYMBOLS,
|
||
)
|
||
expected_position = (breakpoint + before.offset) % SYMBOL_PERIOD
|
||
actual_position = (breakpoint + after.offset) % SYMBOL_PERIOD
|
||
event_type = _event_type(received, breakpoint, delta, before.offset)
|
||
events.append(
|
||
{
|
||
"received_symbol_index": breakpoint,
|
||
"expected_state_number": int(counter_symbol_period()[expected_position]),
|
||
"recognized_state_number": (
|
||
int(received[breakpoint]) if breakpoint < len(received) else None
|
||
),
|
||
"expected_next_position": int(expected_position),
|
||
"actual_next_position": int(actual_position),
|
||
"position_delta_symbols": delta,
|
||
"minimum_confirmed_gap_symbols": abs(delta),
|
||
"type": event_type,
|
||
}
|
||
)
|
||
|
||
covered = np.zeros(len(received), dtype=bool)
|
||
expected_ids = np.zeros(len(received), dtype=np.uint8)
|
||
for run in runs:
|
||
start = run.first_anchor
|
||
stop = min(len(received), run.last_anchor + VERIFY_WINDOW_SYMBOLS)
|
||
indexes = np.arange(start, stop, dtype=np.int64)
|
||
covered[start:stop] = True
|
||
expected_ids[start:stop] = counter_symbol_period()[
|
||
(indexes + run.offset) % SYMBOL_PERIOD
|
||
]
|
||
for event in events:
|
||
center = int(event["received_symbol_index"])
|
||
covered[
|
||
max(0, center - VERIFY_WINDOW_SYMBOLS) : min(
|
||
len(received), center + VERIFY_WINDOW_SYMBOLS
|
||
)
|
||
] = False
|
||
ordinary_errors = int(np.sum(covered & (received != expected_ids)))
|
||
recovered_count = int(np.sum(covered))
|
||
classification = "B" if events else "A"
|
||
return {
|
||
"classification": classification,
|
||
"reason": (
|
||
"Подтверждены скачки цифровой позиции"
|
||
if events
|
||
else "Восстановленная цифровая последовательность непрерывна"
|
||
),
|
||
"anchor_count": int(len(anchors["received_index"])),
|
||
"stable_run_count": len(runs),
|
||
"recovered_symbol_count": recovered_count,
|
||
"ordinary_symbol_error_count": ordinary_errors,
|
||
"events": events,
|
||
"runs": [run.__dict__ for run in runs],
|
||
}
|
||
|
||
|
||
def build_counter_waveform(actual_sample_rate_hz: float) -> tuple[np.ndarray, dict]:
|
||
"""Построить прямоугольный четырёхсостоянийный диагностический сигнал."""
|
||
|
||
if not np.isfinite(actual_sample_rate_hz) or actual_sample_rate_hz <= 0.0:
|
||
raise ValueError("Фактическая частота дискретизации должна быть положительной")
|
||
samples_per_symbol = int(round(actual_sample_rate_hz / SYMBOL_RATE))
|
||
if samples_per_symbol != NOMINAL_SAMPLES_PER_SYMBOL:
|
||
raise RuntimeError("Фактическая Fs не даёт заранее выбранные 120 отсчётов/символ")
|
||
symbols = counter_qpsk_symbols(0, TRANSMITTED_SYMBOL_COUNT)
|
||
waveform = np.repeat(symbols, samples_per_symbol)
|
||
waveform = (TX_AMPLITUDE * waveform).astype(np.complex64)
|
||
actual_symbol_rate = actual_sample_rate_hz / samples_per_symbol
|
||
return waveform, {
|
||
"samples_per_symbol": samples_per_symbol,
|
||
"requested_symbol_rate": SYMBOL_RATE,
|
||
"actual_symbol_rate": float(actual_symbol_rate),
|
||
"margin_symbol_count_each_side": MARGIN_SYMBOL_COUNT,
|
||
"useful_symbol_count": USEFUL_SYMBOL_COUNT,
|
||
"transmitted_symbol_count": TRANSMITTED_SYMBOL_COUNT,
|
||
"useful_duration_seconds": float(USEFUL_SYMBOL_COUNT / actual_symbol_rate),
|
||
"total_duration_seconds": float(len(waveform) / actual_sample_rate_hz),
|
||
}
|
||
|
||
|
||
def estimate_qpsk_cfo_fourth_power(
|
||
samples: np.ndarray,
|
||
sample_rate_hz: float,
|
||
fft_samples: int = 262_144,
|
||
minimum_fourth_power_hz: float = FOURTH_POWER_MINIMUM_HZ,
|
||
maximum_fourth_power_hz: float = FOURTH_POWER_MAXIMUM_HZ,
|
||
) -> dict:
|
||
"""Оценить частотное смещение по четвёртой степени QPSK-сигнала."""
|
||
|
||
received = np.asarray(samples, dtype=np.complex64)
|
||
block = min(int(fft_samples), 1 << (len(received).bit_length() - 1))
|
||
if block < 4096:
|
||
raise ValueError("Слишком мало отсчётов для оценки частотного смещения")
|
||
window = np.hanning(block).astype(np.float32)
|
||
accumulated = np.zeros(block, dtype=np.float64)
|
||
block_count = len(received) // block
|
||
for index in range(block_count):
|
||
part = received[index * block : (index + 1) * block]
|
||
spectrum = np.fft.fft((part**4) * window)
|
||
accumulated += np.abs(spectrum) ** 2
|
||
frequencies = np.fft.fftfreq(block, d=1.0 / sample_rate_hz)
|
||
search = (
|
||
(np.abs(frequencies) >= minimum_fourth_power_hz)
|
||
& (np.abs(frequencies) <= maximum_fourth_power_hz)
|
||
)
|
||
indexes = np.flatnonzero(search)
|
||
peak = int(indexes[np.argmax(accumulated[indexes])])
|
||
resolution = sample_rate_hz / block
|
||
left, right = (peak - 1) % block, (peak + 1) % block
|
||
y0, y1, y2 = np.log(np.maximum(accumulated[[left, peak, right]], 1e-300))
|
||
denominator = y0 - 2.0 * y1 + y2
|
||
fractional = 0.0 if abs(denominator) < 1e-18 else 0.5 * (y0 - y2) / denominator
|
||
fourth_frequency = float(frequencies[peak] + fractional * resolution)
|
||
local = search & (np.abs(frequencies - frequencies[peak]) >= 2_000.0)
|
||
noise = float(np.median(accumulated[local]))
|
||
margin = 10.0 * math.log10(float(accumulated[peak]) / max(noise, 1e-300))
|
||
return {
|
||
"carrier_offset_hz": fourth_frequency / 4.0,
|
||
"fourth_power_peak_hz": fourth_frequency,
|
||
"fft_resolution_hz_before_division": float(resolution),
|
||
"peak_above_noise_db": float(margin),
|
||
"fft_block_count": int(block_count),
|
||
}
|
||
|
||
|
||
def correct_frequency(
|
||
samples: np.ndarray,
|
||
sample_rate_hz: float,
|
||
frequency_hz: float,
|
||
) -> np.ndarray:
|
||
received = np.asarray(samples, dtype=np.complex64)
|
||
result = np.empty_like(received)
|
||
chunk = 1_000_000
|
||
for start in range(0, len(received), chunk):
|
||
stop = min(len(received), start + chunk)
|
||
indexes = np.arange(start, stop, dtype=np.float64)
|
||
oscillator = np.exp(-1j * 2.0 * np.pi * frequency_hz * indexes / sample_rate_hz)
|
||
result[start:stop] = received[start:stop] * oscillator
|
||
return result
|
||
|
||
|
||
def _best_integer_timing_phase(window: np.ndarray, samples_per_symbol: int) -> int:
|
||
values = np.asarray(window, dtype=np.complex64)
|
||
cumulative = np.concatenate(
|
||
(np.zeros(1, dtype=np.complex128), np.cumsum(values, dtype=np.complex128))
|
||
)
|
||
metrics = np.full(samples_per_symbol, -np.inf, dtype=np.float64)
|
||
for phase in range(samples_per_symbol):
|
||
starts = np.arange(phase, len(values) - samples_per_symbol + 1, samples_per_symbol)
|
||
ends = starts + samples_per_symbol
|
||
if len(starts) < 8:
|
||
continue
|
||
means = (cumulative[ends] - cumulative[starts]) / samples_per_symbol
|
||
metrics[phase] = float(np.mean(np.abs(means) ** 2))
|
||
return int(np.argmax(metrics))
|
||
|
||
|
||
def estimate_samples_per_symbol(
|
||
samples: np.ndarray,
|
||
nominal_samples_per_symbol: int,
|
||
) -> dict:
|
||
"""Оценить линейный дрейф символьных границ по 25 фиксированным окнам."""
|
||
|
||
received = np.asarray(samples, dtype=np.complex64)
|
||
window_samples = TIMING_WINDOW_SYMBOLS * nominal_samples_per_symbol
|
||
centers = np.linspace(
|
||
int(0.18 * len(received)),
|
||
int(0.82 * len(received)),
|
||
TIMING_WINDOW_COUNT,
|
||
dtype=np.int64,
|
||
)
|
||
absolute_boundaries = []
|
||
for center in centers:
|
||
start = int(np.clip(center - window_samples // 2, 0, len(received) - window_samples))
|
||
phase = _best_integer_timing_phase(
|
||
received[start : start + window_samples], nominal_samples_per_symbol
|
||
)
|
||
absolute_boundaries.append(start + phase)
|
||
boundaries = np.asarray(absolute_boundaries, dtype=np.float64)
|
||
residues = np.mod(boundaries, nominal_samples_per_symbol)
|
||
unwrapped = np.unwrap(2.0 * np.pi * residues / nominal_samples_per_symbol)
|
||
unwrapped_samples = unwrapped * nominal_samples_per_symbol / (2.0 * np.pi)
|
||
approximate_symbols = np.rint(
|
||
(boundaries - boundaries[0]) / nominal_samples_per_symbol
|
||
).astype(np.int64)
|
||
adjusted_boundaries = (
|
||
boundaries
|
||
+ unwrapped_samples
|
||
- residues
|
||
)
|
||
design = np.column_stack((np.ones(len(approximate_symbols)), approximate_symbols))
|
||
coefficients, *_ = np.linalg.lstsq(design, adjusted_boundaries, rcond=None)
|
||
estimated = float(coefficients[1])
|
||
residuals = adjusted_boundaries - design @ coefficients
|
||
return {
|
||
"samples_per_symbol": estimated,
|
||
"timing_intercept_sample": float(coefficients[0]),
|
||
"timing_fit_rms_samples": float(np.sqrt(np.mean(residuals**2))),
|
||
"timing_window_count": len(centers),
|
||
}
|
||
|
||
|
||
def integrate_symbol_grid(
|
||
samples: np.ndarray,
|
||
samples_per_symbol: float,
|
||
timing_intercept_sample: float,
|
||
) -> tuple[np.ndarray, np.ndarray]:
|
||
received = np.asarray(samples, dtype=np.complex64)
|
||
first_index = math.floor(-timing_intercept_sample / samples_per_symbol) - 1
|
||
last_index = math.ceil((len(received) - timing_intercept_sample) / samples_per_symbol) + 1
|
||
grid = timing_intercept_sample + np.arange(first_index, last_index + 1) * samples_per_symbol
|
||
boundaries = np.rint(grid).astype(np.int64)
|
||
boundaries = np.unique(boundaries[(boundaries >= 0) & (boundaries <= len(received))])
|
||
if len(boundaries) < 3:
|
||
raise RuntimeError("Не удалось построить символьную сетку")
|
||
starts = boundaries[:-1]
|
||
lengths = np.diff(boundaries)
|
||
sums = np.add.reduceat(received, starts)
|
||
sums[-1] = np.sum(received[starts[-1] : boundaries[-1]])
|
||
symbols = sums / lengths
|
||
centers = starts.astype(np.float64) + lengths / 2.0
|
||
return symbols.astype(np.complex64), centers
|
||
|
||
|
||
def correct_qpsk_phase_track(symbols: np.ndarray) -> tuple[np.ndarray, dict]:
|
||
values = np.asarray(symbols, dtype=np.complex64)
|
||
block = PHASE_TRACK_BLOCK_SYMBOLS
|
||
block_count = len(values) // block
|
||
if block_count < 10:
|
||
raise ValueError("Недостаточно символов для фазовой модели")
|
||
trimmed = values[: block_count * block].reshape(block_count, block)
|
||
fourth = -np.sum(trimmed.astype(np.complex128) ** 4, axis=1)
|
||
centers = np.arange(block_count, dtype=np.float64) * block + (block - 1) / 2.0
|
||
select = (centers >= 0.20 * len(values)) & (centers <= 0.80 * len(values))
|
||
phases = np.unwrap(np.angle(fourth[select]))
|
||
weights = np.abs(fourth[select]) ** 2
|
||
design = np.column_stack((np.ones(np.sum(select)), centers[select]))
|
||
root = np.sqrt(weights / max(float(np.max(weights)), 1e-30))
|
||
coefficients, *_ = np.linalg.lstsq(
|
||
design * root[:, None], phases * root, rcond=None
|
||
)
|
||
residuals = phases - design @ coefficients
|
||
phase = (coefficients[0] + coefficients[1] * np.arange(len(values))) / 4.0
|
||
corrected = values * np.exp(-1j * phase)
|
||
return corrected.astype(np.complex64), {
|
||
"residual_frequency_rad_per_symbol": float(coefficients[1] / 4.0),
|
||
"fourth_power_phase_fit_rms_rad": float(
|
||
np.sqrt(np.sum(weights * residuals**2) / np.sum(weights))
|
||
),
|
||
"phase_track_block_count": int(np.sum(select)),
|
||
}
|
||
|
||
|
||
def hard_qpsk_ids(symbols: np.ndarray, rotation_quadrants: int, conjugate: bool) -> np.ndarray:
|
||
values = np.asarray(symbols, dtype=np.complex64)
|
||
if conjugate:
|
||
values = np.conj(values)
|
||
values = values * np.exp(-1j * rotation_quadrants * np.pi / 2.0)
|
||
distances = np.abs(values[:, None] - QPSK_STATES[None, :])
|
||
return np.argmin(distances, axis=1).astype(np.uint8)
|
||
|
||
|
||
def constellation_separation_metrics(
|
||
symbols: np.ndarray,
|
||
symbol_ids: np.ndarray,
|
||
) -> dict:
|
||
"""Описать четыре принятые группы без изменения или подгонки декодера."""
|
||
|
||
values = np.asarray(symbols, dtype=np.complex64)
|
||
ids = np.asarray(symbol_ids, dtype=np.uint8)
|
||
if values.ndim != 1 or ids.ndim != 1 or len(values) != len(ids) or len(values) == 0:
|
||
raise ValueError("Символы и решения должны быть непустыми одномерными массивами одной длины")
|
||
centroids: list[complex] = []
|
||
counts: list[int] = []
|
||
residual_power = 0.0
|
||
for state in range(4):
|
||
group = values[ids == state]
|
||
counts.append(int(len(group)))
|
||
if len(group) == 0:
|
||
centroids.append(complex(float("nan"), float("nan")))
|
||
continue
|
||
centroid = complex(np.mean(group))
|
||
centroids.append(centroid)
|
||
residual_power += float(np.sum(np.abs(group - centroid) ** 2))
|
||
finite_centroids = [value for value in centroids if np.isfinite(value)]
|
||
distances = [
|
||
abs(finite_centroids[left] - finite_centroids[right])
|
||
for left in range(len(finite_centroids))
|
||
for right in range(left + 1, len(finite_centroids))
|
||
]
|
||
within_rms = math.sqrt(residual_power / len(values))
|
||
minimum_distance = float(min(distances)) if len(distances) == 6 else float("nan")
|
||
fourth_power_coherence = float(
|
||
abs(np.mean(values.astype(np.complex128) ** 4))
|
||
/ max(float(np.mean(np.abs(values.astype(np.complex128)) ** 4)), 1e-30)
|
||
)
|
||
return {
|
||
"state_counts": counts,
|
||
"minimum_state_fraction": float(min(counts) / len(values)),
|
||
"centroids": [
|
||
{"i": float(value.real), "q": float(value.imag)}
|
||
for value in centroids
|
||
],
|
||
"minimum_centroid_distance": minimum_distance,
|
||
"pooled_within_state_rms": float(within_rms),
|
||
"centroid_separation_to_within_rms": float(
|
||
minimum_distance / max(within_rms, 1e-30)
|
||
),
|
||
"fourth_power_coherence": fourth_power_coherence,
|
||
"note": "Группы построены по жёстким решениям декодера; метрики сами по себе не доказывают правильную позицию последовательности",
|
||
}
|
||
|
||
|
||
def recover_counter_from_iq(
|
||
samples: np.ndarray,
|
||
sample_rate_hz: float,
|
||
nominal_samples_per_symbol: int = NOMINAL_SAMPLES_PER_SYMBOL,
|
||
fft_samples: int = 262_144,
|
||
minimum_fourth_power_hz: float = FOURTH_POWER_MINIMUM_HZ,
|
||
maximum_fourth_power_hz: float = FOURTH_POWER_MAXIMUM_HZ,
|
||
) -> dict:
|
||
"""Восстановить символы и выбрать только достоверную ориентацию QPSK."""
|
||
|
||
cfo = estimate_qpsk_cfo_fourth_power(
|
||
samples,
|
||
sample_rate_hz,
|
||
fft_samples,
|
||
minimum_fourth_power_hz,
|
||
maximum_fourth_power_hz,
|
||
)
|
||
frequency_corrected = correct_frequency(samples, sample_rate_hz, cfo["carrier_offset_hz"])
|
||
timing = estimate_samples_per_symbol(frequency_corrected, nominal_samples_per_symbol)
|
||
symbols, centers = integrate_symbol_grid(
|
||
frequency_corrected,
|
||
timing["samples_per_symbol"],
|
||
timing["timing_intercept_sample"],
|
||
)
|
||
phase_corrected, phase_metrics = correct_qpsk_phase_track(symbols)
|
||
candidates = []
|
||
for conjugate in (False, True):
|
||
for rotation in range(4):
|
||
ids = hard_qpsk_ids(phase_corrected, rotation, conjugate)
|
||
analysis = analyze_counter_symbols(ids)
|
||
candidates.append(
|
||
(
|
||
analysis["anchor_count"],
|
||
analysis["recovered_symbol_count"],
|
||
-len(analysis["events"]),
|
||
conjugate,
|
||
rotation,
|
||
ids,
|
||
analysis,
|
||
)
|
||
)
|
||
selected = max(candidates, key=lambda row: row[:3])
|
||
separation = constellation_separation_metrics(phase_corrected, selected[5])
|
||
return {
|
||
"symbol_ids": selected[5],
|
||
"symbol_center_samples": centers,
|
||
"demodulated_symbols": phase_corrected,
|
||
"counter_analysis": selected[6],
|
||
"carrier": cfo,
|
||
"timing": timing
|
||
| {
|
||
"sample_clock_error_ppm_relative_to_nominal_sps": float(
|
||
(timing["samples_per_symbol"] / nominal_samples_per_symbol - 1.0) * 1e6
|
||
)
|
||
},
|
||
"phase": phase_metrics,
|
||
"orientation": {
|
||
"conjugated": bool(selected[3]),
|
||
"rotation_quadrants": int(selected[4]),
|
||
},
|
||
"constellation_separation": separation,
|
||
}
|
||
|
||
|
||
def capture_quality_metrics(
|
||
samples: np.ndarray,
|
||
background_rms: float,
|
||
block_samples: int = SIGNAL_POWER_BLOCK_SAMPLES,
|
||
) -> dict:
|
||
"""Измерить raw и его наиболее мощный заранее фиксированный интервал."""
|
||
|
||
values = np.asarray(samples, dtype=np.complex64)
|
||
if values.ndim != 1 or len(values) < block_samples or not np.all(np.isfinite(values)):
|
||
raise ValueError("Недействительный raw для оценки качества")
|
||
if not np.isfinite(background_rms) or background_rms <= 0.0:
|
||
raise ValueError("Фоновое RMS должно быть положительным")
|
||
powers = np.abs(values) ** 2
|
||
block_count = len(values) // block_samples
|
||
block_powers = np.asarray(
|
||
[
|
||
np.mean(powers[index * block_samples : (index + 1) * block_samples])
|
||
for index in range(block_count)
|
||
],
|
||
dtype=np.float64,
|
||
)
|
||
background_power = float(background_rms**2)
|
||
component_peak = np.maximum(np.abs(values.real), np.abs(values.imag))
|
||
return {
|
||
"sample_count": int(len(values)),
|
||
"dtype": str(values.dtype),
|
||
"mean_amplitude": float(np.mean(np.abs(values))),
|
||
"rms_amplitude": float(np.sqrt(np.mean(powers))),
|
||
"maximum_amplitude": float(np.max(np.abs(values))),
|
||
"maximum_component": float(np.max(component_peak)),
|
||
"clipped_component_fraction": float(np.mean(component_peak >= 127.0 / 128.0)),
|
||
"dc_mean_i": float(np.mean(values.real)),
|
||
"dc_mean_q": float(np.mean(values.imag)),
|
||
"background_rms": float(background_rms),
|
||
"background_power": background_power,
|
||
"fixed_power_block_samples": int(block_samples),
|
||
"fixed_power_block_count": int(block_count),
|
||
"maximum_block_power": float(np.max(block_powers)),
|
||
"maximum_block_power_above_background_db": float(
|
||
10.0 * math.log10(max(float(np.max(block_powers)), 1e-300) / background_power)
|
||
),
|
||
}
|
||
|
||
|
||
def configure_counter_receiver(receiver) -> dict:
|
||
"""Задать единственную разрешённую рабочую точку HackRF 8/8."""
|
||
|
||
base = receiver.configure_rx()
|
||
gains = receiver.set_rx_gains(HACKRF_LNA_GAIN_DB, HACKRF_VGA_GAIN_DB)
|
||
parameters = base | gains
|
||
validate_counter_working_point(parameters)
|
||
return parameters
|
||
|
||
|
||
def validate_counter_working_point(parameters: dict) -> None:
|
||
"""Запретить TX, если HackRF не настроен на единственную рабочую точку."""
|
||
|
||
expected = {
|
||
"set_center_frequency_hz": HACKRF_CENTER_HZ,
|
||
"set_sample_rate_hz": NOMINAL_SAMPLE_RATE,
|
||
"lna_gain_db": HACKRF_LNA_GAIN_DB,
|
||
"vga_gain_db": HACKRF_VGA_GAIN_DB,
|
||
"rf_amp_enabled": False,
|
||
"antenna_bias_enabled": False,
|
||
}
|
||
mismatches = {
|
||
key: {"expected": value, "actual": parameters.get(key)}
|
||
for key, value in expected.items()
|
||
if parameters.get(key) != value
|
||
}
|
||
if mismatches:
|
||
raise RuntimeError(f"Рабочая точка HackRF не подтверждена: {mismatches}")
|
||
|
||
|
||
def preflight_counter_receiver(hackrf_module) -> dict:
|
||
"""Проверить HackRF на 8/8 до конфигурирования и запуска Pluto TX."""
|
||
|
||
parameters = hackrf_module.preflight_hackrf(
|
||
HACKRF_LNA_GAIN_DB,
|
||
HACKRF_VGA_GAIN_DB,
|
||
)
|
||
validate_counter_working_point(parameters)
|
||
return parameters
|
||
|
||
|
||
def preflight_counter_hardware(hackrf_module, pluto_module) -> dict:
|
||
"""Проверить прямой USB Pluto+ и HackRF без вызова TX."""
|
||
|
||
pluto_uri = pluto_module.discover_pluto_usb_uri()
|
||
return {
|
||
"pluto": pluto_module.preflight_pluto(pluto_uri),
|
||
"hackrf": preflight_counter_receiver(hackrf_module),
|
||
}
|
||
|
||
|
||
def validate_pluto_working_point(parameters: dict) -> None:
|
||
"""Запретить TX при отклонении фактических настроек Pluto+."""
|
||
|
||
exact_expected = {
|
||
"actual_center_frequency_hz": HACKRF_CENTER_HZ,
|
||
"actual_gain_db": -30.0,
|
||
}
|
||
mismatches = {
|
||
key: {"expected": value, "actual": parameters.get(key)}
|
||
for key, value in exact_expected.items()
|
||
if parameters.get(key) != value
|
||
}
|
||
actual_sample_rate_hz = parameters.get("actual_sample_rate_hz")
|
||
if not isinstance(actual_sample_rate_hz, (int, float)) or not math.isclose(
|
||
float(actual_sample_rate_hz),
|
||
NOMINAL_SAMPLE_RATE,
|
||
rel_tol=0.0,
|
||
abs_tol=PLUTO_SAMPLE_RATE_TOLERANCE_HZ,
|
||
):
|
||
mismatches["actual_sample_rate_hz"] = {
|
||
"expected": NOMINAL_SAMPLE_RATE,
|
||
"absolute_tolerance_hz": PLUTO_SAMPLE_RATE_TOLERANCE_HZ,
|
||
"actual": actual_sample_rate_hz,
|
||
}
|
||
if mismatches:
|
||
raise RuntimeError(f"Рабочая точка Pluto+ не подтверждена: {mismatches}")
|
||
|
||
|
||
def synthesize_counter_iq(
|
||
start_position: int,
|
||
symbol_count: int,
|
||
samples_per_symbol: int,
|
||
sample_rate_hz: float,
|
||
carrier_offset_hz: float = 0.0,
|
||
sample_clock_error_ppm: float = 0.0,
|
||
noise_standard_deviation: float = 0.0,
|
||
seed: int = 43,
|
||
) -> np.ndarray:
|
||
"""Синтетический RF-вход для проверки CFO, SRO и шума до аппаратуры."""
|
||
|
||
symbols = counter_qpsk_symbols(start_position, symbol_count)
|
||
transmitted = np.repeat(symbols, samples_per_symbol).astype(np.complex64)
|
||
scale = 1.0 + sample_clock_error_ppm * 1e-6
|
||
output_count = int(round(len(transmitted) * scale))
|
||
source = np.arange(output_count, dtype=np.float64) / scale
|
||
base = np.arange(len(transmitted), dtype=np.float64)
|
||
real = np.interp(source, base, transmitted.real)
|
||
imag = np.interp(source, base, transmitted.imag)
|
||
received = real + 1j * imag
|
||
indexes = np.arange(output_count, dtype=np.float64)
|
||
received *= np.exp(1j * 2.0 * np.pi * carrier_offset_hz * indexes / sample_rate_hz)
|
||
if noise_standard_deviation:
|
||
generator = np.random.default_rng(seed)
|
||
received += noise_standard_deviation * (
|
||
generator.normal(size=output_count) + 1j * generator.normal(size=output_count)
|
||
)
|
||
return received.astype(np.complex64)
|
||
|
||
|
||
def _event_hardware_rows(
|
||
events: list[dict],
|
||
symbol_centers: np.ndarray,
|
||
callback_boundaries,
|
||
raw_samples: np.ndarray,
|
||
) -> tuple[list[dict], dict]:
|
||
callback_samples = np.asarray(
|
||
[row.accepted_end_sample for row in callback_boundaries], dtype=np.int64
|
||
)
|
||
midpoints = (
|
||
(callback_samples[:-1] + callback_samples[1:]) // 2
|
||
if len(callback_samples) >= 2
|
||
else np.empty(0, dtype=np.int64)
|
||
)
|
||
categories = {"exactly_on_boundary": 0, "within_100": 0, "within_1000": 0, "farther": 0}
|
||
control_categories = dict.fromkeys(categories, 0)
|
||
rows = []
|
||
for event in events:
|
||
symbol_index = int(event["received_symbol_index"])
|
||
sample_index = int(round(symbol_centers[symbol_index]))
|
||
nearest_index = int(np.argmin(np.abs(callback_samples - sample_index)))
|
||
signed_distance = sample_index - int(callback_samples[nearest_index])
|
||
absolute = abs(signed_distance)
|
||
category = (
|
||
"exactly_on_boundary"
|
||
if absolute == 0
|
||
else "within_100"
|
||
if absolute <= 100
|
||
else "within_1000"
|
||
if absolute <= 1000
|
||
else "farther"
|
||
)
|
||
categories[category] += 1
|
||
if len(midpoints):
|
||
midpoint_distance = int(np.min(np.abs(midpoints - sample_index)))
|
||
control_category = (
|
||
"exactly_on_boundary"
|
||
if midpoint_distance == 0
|
||
else "within_100"
|
||
if midpoint_distance <= 100
|
||
else "within_1000"
|
||
if midpoint_distance <= 1000
|
||
else "farther"
|
||
)
|
||
control_categories[control_category] += 1
|
||
else:
|
||
midpoint_distance = None
|
||
local_start = max(0, sample_index - 2_000)
|
||
local_end = min(len(raw_samples), sample_index + 2_000)
|
||
local = np.asarray(raw_samples[local_start:local_end])
|
||
before = np.asarray(raw_samples[max(0, sample_index - 1_000) : sample_index])
|
||
after = np.asarray(raw_samples[sample_index : min(len(raw_samples), sample_index + 1_000)])
|
||
row = dict(event)
|
||
row.update(
|
||
{
|
||
"estimated_raw_sample_index": sample_index,
|
||
"nearest_callback_index": callback_boundaries[nearest_index].callback_index,
|
||
"signed_distance_to_callback_samples": signed_distance,
|
||
"distance_category": category,
|
||
"distance_to_nearest_midpoint_samples": midpoint_distance,
|
||
"local_mean_amplitude": float(np.mean(np.abs(local))),
|
||
"local_peak_amplitude": float(np.max(np.abs(local))),
|
||
"before_mean_amplitude": float(np.mean(np.abs(before))),
|
||
"after_mean_amplitude": float(np.mean(np.abs(after))),
|
||
"local_exact_zero_fraction": float(np.mean(local == 0)),
|
||
"exact_lost_or_repeated_iq_samples": None,
|
||
"exact_iq_sample_count_status": "not_computed",
|
||
"exact_iq_sample_count_reason": (
|
||
"цифровая последовательность не доказывает число потерянных "
|
||
"или повторённых сырых I/Q-отсчётов"
|
||
),
|
||
}
|
||
)
|
||
rows.append(row)
|
||
return rows, {
|
||
"event_distance_distribution": categories,
|
||
"midpoint_control_distance_distribution": control_categories,
|
||
}
|
||
|
||
|
||
def analyze_hardware_counter(
|
||
raw_samples: np.ndarray,
|
||
callback_boundaries,
|
||
sample_rate_hz: float,
|
||
) -> tuple[dict, dict]:
|
||
recovery = recover_counter_from_iq(raw_samples, sample_rate_hz)
|
||
digital = recovery["counter_analysis"]
|
||
runs = digital["runs"]
|
||
useful_positions: set[int] = set()
|
||
transmitted_positions: set[int] = set()
|
||
for run in runs:
|
||
start = int(run["first_anchor"])
|
||
stop = int(run["last_anchor"]) + VERIFY_WINDOW_SYMBOLS
|
||
positions = (
|
||
np.arange(start, stop, dtype=np.int64) + int(run["offset"])
|
||
) % SYMBOL_PERIOD
|
||
transmitted_positions.update(
|
||
int(value) for value in positions if 0 <= value < TRANSMITTED_SYMBOL_COUNT
|
||
)
|
||
useful_positions.update(
|
||
int(value)
|
||
for value in positions
|
||
if MARGIN_SYMBOL_COUNT
|
||
<= value
|
||
< MARGIN_SYMBOL_COUNT + USEFUL_SYMBOL_COUNT
|
||
)
|
||
event_rows, boundary_relation = _event_hardware_rows(
|
||
digital["events"],
|
||
recovery["symbol_center_samples"],
|
||
callback_boundaries,
|
||
raw_samples,
|
||
)
|
||
if digital["classification"] == "A" and len(useful_positions) >= int(
|
||
0.95 * USEFUL_SYMBOL_COUNT
|
||
):
|
||
classification = "A"
|
||
reason = "Полезный цифровой счётчик восстановлен без скачков позиции"
|
||
elif digital["classification"] == "B":
|
||
classification = "B"
|
||
reason = "Подтверждены скачки позиции цифрового счётчика"
|
||
else:
|
||
classification = "D"
|
||
reason = "Недостаточно достоверно восстановленных полезных символов"
|
||
analysis = {
|
||
"classification": classification,
|
||
"reason": reason,
|
||
"digital_sequence_continuity": (
|
||
"ДА" if classification == "A" else "НЕТ" if classification == "B" else "НЕ ОПРЕДЕЛЕНО"
|
||
),
|
||
"raw_iq_continuity": "НЕ ОПРЕДЕЛЕНО",
|
||
"raw_iq_reason": (
|
||
"Цифровая последовательность доказывает порядок символов, но сама по себе не доказывает сохранность каждого I/Q-отсчёта"
|
||
),
|
||
"transmitted_symbol_count": TRANSMITTED_SYMBOL_COUNT,
|
||
"useful_transmitted_symbol_count": USEFUL_SYMBOL_COUNT,
|
||
"recovered_transmitted_position_count": len(transmitted_positions),
|
||
"recovered_useful_position_count": len(useful_positions),
|
||
"ordinary_symbol_error_count": digital["ordinary_symbol_error_count"],
|
||
"ordinary_symbol_error_count_status": (
|
||
"computed"
|
||
if digital["ordinary_symbol_error_count"] is not None
|
||
else "not_computed"
|
||
),
|
||
"confirmed_position_jump_count": len(event_rows),
|
||
"skip_event_count": sum(row["type"] == "пропуск" for row in event_rows),
|
||
"repeat_event_count": sum(row["type"] == "повтор" for row in event_rows),
|
||
"insertion_event_count": sum(row["type"] == "вставка" for row in event_rows),
|
||
"ambiguous_event_count": sum(row["type"] == "неоднозначно" for row in event_rows),
|
||
"carrier": recovery["carrier"],
|
||
"timing": recovery["timing"],
|
||
"phase": recovery["phase"],
|
||
"orientation": recovery["orientation"],
|
||
"constellation_separation": recovery["constellation_separation"],
|
||
"anchor_count": digital["anchor_count"],
|
||
"stable_run_count": digital["stable_run_count"],
|
||
"recovered_symbol_count": digital["recovered_symbol_count"],
|
||
"events": event_rows,
|
||
"runs": runs,
|
||
"callback_relation": boundary_relation,
|
||
}
|
||
private = {
|
||
"symbol_ids": recovery["symbol_ids"],
|
||
"symbol_center_samples": recovery["symbol_center_samples"],
|
||
"demodulated_symbols": recovery["demodulated_symbols"],
|
||
}
|
||
return analysis, private
|
||
|
||
|
||
def _write_csv(path: Path, rows: list[dict], fieldnames: list[str]) -> None:
|
||
with path.open("w", encoding="utf-8-sig", newline="") as stream:
|
||
writer = csv.DictWriter(stream, fieldnames=fieldnames, extrasaction="ignore")
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
|
||
|
||
def save_counter_artifacts(
|
||
analysis: dict,
|
||
private: dict,
|
||
output_directory: Path = PROCESSED_DIRECTORY,
|
||
) -> tuple[Path, ...]:
|
||
from matplotlib import pyplot as plt
|
||
|
||
output_directory.mkdir(parents=True, exist_ok=True)
|
||
summary = {
|
||
key: value
|
||
for key, value in analysis.items()
|
||
if key not in {"events", "runs", "carrier", "timing", "phase", "orientation", "constellation_separation", "callback_relation"}
|
||
}
|
||
summary.update(
|
||
{
|
||
"carrier_offset_hz": analysis["carrier"]["carrier_offset_hz"],
|
||
"carrier_peak_above_noise_db": analysis["carrier"]["peak_above_noise_db"],
|
||
"samples_per_symbol": analysis["timing"]["samples_per_symbol"],
|
||
"sample_clock_error_ppm": analysis["timing"][
|
||
"sample_clock_error_ppm_relative_to_nominal_sps"
|
||
],
|
||
}
|
||
)
|
||
_write_csv(output_directory / "summary.csv", [summary], list(summary))
|
||
event_fields = [
|
||
"received_symbol_index",
|
||
"expected_state_number",
|
||
"recognized_state_number",
|
||
"expected_next_position",
|
||
"actual_next_position",
|
||
"position_delta_symbols",
|
||
"minimum_confirmed_gap_symbols",
|
||
"type",
|
||
"estimated_raw_sample_index",
|
||
"nearest_callback_index",
|
||
"signed_distance_to_callback_samples",
|
||
"distance_category",
|
||
"distance_to_nearest_midpoint_samples",
|
||
"local_mean_amplitude",
|
||
"local_peak_amplitude",
|
||
"before_mean_amplitude",
|
||
"after_mean_amplitude",
|
||
"local_exact_zero_fraction",
|
||
"exact_lost_or_repeated_iq_samples",
|
||
"exact_iq_sample_count_status",
|
||
"exact_iq_sample_count_reason",
|
||
]
|
||
_write_csv(output_directory / "discontinuities.csv", analysis["events"], event_fields)
|
||
|
||
figure, axis = plt.subplots(figsize=(10, 4.5))
|
||
for run in analysis["runs"]:
|
||
axis.plot(
|
||
[run["first_anchor"], run["last_anchor"]],
|
||
[run["offset"], run["offset"]],
|
||
linewidth=3,
|
||
)
|
||
if not analysis["runs"]:
|
||
axis.text(
|
||
0.5,
|
||
0.5,
|
||
"Достоверные участки позиции не найдены",
|
||
ha="center",
|
||
va="center",
|
||
transform=axis.transAxes,
|
||
)
|
||
axis.set_title("Устойчивые участки карты позиции цифрового счётчика")
|
||
axis.set_xlabel("Индекс принятого символа")
|
||
axis.set_ylabel("Смещение позиции modulo периода")
|
||
axis.grid(True, alpha=0.3)
|
||
figure.tight_layout()
|
||
figure.savefig(output_directory / "position_map.png", dpi=150)
|
||
plt.close(figure)
|
||
|
||
constellation = np.asarray(private["demodulated_symbols"])
|
||
if len(constellation) > 20_000:
|
||
constellation = constellation[:: max(1, len(constellation) // 20_000)]
|
||
figure, axis = plt.subplots(figsize=(6, 6))
|
||
axis.scatter(constellation.real, constellation.imag, s=2, alpha=0.25)
|
||
axis.set_title(
|
||
"Мягкие символы: четыре состояния не выделены"
|
||
if analysis["classification"] == "D"
|
||
else "Восстановленные четыре комплексных состояния"
|
||
)
|
||
axis.set_xlabel("Синфазная составляющая I")
|
||
axis.set_ylabel("Квадратурная составляющая Q")
|
||
axis.set_aspect("equal", adjustable="box")
|
||
axis.grid(True, alpha=0.3)
|
||
figure.tight_layout()
|
||
figure.savefig(output_directory / "constellation.png", dpi=150)
|
||
plt.close(figure)
|
||
|
||
lines = [
|
||
"Lab043: цифровой счётчик непрерывности HackRF",
|
||
"",
|
||
f"Классификация: {analysis['classification']}.",
|
||
f"Причина: {analysis['reason']}.",
|
||
f"Передано символов: {analysis['transmitted_symbol_count']}, полезных: {analysis['useful_transmitted_symbol_count']}.",
|
||
f"Восстановлено полезных позиций: {analysis['recovered_useful_position_count']}.",
|
||
f"Обычных ошибок символов: {analysis['ordinary_symbol_error_count']}.",
|
||
f"Подтверждённых скачков позиции: {analysis['confirmed_position_jump_count']}.",
|
||
f"Пропусков: {analysis['skip_event_count']}; повторов: {analysis['repeat_event_count']}; вставок: {analysis['insertion_event_count']}.",
|
||
f"Непрерывность цифровой последовательности: {analysis['digital_sequence_continuity']}.",
|
||
f"Непрерывность сырых I/Q-отсчётов: {analysis['raw_iq_continuity']}.",
|
||
"",
|
||
"I/Q означает синфазные и квадратурные комплексные отсчёты.",
|
||
]
|
||
(output_directory / "report.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return tuple(sorted(output_directory.iterdir()))
|
||
|
||
|
||
def run_one_hardware_counter(
|
||
raw_directory: Path = RAW_DIRECTORY,
|
||
processed_directory: Path = PROCESSED_DIRECTORY,
|
||
) -> dict:
|
||
"""Выполнить ровно один разрешённый аппаратный опыт цифрового счётчика."""
|
||
|
||
from experiments import lab043_hackrf_hardware as hackrf
|
||
from experiments import lab043_pluto_to_rtlsdr as lab043
|
||
from experiments import lab043_prbs11_hardware as rtl_hardware
|
||
|
||
pluto_uri = rtl_hardware.discover_pluto_usb_uri()
|
||
pluto_preflight = rtl_hardware.preflight_pluto(pluto_uri)
|
||
hackrf_preflight = preflight_counter_receiver(hackrf)
|
||
pluto, tx_parameters = rtl_hardware.configure_pluto(pluto_uri)
|
||
validate_pluto_working_point(tx_parameters)
|
||
actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"])
|
||
waveform, waveform_parameters = build_counter_waveform(actual_tx_rate)
|
||
waveform_sha256 = hashlib.sha256(waveform.tobytes()).hexdigest()
|
||
device_waveform = lab043.to_pluto_tx_samples(waveform)
|
||
target_samples = math.ceil(
|
||
(
|
||
RX_LEADING_SECONDS
|
||
+ waveform_parameters["total_duration_seconds"]
|
||
+ RX_TRAILING_SECONDS
|
||
+ FIXED_TX_UPLOAD_MARGIN_SECONDS
|
||
)
|
||
* NOMINAL_SAMPLE_RATE
|
||
) + hackrf.HACKRF_EXPECTED_CALLBACK_BYTES // 2
|
||
capture_started = datetime.now(timezone.utc)
|
||
capture_holder = []
|
||
errors: list[str] = []
|
||
tx_timing: dict[str, float] = {}
|
||
|
||
with hackrf.HackrfRxDevice() as receiver:
|
||
rx_parameters = configure_counter_receiver(receiver)
|
||
|
||
def receive() -> None:
|
||
try:
|
||
capture_holder.append(receiver.capture(target_samples))
|
||
except Exception as error: # pragma: no cover - аппаратный путь
|
||
errors.append(f"RX {type(error).__name__}: {error}")
|
||
|
||
rx_thread = threading.Thread(target=receive, daemon=True)
|
||
rx_thread.start()
|
||
deadline = time.perf_counter() + 5.0
|
||
while time.perf_counter() < deadline:
|
||
if receiver.library.hackrf_is_streaming(receiver.device) == 1:
|
||
break
|
||
if errors:
|
||
break
|
||
time.sleep(0.01)
|
||
else:
|
||
raise RuntimeError("HackRF RX не подтвердил поток; Pluto TX не включён")
|
||
time.sleep(RX_LEADING_SECONDS)
|
||
tx_timing["tx_call_started_monotonic"] = time.perf_counter()
|
||
tx_timing["tx_buffer_hold_seconds"] = rtl_hardware.transmit_noncyclic_buffer_once(
|
||
pluto,
|
||
device_waveform,
|
||
actual_tx_rate,
|
||
)
|
||
tx_timing["tx_completed_monotonic"] = time.perf_counter()
|
||
rx_thread.join(timeout=hackrf.HACKRF_STREAM_TIMEOUT_SECONDS + 10.0)
|
||
if rx_thread.is_alive():
|
||
raise RuntimeError("Единственный непрерывный HackRF RX-сеанс не завершился")
|
||
if errors:
|
||
raise RuntimeError("; ".join(errors))
|
||
if len(capture_holder) != 1:
|
||
raise RuntimeError("HackRF не вернул единственный массив")
|
||
capture = capture_holder[0]
|
||
if len(capture.samples) != target_samples or not np.all(np.isfinite(capture.samples)):
|
||
raise RuntimeError("Размер или содержимое массива RX недействительны")
|
||
boundary_rows = [row.__dict__ for row in capture.boundaries]
|
||
overload = {
|
||
"peak_magnitude": float(np.max(np.abs(capture.samples))),
|
||
"clipped_component_fraction": float(
|
||
np.mean(
|
||
(np.abs(capture.samples.real * 128.0) >= 127)
|
||
| (np.abs(capture.samples.imag * 128.0) >= 127)
|
||
)
|
||
),
|
||
}
|
||
metadata = {
|
||
"experiment": "Lab043 HackRF digital continuity counter",
|
||
"capture_status": "captured",
|
||
"processing_status": "pending",
|
||
"processing_error": None,
|
||
"capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"),
|
||
"tx_parameters": tx_parameters,
|
||
"rx_parameters": rx_parameters | {
|
||
"serial_number": hackrf_preflight["serial_number"],
|
||
"firmware_version": hackrf_preflight["firmware_version"],
|
||
"libhackrf_version": hackrf_preflight["libhackrf_version"],
|
||
},
|
||
"pluto_preflight": pluto_preflight,
|
||
"hackrf_preflight": hackrf_preflight,
|
||
"physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> HackRF ANTENNA; HackRF RX only",
|
||
"waveform_sha256": waveform_sha256,
|
||
"waveform": {
|
||
"purpose": "цифровой счётчик времени, не измерение BER",
|
||
"alphabet": "four states (+/-1 +/- j)/sqrt(2)",
|
||
"lfsr_polynomial": LFSR_POLYNOMIAL,
|
||
"lfsr_degree": LFSR_DEGREE,
|
||
"lfsr_initial_state_hex": f"0x{LFSR_INITIAL_STATE:05X}",
|
||
"bit_period": LFSR_PERIOD_BITS,
|
||
"symbol_period": SYMBOL_PERIOD,
|
||
"minimum_position_symbols": MINIMUM_POSITION_SYMBOLS,
|
||
"verification_window_symbols": VERIFY_WINDOW_SYMBOLS,
|
||
**waveform_parameters,
|
||
},
|
||
"tx_waveform_duration_seconds": waveform_parameters["total_duration_seconds"],
|
||
"tx_timing": tx_timing,
|
||
"callback_count": capture.diagnostics["callback_count_total"],
|
||
"callback_boundaries": boundary_rows,
|
||
"continuous_rx": capture.diagnostics | {"boundaries": boundary_rows},
|
||
"expected_intervals": {
|
||
"rx_leading_seconds": RX_LEADING_SECONDS,
|
||
"rx_trailing_seconds": RX_TRAILING_SECONDS,
|
||
"fixed_tx_upload_margin_seconds": FIXED_TX_UPLOAD_MARGIN_SECONDS,
|
||
"useful_symbol_positions": [
|
||
MARGIN_SYMBOL_COUNT,
|
||
MARGIN_SYMBOL_COUNT + USEFUL_SYMBOL_COUNT,
|
||
],
|
||
},
|
||
"overload": overload,
|
||
}
|
||
stamp = capture_started.strftime("%Y%m%d_%H%M%S")
|
||
base = raw_directory / f"lab043_hackrf_counter_{stamp}"
|
||
iq_path, metadata_path, document = lab043.save_raw_iq_before_processing(
|
||
base,
|
||
capture.samples,
|
||
metadata,
|
||
)
|
||
callback_boundaries = capture.boundaries
|
||
continuous_rx = dict(capture.diagnostics)
|
||
del capture_holder, capture, device_waveform, waveform
|
||
gc.collect()
|
||
restored = np.load(iq_path, allow_pickle=False)
|
||
if hashlib.sha256(iq_path.read_bytes()).hexdigest() != document["iq_sha256"]:
|
||
raise RuntimeError("SHA-256 raw изменился до обработки")
|
||
try:
|
||
analysis, private = analyze_hardware_counter(
|
||
restored,
|
||
callback_boundaries,
|
||
NOMINAL_SAMPLE_RATE,
|
||
)
|
||
save_counter_artifacts(analysis, private, processed_directory)
|
||
except Exception as error:
|
||
message = f"{type(error).__name__}: {error}"
|
||
lab043.update_processing_metadata(
|
||
metadata_path,
|
||
processing_status="processing_failed",
|
||
processing_error=message,
|
||
)
|
||
return {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"iq_sha256": document["iq_sha256"],
|
||
"processing_status": "processing_failed",
|
||
"processing_error": message,
|
||
"continuous_rx": continuous_rx,
|
||
}
|
||
lab043.update_processing_metadata(
|
||
metadata_path,
|
||
processing_status="success",
|
||
processing_error=None,
|
||
analysis=analysis,
|
||
)
|
||
return {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"iq_sha256": document["iq_sha256"],
|
||
"processing_status": "success",
|
||
"tx_parameters": tx_parameters,
|
||
"rx_parameters": metadata["rx_parameters"],
|
||
"waveform": metadata["waveform"],
|
||
"overload": overload,
|
||
"continuous_rx": continuous_rx,
|
||
"analysis": analysis,
|
||
}
|
||
|
||
|
||
def reprocess_saved_counter(
|
||
metadata_path: Path,
|
||
processed_directory: Path = PROCESSED_DIRECTORY,
|
||
) -> dict:
|
||
"""Повторно обработать сохранённый raw без открытия SDR и без TX."""
|
||
|
||
from experiments import lab043_hackrf_hardware as hackrf
|
||
from experiments import lab043_pluto_to_rtlsdr as lab043
|
||
|
||
metadata_path = Path(metadata_path)
|
||
document = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||
iq_path = metadata_path.parent / document["iq_file"]
|
||
actual_sha = hashlib.sha256(iq_path.read_bytes()).hexdigest()
|
||
if not document.get("raw_verified") or actual_sha != document.get("iq_sha256"):
|
||
raise RuntimeError("Сохранённый raw не прошёл SHA-256 перед повторной обработкой")
|
||
samples = np.load(iq_path, allow_pickle=False)
|
||
if samples.dtype != np.dtype(np.complex64) or len(samples) != document["sample_count"]:
|
||
raise RuntimeError("Формат raw не совпал с первичным JSON")
|
||
boundaries = tuple(
|
||
hackrf.HackrfBoundary(**row) for row in document["callback_boundaries"]
|
||
)
|
||
try:
|
||
analysis, private = analyze_hardware_counter(
|
||
samples,
|
||
boundaries,
|
||
NOMINAL_SAMPLE_RATE,
|
||
)
|
||
save_counter_artifacts(analysis, private, processed_directory)
|
||
except Exception as error:
|
||
message = f"{type(error).__name__}: {error}"
|
||
lab043.update_processing_metadata(
|
||
metadata_path,
|
||
processing_status="processing_failed",
|
||
processing_error=message,
|
||
)
|
||
return {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"iq_sha256": actual_sha,
|
||
"processing_status": "processing_failed",
|
||
"processing_error": message,
|
||
}
|
||
lab043.update_processing_metadata(
|
||
metadata_path,
|
||
processing_status="success",
|
||
processing_error=None,
|
||
analysis=analysis,
|
||
)
|
||
return {
|
||
"iq_path": str(iq_path),
|
||
"metadata_path": str(metadata_path),
|
||
"iq_sha256": actual_sha,
|
||
"processing_status": "success",
|
||
"analysis": analysis,
|
||
}
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
group = parser.add_mutually_exclusive_group(required=True)
|
||
group.add_argument("--preflight-only", action="store_true")
|
||
group.add_argument("--run-one-counter", action="store_true")
|
||
group.add_argument("--reprocess-metadata", type=Path)
|
||
arguments = parser.parse_args()
|
||
if arguments.preflight_only:
|
||
from experiments import lab043_hackrf_hardware as hackrf
|
||
from experiments import lab043_prbs11_hardware as pluto_hardware
|
||
|
||
result = preflight_counter_hardware(hackrf, pluto_hardware)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0
|
||
result = (
|
||
reprocess_saved_counter(arguments.reprocess_metadata)
|
||
if arguments.reprocess_metadata is not None
|
||
else run_one_hardware_counter()
|
||
)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0 if result["processing_status"] == "success" else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|