"""Диагностический цифровой счётчик непрерывности 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())