Files
SDR-Rover/experiments/lab043_hackrf_counter.py

1398 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Диагностический цифровой счётчик непрерывности 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())