CLOCK-001: validate independent-clock QPSK receiver

This commit is contained in:
LittleSam129
2026-09-17 15:05:44 +03:00
parent 6df5f8aa39
commit 3c31bac0da
12 changed files with 4458 additions and 1 deletions

354
tools/clock001.py Normal file
View File

@@ -0,0 +1,354 @@
"""CLOCK-001: программная проверка QPSK при независимых тактах TX и RX."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import json
import math
from pathlib import Path
import statistics
import sys
import time
import numpy as np
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from protocol.packet import parse_packet
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame
from protocol.video005_iq_waveform import (
GUARD_SAMPLES,
NOMINAL_CARRIER_HZ,
SYMBOL_RATE,
rrc_taps,
shape_packet,
)
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
from tools.qpsk_rf_common import GAP_SAMPLES, SEED
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/clock001/results.json"
PAYLOAD_SIZES = (256, 512, 1012)
OFFSETS_PPM = (0, -1, 1, -5, 5, -10, 10, -20, 20, -50, 50, -100, 100)
PRIMARY_LIMIT_PPM = 50
PACKETS_PER_CASE = 3
@dataclass(frozen=True)
class ClockFixture:
iq: np.ndarray
expected_packets: dict[int, bytes]
starts: dict[int, int]
symbol_counts: dict[int, int]
payload_bytes: int
def payload_for_case(payload_bytes: int, sequence_number: int) -> bytes:
"""Расширяет фиксированную RF-001 SHA-последовательность до длины пакета."""
if not 0 <= sequence_number <= 0xFFFF or not 1 <= payload_bytes <= 1012:
raise ValueError("неверный номер или размер пакета")
prefix = SEED.to_bytes(8, "big") + sequence_number.to_bytes(2, "big")
blocks = math.ceil(payload_bytes / hashlib.sha256().digest_size)
return b"".join(
hashlib.sha256(prefix + block.to_bytes(2, "big")).digest()
for block in range(blocks)
)[:payload_bytes]
def build_fixture(payload_bytes: int, packet_count: int = PACKETS_PER_CASE) -> ClockFixture:
"""Собирает поток с RF-001 паузой; границы сохраняет только для оценки."""
if packet_count < 1 or packet_count > 100:
raise ValueError("число пакетов должно быть от 1 до 100")
separator = GAP_SAMPLES - 2 * GUARD_SAMPLES
if separator < 0:
raise AssertionError("пауза короче защитных интервалов")
waveforms = []
expected_packets = {}
starts = {}
symbol_counts = {}
position = 0
for sequence_number in range(packet_count):
frame = build_frame(payload_for_case(payload_bytes, sequence_number),
"QPSK", "2/3", sequence_number=sequence_number)
waveform = shape_packet(frame)
starts[sequence_number] = position
expected_packets[sequence_number] = frame.packet
symbol_counts[sequence_number] = len(frame.symbols)
waveforms.append(waveform)
position += len(waveform) + (separator if sequence_number < packet_count - 1 else 0)
iq = np.zeros(position, dtype=np.complex64)
for sequence_number, waveform in enumerate(waveforms):
start = starts[sequence_number]
iq[start:start + len(waveform)] = waveform
return ClockFixture(iq, expected_packets, starts, symbol_counts, payload_bytes)
def sample_independent_clock(
tx_iq: np.ndarray,
sample_clock_offset_ppm: float,
*,
cfo_hz: float = 0.0,
initial_phase_degrees: float = 0.0,
ebn0_db: float | None = None,
seed: int = SEED,
) -> np.ndarray:
"""Выбирает TX-волну в моменты АЦП Fs_RX=Fs_TX·(1+ppm·1e-6).
Дробная интерполяция меняет временную шкалу по всему потоку. CFO задаётся
отдельно в герцах физического времени RX; затем добавляется AWGN.
"""
if (tx_iq.ndim != 1 or not np.iscomplexobj(tx_iq) or tx_iq.size < 2
or not np.all(np.isfinite(tx_iq))):
raise ValueError("нужен конечный одномерный комплексный TX IQ")
if not all(math.isfinite(value) for value in (
sample_clock_offset_ppm, cfo_hz, initial_phase_degrees)):
raise ValueError("параметры такта и несущей должны быть конечными")
if abs(sample_clock_offset_ppm) > 1000:
raise ValueError("модель ограничена диапазоном ±1000 ppm")
if ebn0_db is not None and not math.isfinite(ebn0_db):
raise ValueError("Eb/N0 должно быть конечным")
scale = 1 + sample_clock_offset_ppm * 1e-6
output_count = math.floor((tx_iq.size - 1) * scale) + 1
received = np.empty(output_count, dtype=np.complex64)
# Обрабатываем длинные потоки частями, сохраняя единую временную шкалу.
for start in range(0, output_count, 1_000_000):
stop = min(start + 1_000_000, output_count)
positions = np.arange(start, stop, dtype=np.float64) / scale
lower = np.floor(positions).astype(np.int64)
fraction = positions - lower
upper = np.minimum(lower + 1, tx_iq.size - 1)
part = (tx_iq[lower] * (1 - fraction)
+ tx_iq[upper] * fraction).astype(np.complex64)
if cfo_hz or initial_phase_degrees:
physical_time = np.arange(start, stop, dtype=np.float64) / (SAMPLE_RATE_HZ * scale)
phase = math.radians(initial_phase_degrees) + 2 * math.pi * cfo_hz * physical_time
part *= np.exp(1j * phase).astype(np.complex64)
received[start:stop] = part
if ebn0_db is not None:
sigma = math.sqrt(1 / (2 * (2 / 3) * 10 ** (ebn0_db / 10)) / 2)
random_generator = np.random.default_rng(seed)
for start in range(0, output_count, 1_000_000):
stop = min(start + 1_000_000, output_count)
received[start:stop] += (sigma * (
random_generator.standard_normal(stop - start)
+ 1j * random_generator.standard_normal(stop - start)
)).astype(np.complex64)
return received
def _median(values: list[float]) -> float | None:
return float(statistics.median(values)) if values else None
def summarize_case(
result: IqStreamResult,
fixture: ClockFixture,
*,
sample_clock_offset_ppm: float,
cfo_hz: float,
initial_phase_degrees: float,
ebn0_db: float | None,
receiver_time_s: float,
received_samples: int,
) -> dict:
"""Сравнивает строгий выход приёмника с эталоном после декодирования."""
scale = 1 + sample_clock_offset_ppm * 1e-6
tx_filter_delay = (len(rrc_taps()) - 1) / 2
rx_filter_delay = tx_filter_delay
samples_per_symbol = SAMPLE_RATE_HZ // SYMBOL_RATE
effective_cfo_hz = (NOMINAL_CARRIER_HZ + cfo_hz) / scale - NOMINAL_CARRIER_HZ
exact_sequences: set[int] = set()
seen: set[int] = set()
duplicates: list[int] = []
unexpected: list[int] = []
initial_errors = []
final_errors = []
drift_values = []
cfo_errors = []
for packet in result.packets:
sequence_number = parse_packet(packet.packet).sequence_number
if sequence_number in seen:
duplicates.append(sequence_number)
seen.add(sequence_number)
expected = fixture.expected_packets.get(sequence_number)
if expected is None or packet.packet != expected:
unexpected.append(sequence_number)
continue
exact_sequences.add(sequence_number)
tx_preamble = fixture.starts[sequence_number] + GUARD_SAMPLES + tx_filter_delay
ideal_rx_start = tx_preamble * scale + rx_filter_delay
initial_error = (packet.start_sample - ideal_rx_start) / samples_per_symbol
symbol_intervals = fixture.symbol_counts[sequence_number] - 1
final_error = initial_error - symbol_intervals * sample_clock_offset_ppm * 1e-6
initial_errors.append(initial_error)
final_errors.append(final_error)
drift_values.append(final_error - initial_error)
cfo_errors.append(packet.carrier_offset_hz - effective_cfo_hz)
packet_count = len(fixture.expected_packets)
modeled_drift = -(next(iter(fixture.symbol_counts.values())) - 1) * sample_clock_offset_ppm * 1e-6
# Приёмник выдаёт только CRC-valid пакеты. Отдельной стадии "пакет до CRC"
# в его API нет; число корреляционных кандидатов указано отдельно.
return {
"sample_clock_offset_ppm": sample_clock_offset_ppm,
"cfo_hz": cfo_hz,
"effective_cfo_hz_at_nominal_rx_rate": effective_cfo_hz,
"initial_phase_degrees": initial_phase_degrees,
"ebn0_db": ebn0_db,
"payload_bytes": fixture.payload_bytes,
"tx_packets": packet_count,
"correlation_candidates": result.candidate_count,
"detected_packets": len(result.packets),
"crc_valid_packets": len(result.packets),
"exact_packets": len(exact_sequences),
"packet_success_rate": len(exact_sequences) / packet_count,
"exact_sequences": sorted(exact_sequences),
"missing_sequences": sorted(fixture.expected_packets.keys() - exact_sequences),
"duplicate_sequences": duplicates,
"unexpected_or_wrong_sequences": unexpected,
"initial_timing_error_symbols_median": _median(initial_errors),
"final_timing_error_symbols_median": _median(final_errors),
"timing_drift_across_packet_symbols_median": _median(drift_values),
"injected_timing_drift_across_packet_symbols": modeled_drift,
"maximum_absolute_final_timing_error_symbols": (
max(map(abs, final_errors)) if final_errors else None
),
"cfo_estimate_error_hz_median": _median(cfo_errors),
"timing_error_method": "accepted-packet start versus injected time base; final error extrapolated with fixed 16-sample step",
"reacquisition_per_packet": True,
"continuous_timing_tracking_used": False,
"receiver_time_s": receiver_time_s,
"received_duration_s": received_samples / (SAMPLE_RATE_HZ * scale),
"receiver_realtime_factor": receiver_time_s / (received_samples / (SAMPLE_RATE_HZ * scale)),
}
def evaluate_case(fixture: ClockFixture, *, ppm: float, cfo_hz: float = 0.0,
phase_degrees: float = 0.0, ebn0_db: float | None = None,
seed: int = SEED) -> dict:
rx_iq = sample_independent_clock(
fixture.iq, ppm, cfo_hz=cfo_hz, initial_phase_degrees=phase_degrees,
ebn0_db=ebn0_db, seed=seed,
)
started = time.perf_counter()
result = receive_iq_stream(rx_iq)
elapsed = time.perf_counter() - started
return summarize_case(result, fixture, sample_clock_offset_ppm=ppm,
cfo_hz=cfo_hz, initial_phase_degrees=phase_degrees,
ebn0_db=ebn0_db, receiver_time_s=elapsed,
received_samples=len(rx_iq))
def run() -> dict:
"""Снимает отдельную ppm-сетку, короткие комбинации и прогретую скорость."""
fixtures = {size: build_fixture(size) for size in PAYLOAD_SIZES}
grid = [evaluate_case(fixtures[size], ppm=ppm)
for size in PAYLOAD_SIZES for ppm in OFFSETS_PPM]
combinations = [
(5, 20, 15, 9), (5, 20, 15, 12),
(10, 100, 90, 9), (10, 100, 90, 12),
(20, 100, -45, 9), (20, 100, -45, 12),
]
combined = [evaluate_case(fixtures[256], ppm=ppm, cfo_hz=cfo,
phase_degrees=phase, ebn0_db=ebn0,
seed=SEED + index)
for index, (ppm, cfo, phase, ebn0) in enumerate(combinations)]
single_long_fixture = build_fixture(1012, packet_count=1)
single_long_stress = [evaluate_case(single_long_fixture, ppm=ppm)
for ppm in (-100, 100)]
from tools.video005_benchmark import build_radio_fixture
benchmark_packets, clean_benchmark_iq, _ = build_radio_fixture()
benchmark_iq = sample_independent_clock(clean_benchmark_iq, 20, cfo_hz=20,
initial_phase_degrees=15, ebn0_db=9)
benchmark_times = []
benchmark_exact = []
for _ in range(3):
started = time.perf_counter()
received = receive_iq_stream(benchmark_iq)
benchmark_times.append(time.perf_counter() - started)
observed = {parse_packet(packet.packet).sequence_number: packet.packet
for packet in received.packets}
benchmark_exact.append(sum(observed.get(index) == packet
for index, packet in enumerate(benchmark_packets)))
duration = len(benchmark_iq) / (SAMPLE_RATE_HZ * (1 + 20e-6))
benchmark_all_exact = all(count == len(benchmark_packets) for count in benchmark_exact)
primary = [case for case in grid if abs(case["sample_clock_offset_ppm"]) <= PRIMARY_LIMIT_PPM]
primary_all_exact = all(case["exact_packets"] == case["tx_packets"] for case in primary)
stress = [case for case in grid if abs(case["sample_clock_offset_ppm"]) > PRIMARY_LIMIT_PPM]
stress_all_exact = all(case["exact_packets"] == case["tx_packets"] for case in stress)
all_combined_exact = all(case["exact_packets"] == case["tx_packets"] for case in combined)
symmetric_limits = {}
for size in PAYLOAD_SIZES:
maximum = 0
for offset in (1, 5, 10, 20, 50, 100):
pair = [case for case in grid if case["payload_bytes"] == size
and abs(case["sample_clock_offset_ppm"]) == offset]
if len(pair) != 2 or any(case["exact_packets"] != case["tx_packets"] for case in pair):
break
maximum = offset
symmetric_limits[str(size)] = maximum
return {
"status": ("PASS" if primary_all_exact and stress_all_exact
and all_combined_exact and benchmark_all_exact else "PARTIAL"),
"experiment": "CLOCK-001 INDEPENDENT TX/RX CLOCK READINESS",
"seed": SEED,
"sample_rate_nominal_hz": SAMPLE_RATE_HZ,
"symbol_rate": SYMBOL_RATE,
"fec_rate": "2/3",
"packets_per_case": PACKETS_PER_CASE,
"payload_sizes": PAYLOAD_SIZES,
"offsets_ppm": OFFSETS_PPM,
"primary_limit_ppm": PRIMARY_LIMIT_PPM,
"grid": grid,
"combined": combined,
"single_long_stress": single_long_stress,
"primary_grid_all_exact": primary_all_exact,
"stress_grid_all_exact": stress_all_exact,
"combined_all_exact": all_combined_exact,
"benchmark_all_exact": benchmark_all_exact,
"maximum_symmetric_ppm_with_all_exact_by_payload": symmetric_limits,
"maximum_symmetric_ppm_with_all_payloads_exact": min(symmetric_limits.values()),
"per_packet_reacquisition_sufficient_for_primary_grid": primary_all_exact,
"continuous_tracking_required_for_primary_grid": not primary_all_exact,
"intra_packet_timing_correction_needed_to_cover_long_packet_stress": not stress_all_exact,
"benchmark": {
"description": "VIDEO-005 32 long packets; independent clocks +20 ppm, CFO 20 Hz, phase 15 deg, Eb/N0 9 dB",
"iq_duration_s": duration,
"warm_receiver_times_s": benchmark_times,
"warm_realtime_factor": statistics.median(benchmark_times) / duration,
"exact_packets_each_run": benchmark_exact,
"tx_packets": len(benchmark_packets),
},
"rf_tx": 0,
"sdr_hardware": "NOT_ACCESSED",
"hardware_status": "NOT_RUN",
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--numba-packages", type=Path)
parser.add_argument("--report", type=Path, default=OUTPUT)
args = parser.parse_args()
if args.numba_packages is not None:
if not args.numba_packages.is_dir():
parser.error("каталог Numba недоступен")
sys.path.insert(0, str(args.numba_packages.resolve()))
if args.report.exists():
parser.error("отчёт уже существует; выберите новый путь")
report = run()
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(f"CLOCK-001: {report['status']}; primary_all_exact={report['primary_grid_all_exact']}; "
f"combined_all_exact={report['combined_all_exact']}; "
f"realtime_factor={report['benchmark']['warm_realtime_factor']:.3f}; "
"RF TX=0")
if __name__ == "__main__":
main()

69
tools/qpsk_rf_common.py Normal file
View File

@@ -0,0 +1,69 @@
"""Формат файлов и воспроизводимые пакеты для подготовки RF-001 без SDR."""
from __future__ import annotations
import hashlib
from pathlib import Path
import numpy as np
from protocol.video003_modem import SAMPLE_RATE_HZ
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE
TEST_ID = "RF001_QPSK_500K_R23_256B_100P_GAP5MS"
SEED = 20260917
PAYLOAD_BYTES = 256
PACKET_COUNT = 100
GAP_MS = 5
GAP_SAMPLES = SAMPLE_RATE_HZ * GAP_MS // 1000
IQ_FORMAT = "c64le"
def payload_for_sequence(seed: int, sequence_number: int) -> bytes:
"""Создаёт 256 байт через SHA-256, не завися от версии генератора NumPy."""
if not 0 <= seed < 2**64 or not 0 <= sequence_number <= 0xFFFF:
raise ValueError("seed или номер пакета вне допустимого диапазона")
prefix = seed.to_bytes(8, "big") + sequence_number.to_bytes(2, "big")
return b"".join(
hashlib.sha256(prefix + block.to_bytes(2, "big")).digest()
for block in range(PAYLOAD_BYTES // hashlib.sha256().digest_size)
)
def sha256_file(path: Path) -> str:
"""Хеширует файл потоково, не копируя весь IQ в память."""
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1 << 20):
digest.update(chunk)
return digest.hexdigest()
def read_iq_file(path: Path, iq_format: str = IQ_FORMAT) -> tuple[np.ndarray, int | None]:
"""Читает файловый IQ; `cs8` сохраняет исходный файл без изменений."""
size = path.stat().st_size
if iq_format == "c64le":
if size == 0 or size % 8:
raise ValueError("c64le должен содержать целое ненулевое число отсчётов")
return np.memmap(path, dtype="<c8", mode="r"), None
if iq_format == "cs8":
if size == 0 or size % 2:
raise ValueError("cs8 должен содержать целое ненулевое число пар I/Q")
raw = np.memmap(path, dtype=np.int8, mode="r")
clipping = int(np.count_nonzero((raw == -128) | (raw == 127)))
iq = ((raw[0::2].astype(np.float32) + 1j * raw[1::2].astype(np.float32))
/ 128).astype(np.complex64)
return iq, clipping
raise ValueError("поддерживаются только c64le и cs8")
def radio_mode() -> dict[str, int | float | str]:
"""Параметры номинального режима, которые разрешено знать приёмнику."""
return {
"modulation": "QPSK",
"symbol_rate": SYMBOL_RATE,
"sample_rate": SAMPLE_RATE_HZ,
"fec_rate": "2/3",
"digital_carrier_offset_hz": NOMINAL_CARRIER_HZ,
}

302
tools/qpsk_rf_rx.py Normal file
View File

@@ -0,0 +1,302 @@
"""RF-001: файловый IQ → существующий VIDEO-005 receiver → отчёт после CRC."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import statistics
import sys
from typing import Any
import numpy as np
from scipy.signal import oaconvolve
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from protocol.packet import build_packet, parse_packet
from protocol import punctured_fec
from protocol.video003_modem import (
PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, SAMPLE_RATE_HZ, receive_frame,
)
from protocol.video005_carrier import (
correct_pilot_estimate, estimate_from_preamble, track_qpsk_body,
)
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, rrc_taps
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
from protocol.video005_timing import sample_symbols
from tools.qpsk_rf_common import (
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID,
payload_for_sequence, read_iq_file, sha256_file,
)
def _bit_errors(observed: bytes, expected: bytes) -> tuple[int, int]:
"""Считает ошибки только для сопоставленных пакетов равной длины."""
if len(observed) != len(expected):
return 0, 0
return sum((left ^ right).bit_count() for left, right in zip(
observed, expected, strict=True
)), len(expected) * 8
def _raw_ber_after_decode(
iq: np.ndarray,
reception: IqStreamResult,
seed: int,
decoder_backend: str,
) -> tuple[int, int, int]:
"""Повторяет штатные PHY-функции для BER только после строгого первого приёма.
Метрика относится к CRC-valid кандидатам. Границы отвергнутых кандидатов
VIDEO-005 не возвращает; их биты здесь не выдумываются.
"""
period = round(SAMPLE_RATE_HZ / NOMINAL_CARRIER_HZ)
cycle = np.exp(-2j * np.pi * np.arange(period) / period).astype(np.complex64)
baseband = np.asarray(iq, dtype=np.complex64) * np.resize(cycle, iq.size)
matched = oaconvolve(baseband, rrc_taps().astype(np.float32), mode="full")
if decoder_backend == "numba":
from protocol.video004_viterbi import decode_soft as fec_decoder
else:
fec_decoder = punctured_fec.decode_soft
bit_errors = 0
bits_compared = 0
replay_failures = 0
for packet in reception.packets:
sequence_number = parse_packet(packet.packet).sequence_number
coded_count = punctured_fec.coded_bit_count(len(packet.packet) * 8, "2/3")
symbol_count = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + (coded_count + 1) // 2
symbols = sample_symbols(matched, packet.start_sample, 16, symbol_count)
if len(symbols) != symbol_count:
replay_failures += 1
continue
estimate = estimate_from_preamble(symbols, SYMBOL_RATE)
corrected = correct_pilot_estimate(symbols, estimate)
tracked, _ = track_qpsk_body(corrected)
replay = receive_frame(tracked, "QPSK", "2/3", SYMBOL_RATE,
maximum_start=0, fec_decoder=fec_decoder)
if replay.packet != packet.packet or replay.coded_soft is None:
replay_failures += 1
continue
from protocol.video003_modem import build_frame
expected_bits = build_frame(payload_for_sequence(seed, sequence_number),
"QPSK", "2/3", sequence_number).coded_bits
observed_bits = (replay.coded_soft >= 0).astype(np.uint8)
if len(observed_bits) != len(expected_bits):
replay_failures += 1
continue
bit_errors += int(np.count_nonzero(observed_bits != expected_bits))
bits_compared += len(expected_bits)
return bit_errors, bits_compared, replay_failures
def summarize_result(
reception: IqStreamResult,
*,
iq_sample_count: int,
iq_sha256: str,
iq_format: str,
clipping_codes: int | None,
truth: dict[str, Any] | None,
raw_bits: tuple[int, int, int] | None = None,
) -> dict[str, Any]:
"""Выполняет сравнение с эталоном только после завершения IQ-приёма."""
rows = []
observed_by_sequence: dict[int, bytes] = {}
for packet in reception.packets:
parsed = parse_packet(packet.packet)
observed_by_sequence.setdefault(parsed.sequence_number, packet.packet)
rows.append({
"sequence_number": parsed.sequence_number,
"packet_sha256": hashlib.sha256(packet.packet).hexdigest(),
"payload_sha256": hashlib.sha256(parsed.payload).hexdigest(),
"start_sample": packet.start_sample,
"preamble_score": packet.preamble_score,
"cfo_hz": packet.carrier_offset_hz,
"phase_rad": packet.initial_phase_rad,
"timing_phase_symbols": packet.timing_phase_symbols,
"qpsk_quadrant": packet.quadrant,
"tracking_error_rms_rad": packet.tracking_error_rms_rad,
})
sequences = [row["sequence_number"] for row in rows]
unique_sequences = set(sequences)
cfo = [row["cfo_hz"] for row in rows]
report: dict[str, Any] = {
"rf001_status": "PREPARED_NOT_RUN",
"test_id": truth.get("test_id") if truth is not None else None,
"modulation": "QPSK",
"symbol_rate": 500_000,
"inner_fec": "2/3",
"payload_bytes": 256,
"inter_packet_gap_ms": 5,
"tx_packets": truth.get("packet_count") if truth is not None else None,
"iq_format": iq_format,
"iq_sample_count": iq_sample_count,
"iq_sha256": iq_sha256,
"detected_preambles": reception.candidate_count,
"rejected_candidates": reception.rejected_candidates,
"decoded_packets": len(rows),
"crc_valid_packets": len(rows),
"packet_sequences": sequences,
"duplicates": len(sequences) - len(unique_sequences),
"missing_packets": None,
"exact_packets": None,
"packet_success_rate": None,
"raw_ber": None,
"raw_ber_scope": "UNAVAILABLE_FOR_REJECTED_CANDIDATES_FROM_VIDEO005_API",
"raw_bit_errors_on_crc_valid_packets": None,
"raw_bits_compared_on_crc_valid_packets": None,
"raw_ber_on_crc_valid_packets": None,
"raw_ber_replay_failures": None,
"post_fec_bit_errors": None,
"post_fec_bits_compared": None,
"post_fec_error_rate": None,
"post_fec_error_rate_on_compared_packets": None,
"post_fec_uncomparable_packets": None,
"cfo_median_hz": statistics.median(cfo) if cfo else None,
"cfo_p95_absolute_hz": float(np.percentile(np.abs(cfo), 95)) if cfo else None,
"phase_error_rad": None,
"timing_offset_symbols": None,
"phase_and_timing_estimates": rows,
"tx_underruns": "NOT_RUN",
"rx_overruns": "NOT_RUN",
"rx_clipping_rail_codes": clipping_codes if clipping_codes is not None else "NOT_APPLICABLE",
"common_10mhz_clock": "NOT_RUN",
"sdr_hardware": "NOT_ACCESSED",
"rf_tx": 0,
}
if truth is None:
return report
expected_rows = truth["expected_packet_hashes"]
expected = {int(item["sequence_number"]): item["packet_sha256"]
for item in expected_rows}
if (len(expected) != len(expected_rows) or len(expected) != truth["packet_count"]
or set(expected) != set(range(PACKET_COUNT))):
raise ValueError("ведомость содержит повторные или недостающие номера")
exact = {
row["sequence_number"] for row in rows
if expected.get(row["sequence_number"]) == row["packet_sha256"]
}
missing = sorted(expected.keys() - unique_sequences)
seed = int(truth["seed"])
if (truth["packet_count"] != PACKET_COUNT
or truth["application_payload_bytes"] != PAYLOAD_BYTES
or truth["seed"] != SEED):
raise ValueError("ведомость не соответствует каноническому RF-001")
for sequence_number, digest in expected.items():
reference = build_packet(payload_for_sequence(seed, sequence_number),
message_type=3, sequence_number=sequence_number)
if hashlib.sha256(reference).hexdigest() != digest:
raise ValueError("эталонный SHA пакета не совпадает с seed")
errors = 0
compared = 0
uncomparable = 0
for sequence_number, observed in observed_by_sequence.items():
if sequence_number not in expected:
continue
reference = build_packet(payload_for_sequence(seed, sequence_number),
message_type=3, sequence_number=sequence_number)
packet_errors, packet_bits = _bit_errors(observed, reference)
if packet_bits == 0:
uncomparable += 1
errors += packet_errors
compared += packet_bits
complete_post_fec = (unique_sequences == expected.keys()
and len(sequences) == truth["packet_count"]
and uncomparable == 0)
report.update({
"missing_packets": missing,
"unexpected_sequences": sorted(unique_sequences - expected.keys()),
"exact_packets": len(exact),
"packet_success_rate": len(exact) / truth["packet_count"],
"post_fec_bit_errors": errors,
"post_fec_bits_compared": compared,
"post_fec_error_rate": errors / compared if compared and complete_post_fec else None,
"post_fec_error_rate_on_compared_packets": errors / compared if compared else None,
"post_fec_uncomparable_packets": uncomparable,
"same_bytes_as_tx_waveform": iq_sha256 == truth.get("iq_sha256"),
})
if raw_bits is not None:
raw_errors, raw_count, replay_failures = raw_bits
conditional_ber = raw_errors / raw_count if raw_count else None
complete = (unique_sequences == expected.keys()
and len(sequences) == truth["packet_count"]
and replay_failures == 0)
report.update({
"raw_bit_errors_on_crc_valid_packets": raw_errors,
"raw_bits_compared_on_crc_valid_packets": raw_count,
"raw_ber_on_crc_valid_packets": conditional_ber,
"raw_ber_replay_failures": replay_failures,
"raw_ber": conditional_ber if complete else None,
"raw_ber_scope": ("ALL_EXPECTED_PACKETS" if complete else
"CRC_VALID_PACKETS_ONLY; FULL_RAW_BER_UNAVAILABLE"),
})
return report
def decode_file(
iq_path: Path,
*,
iq_format: str = "c64le",
truth_metadata_path: Path | None = None,
decoder_backend: str = "numba",
) -> dict[str, Any]:
"""Открывает только файл; truth читается после вызова рабочего приёмника."""
iq, clipping_codes = read_iq_file(iq_path, iq_format)
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
truth = (json.loads(truth_metadata_path.read_text(encoding="utf-8"))
if truth_metadata_path is not None else None)
if truth is not None and (
truth.get("schema_version") != 1
or truth.get("test_id") != TEST_ID
or truth.get("modulation") != "QPSK"
or truth.get("symbol_rate") != SYMBOL_RATE
or truth.get("sample_rate") != SAMPLE_RATE_HZ
or truth.get("fec_rate") != "2/3"
or truth.get("digital_carrier_offset_hz") != NOMINAL_CARRIER_HZ
or truth.get("packet_count") != PACKET_COUNT
or truth.get("application_payload_bytes") != PAYLOAD_BYTES
or truth.get("inter_packet_gap_ms") != GAP_MS
or truth.get("seed") != SEED
):
raise ValueError("TX-ведомость не соответствует режиму RF-001")
raw_bits = (_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
if truth is not None else None)
return summarize_result(
reception,
iq_sample_count=int(iq.size),
iq_sha256=sha256_file(iq_path),
iq_format=iq_format,
clipping_codes=clipping_codes,
truth=truth,
raw_bits=raw_bits,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--iq-file", required=True, type=Path)
parser.add_argument("--iq-format", choices=("c64le", "cs8"), default="c64le")
parser.add_argument("--truth-metadata", type=Path)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--numba-packages", type=Path)
arguments = parser.parse_args()
if arguments.report.exists():
parser.error("отчёт уже существует; перезапись запрещена")
if arguments.numba_packages is not None:
if not arguments.numba_packages.is_dir():
parser.error("каталог пакетов Numba отсутствует")
sys.path.insert(0, str(arguments.numba_packages.resolve()))
result = decode_file(arguments.iq_file, iq_format=arguments.iq_format,
truth_metadata_path=arguments.truth_metadata)
arguments.report.parent.mkdir(parents=True, exist_ok=True)
arguments.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(f"RF-001 FILE RX: CRC={result['crc_valid_packets']}; "
f"exact={result['exact_packets']}; RF TX=0")
if __name__ == "__main__":
main()

107
tools/qpsk_rf_tx.py Normal file
View File

@@ -0,0 +1,107 @@
"""RF-001: построение 100 QPSK-пакетов и IQ только в режиме dry-run."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import sys
import numpy as np
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame
from protocol.video005_iq_waveform import GUARD_SAMPLES, shape_packet
from tools.qpsk_rf_common import (
GAP_MS,
GAP_SAMPLES,
IQ_FORMAT,
PACKET_COUNT,
PAYLOAD_BYTES,
SEED,
TEST_ID,
payload_for_sequence,
radio_mode,
sha256_file,
)
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT / "data/raw/rf001/offline_qpsk_100_packets"
def build_dry_run() -> tuple[np.ndarray, dict]:
"""Собирает ровно утверждённый режим; границы остаются только в TX-ведомости."""
separator = GAP_SAMPLES - 2 * GUARD_SAMPLES
if separator < 0:
raise AssertionError("пауза короче двух защитных интервалов RRC")
waveforms = []
expected = []
starts = []
position = 0
for sequence_number in range(PACKET_COUNT):
payload = payload_for_sequence(SEED, sequence_number)
frame = build_frame(payload, "QPSK", "2/3", sequence_number=sequence_number)
waveform = shape_packet(frame)
starts.append(position)
waveforms.append(waveform)
expected.append({
"sequence_number": sequence_number,
"payload_sha256": hashlib.sha256(payload).hexdigest(),
"packet_sha256": hashlib.sha256(frame.packet).hexdigest(),
})
position += waveform.size + (separator if sequence_number < PACKET_COUNT - 1 else 0)
iq = np.zeros(position, dtype="<c8")
for index, waveform in enumerate(waveforms):
iq[starts[index]:starts[index] + waveform.size] = waveform
metadata = {
"schema_version": 1,
"test_id": TEST_ID,
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
**radio_mode(),
"application_payload_bytes": PAYLOAD_BYTES,
"packet_count": PACKET_COUNT,
"inter_packet_gap_ms": GAP_MS,
"inter_packet_gap_samples": GAP_SAMPLES,
"inter_array_zero_samples": separator,
"seed": SEED,
"iq_format": IQ_FORMAT,
"dtype": "<c8",
"waveform_sample_count": int(iq.size),
"waveform_duration_s": iq.size / SAMPLE_RATE_HZ,
"packet_waveform_start_samples": starts,
"expected_packet_hashes": expected,
"rf_tx": 0,
"sdr_hardware": "NOT_ACCESSED",
"status": "PREPARED_NOT_RUN",
}
return iq, metadata
def write_dry_run(output_directory: Path = DEFAULT_OUTPUT) -> tuple[Path, Path]:
"""Создаёт новый каталог; существующие IQ и метаданные не перезаписывает."""
output_directory.mkdir(parents=True, exist_ok=False)
iq, metadata = build_dry_run()
iq_path = output_directory / "tx_waveform.c64le"
metadata_path = output_directory / "tx_metadata.json"
iq.tofile(iq_path)
metadata["iq_sha256"] = sha256_file(iq_path)
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
return iq_path, metadata_path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT)
arguments = parser.parse_args()
iq_path, metadata_path = write_dry_run(arguments.output_dir)
print(f"RF-001 DRY-RUN: {iq_path}; metadata={metadata_path}; RF TX=0")
if __name__ == "__main__":
main()