From b6654671b41c9cc68cdcd24ea1ec8117da880433 Mon Sep 17 00:00:00 2001 From: LittleSam129 <1qwe432@gmail.com> Date: Tue, 1 Sep 2026 15:42:00 +0300 Subject: [PATCH] Lab046C: record first continuous RF object attempt --- PROJECT_LOG.md | 104 +++ docs/lab046_current_state.md | 110 ++- experiments/lab046c_hackrf_bpsk_object.py | 886 ++++++++++++++++++++++ tests/test_lab046c_hackrf_bpsk_object.py | 350 +++++++++ 4 files changed, 1441 insertions(+), 9 deletions(-) create mode 100644 experiments/lab046c_hackrf_bpsk_object.py create mode 100644 tests/test_lab046c_hackrf_bpsk_object.py diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 078c42d..eb71c18 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -779,3 +779,107 @@ Lab046B: программная проверка нескольких полны ## Следующий этап Сначала ChatGPT анализирует Lab046B. Аппаратную Lab046, JPEG, video, QPSK, 12+3 erasure FEC, ARQ и адаптивную модуляцию автоматически не начинать. + +# Запись 028 + +## Дата + +1 сентября 2026 года + +## Тема + +Подготовка одного непрерывного аппаратного BPSK-сеанса Lab046C без RF-передачи. + +## Выполнено + +Проведён аудит фактических Lab043--Lab046B. Новый радиопротокол не создавался: +Lab046C переиспользует общий clock-gate и однократный TX guard Lab043--Lab045, +физический FEC/CRC-кадр Lab045, фрагментацию и `ObjectAssembler` Lab046, +локальный многокадровый IQ-поиск Lab046B. + +Подготовлен `experiments/lab046c_hackrf_bpsk_object.py`. Он создаёт новый +криптографически случайный объект 16384 байта, последовательно формирует один +cs8-файл из 68 waveform без искусственных gaps и фиксирует SHA-256. Расчёт по +фактическому коду: 2 306 400 samples на кадр, 156 835 200 samples и +313 670 400 bytes всего, 19,6044 с при 8 MS/s. + +Будущий приём запланирован на 24,0 с: 192 000 000 cs8 samples и +384 000 000 bytes. Файл читается через memmap окнами в `complex64`; RX API не +принимает TX offsets, длины waveform, ожидаемые 68 фрагментов, TX packets, +coded bits или объект. Завершённость определяется только принятыми метаданными +и `ObjectAssembler` после CRC. + +Аппаратная последовательность fail-closed: фиксированные MASTER/SLAVE serial, +common-clock gate, RX раньше TX, ровно один вызов guard перед одним TX process, +cleanup CLKOUT/processes в `finally`, без retry. Отдельный `--preflight-only` +не может выполнить TX. Реальный аппаратный Lab046C не запускался, `RF TX = 0`. + +Фактически создан внешний каталог +`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260901_145339_bpsk_object`. +SHA-256 нового `tx_object.bin`: +`f7568a5d1a6dc5dd907fa1e971a171578315de740bd6788136f447e409fc6bf8`. +SHA-256 единого `tx_stream.cs8`: +`8941eb231f1f2f99cde51f3cc5426472dbedf1a61675543e7774faec4b935f5e`. +Quick gate прошёл 482 теста и 8 функциональных лаб, полный pytest дал +482 passed. Безопасный common-clock preflight без RF TX подтвердил MASTER и +SLAVE reg29 `0x26`, точный SLAVE CLKIN `DETECTED` за 141,51 мс; cleanup +выключил CLKOUT обеих плат, оставшихся `hackrf_transfer` нет. RF TX count = 0. + +Следующий этап: после успешных software gates и безопасного common-clock +preflight остановиться и запросить точную отдельную авторизацию на один +непрерывный RF TX Lab046C; без неё передачу не выполнять. + +# Запись 029 + +## Дата + +1 сентября 2026 года + +## Тема + +Первый непрерывный аппаратный опыт Lab046C: официальный RESULT C. + +## Выполнено + +После отдельной точной авторизации выполнен ровно один непрерывный RF TX +Lab046C без повтора. Передан единый `tx_stream.cs8`: 68 полных Lab045 waveform, +156 835 200 complex samples, 313 670 400 bytes, 19,6044 с при 8 MS/s. MASTER/TX +`000000000000000097c063dc323b97d3`, SLAVE/RX +`000000000000000097c063dc2e5327d3`, RF 1080 МГц, TX VGA 10 дБ, RX LNA/VGA +24/24 дБ, RF amp и bias OFF. + +Common-clock gate прошёл: MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED`, SLAVE +reg29 `0x26`. Фактический TX длился 19,60632 с и полностью находился внутри RX +capture 24,00386 с. TX underruns 0, RX overruns 0; cleanup выключил CLKOUT обеих +плат и не оставил процессов `hackrf_transfer`. `RF TX COUNT = 1`. + +Независимый многокадровый приёмник не использовал TX offsets, packets, coded +bits, ожидаемые 68 фрагментов или исходный объект. Его официальный результат: +**LAB046C HARDWARE RESULT = C, PREAMBLE CANDIDATES = 0, VALID FRAMES = 0, +PHY HEADER PASS = 0, CRC PASS = 0, OBJECT COMPLETE = NO.** Восстановленный +объект не создавался. Рабочий порог PRBS7 оставался `0.65`, независимый максимум +равен `0.126562`; detector, gain, PHY, FEC и Витерби после опыта не менялись. + +TX-файл независимо проверен: SHA-256 +`8941eb231f1f2f99cde51f3cc5426472dbedf1a61675543e7774faec4b935f5e`, RMS +44,002775 code (`-9,274598 dBFS`), peak 70,405966 code, BPSK около +50 кГц. +Первый, средний и последний кадры проходят PRBS7, PHY header, FEC и CRC прямо +из файла; амплитуда практически идентична успешному Lab045. Дефект TX-файла +или runner, объясняющий RESULT C, не найден. + +Только после фиксации независимого RESULT C выполнена read-only диагностика с +TX reference. Обнаружены следы 68/68 waveform с точным периодом 2 306 400 +samples: aligned min/median/max `0.065784/0.089991/0.122379`, смещённый контроль +median/max `0.004275/0.010075`, медианное преимущество 26,46 дБ. Все 68 aligned +значений выше максимума контроля. Это доказывает слабое присутствие TX в RX, +но не меняет официальный исход: **POST-RUN TX-REFERENCE DIAGNOSTIC = 68/68 +WAVEFORM TRACES DETECTED; OFFICIAL RESULT CHANGED = NO.** OBJECT TRANSPORT PASS +и LAB046C COMPLETE SUCCESS не заявляются. + +Перед фиксацией результата повторно пройдены software gates: `python +tools/quick_gate.py` PASS, `python -m pytest tests/ -q` = 482 passed, +`git diff --check` чист, кроме информационных LF/CRLF notices. + +Следующий этап определяется отдельной постановкой после анализа RESULT C; +повторный RF TX, изменение PRBS7 threshold, gain или detector автоматически не +разрешены. diff --git a/docs/lab046_current_state.md b/docs/lab046_current_state.md index 6d2ccd6..1ca7727 100644 --- a/docs/lab046_current_state.md +++ b/docs/lab046_current_state.md @@ -8,12 +8,102 @@ **ПРОГРАММНАЯ ПРОВЕРКА НЕПРЕРЫВНОГО СЫРОГО IQ-ПОТОКА = PASS.** -**RF TX = 0.** +**LAB046C HARDWARE RESULT = C.** -**АППАРАТНАЯ ПРОВЕРКА = НЕ ВЫПОЛНЯЛАСЬ.** +**RF TX COUNT = 1.** -Lab046 — только программная лаба. HackRF и `hackrf_transfer` не -запускались, эфирной передачи нет. Ни JPEG, ни видео, ни QPSK. +**PREAMBLE CANDIDATES = 0. VALID FRAMES = 0. CRC PASS = 0.** + +**OBJECT COMPLETE = NO.** + +**POST-RUN TX-REFERENCE DIAGNOSTIC = 68/68 WAVEFORM TRACES DETECTED.** + +**OFFICIAL RESULT CHANGED BY POST-RUN DIAGNOSTICS = NO.** + +Lab046A/B остаются только программными лабами. Для Lab046C выполнен ровно один +заранее разрешённый непрерывный RF-сеанс без повтора. Ни JPEG, ни видео, ни +QPSK не запускались. + +## Lab046C: подготовка аппаратного непрерывного объекта + +Добавлен `experiments/lab046c_hackrf_bpsk_object.py`, который не меняет +протокол и только связывает готовые Lab043--Lab046B. Подготовительный режим +создаёт новый криптографически случайный объект 16384 байта вне Git, разбивает +его существующим Lab046 API на 68 фрагментов и последовательно записывает +68 полных Lab045 waveform в один `tx_stream.cs8`. Полные 313 670 400 байт не +удерживаются в ОЗУ одновременно; SHA-256 объекта и потока сохраняются в +`tx_metadata.json`. + +24-секундный RX был запланирован как 192 000 000 complex cs8 samples, +384 000 000 байт. Такой capture даёт 0,75 с до передачи и около 3,65 с после +потока длительностью 19,6044 с. Приёмный API получает только путь к сырому +cs8 и PHY safety limits. Он читает файл через memmap окнами `complex64`, ищет +локальные PRBS7-кандидаты без обязательного global peak/second gate, получает +длину кадра из принятого PHY header и передаёт прошедшие CRC payload в +`ObjectAssembler`. TX start, границы 68 waveform, ожидаемое число фрагментов, +пакеты, coded bits и исходный объект приёмнику не передаются. + +Аппаратный runner фиксирует доказанные serial и PHY-параметры, запускает RX +раньше TX, содержит один `TransmitGuard.register_transmit()` и cleanup в +`finally`. `--preflight-only` не регистрирует передачу и выполняет только +inventory/common-clock gate с последующим CLKOUT OFF. RF-режим требует два +отдельных CLI-ключа. После отдельной точной авторизации он был выполнен один +раз; повторный запуск не выполнялся и не разрешён. + +Фактическая подготовка 1 сентября 2026 года создала внешний каталог +`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260901_145339_bpsk_object`: + +- `tx_object.bin`: 16384 байта, SHA-256 + `f7568a5d1a6dc5dd907fa1e971a171578315de740bd6788136f447e409fc6bf8`; +- `tx_stream.cs8`: 313670400 байт, 156835200 complex samples, 19,6044 с, + SHA-256 `8941eb231f1f2f99cde51f3cc5426472dbedf1a61675543e7774faec4b935f5e`; +- `tx_metadata.json`: `LAB046C_PREPARATION_ONLY`, `rf_tx_count = 0`. + +Software gate подготовки: quick gate PASS с 482 тестами и 8 функциональными +лабами; полный `pytest` = 482 passed. Безопасный common-clock preflight также PASS: +MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED` после 141,51 мс, SLAVE reg29 +`0x26`; после проверки оба CLKOUT выключены, процессов `hackrf_transfer` нет, +`RF TX = 0` на этапе подготовки. + +## Lab046C: первый аппаратный результат + +Выполнен ровно один непрерывный RF TX всего `tx_stream.cs8`; автоматического +или ручного повтора не было. MASTER/TX +`000000000000000097c063dc323b97d3`, SLAVE/RX +`000000000000000097c063dc2e5327d3`, RF 1080 МГц, Fs 8 MS/s, BPSK 20 ксимв/с, +TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, RF amp и bias OFF. Common clock прошёл +fail-closed gate: MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED`, SLAVE reg29 +`0x26`. Передача длилась 19,60632 с и полностью находилась внутри RX capture +длительностью 24,00386 с. USB-журналы чистые: TX underruns 0, RX overruns 0. + +Независимый приёмник без TX oracle вернул официальный `RESULT C`: + +- `PREAMBLE CANDIDATES = 0`; +- `VALID FRAMES = 0`; +- `PHY HEADER PASS = 0`; +- `CRC PASS = 0`; +- `OBJECT COMPLETE = NO`; +- восстановленный объект не создавался. + +Рабочий порог PRBS7 оставался `0.65`; максимальная независимая корреляция +равна `0.126562`. Фоновая медиана мощности `-34.467535 dBFS`, кратковременный +максимум `-34.307678 dBFS`; максимальный средний подъём на интервале 19,6 с +всего `0.012049 dB`. Обычный power/PSD-анализ не показывает заметной передачи. + +После завершения и фиксации независимого `RESULT C` выполнена разрешённая +read-only диагностика с TX reference. Она обнаружила следы всех 68 waveform с +точным периодом 2 306 400 samples. Нормированная корреляция в выровненных +позициях: min `0.065784`, median `0.089991`, max `0.122379`; контроль со +смещением: median `0.004275`, max `0.010075`. Медианное преимущество 26,46 дБ, +все 68 aligned значений выше максимума контроля. + +Эта post-run диагностика использует знание TX только после официального +решения и **не** превращает опыт в PASS: `OBJECT TRANSPORT PASS` и +`LAB046C COMPLETE SUCCESS` не заявляются. TX-файл, его спектр около +50 кГц, +первый/средний/последний self-decode и runner проверены; дефекта формирования +или управления, объясняющего результат, не найдено. Сохранённый официальный +исход остаётся `LAB046C HARDWARE RESULT = C`, `RF TX COUNT = 1`, +`OBJECT COMPLETE = NO`. ## Назначение @@ -292,7 +382,7 @@ Goodput 6,69 кбит/с — это честная baseline при 20 ксимв - Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка` проверен для всех шести сценариев значениями выше. -## Software gate +## Software gate Lab046B на момент его фиксации - `python tools/quick_gate.py`: PASS; 468 тестов ядра, 7 функциональных проверок лабораторий выполнено, 13 штатно пропущено. @@ -301,9 +391,11 @@ Goodput 6,69 кбит/с — это честная baseline при 20 ксимв `.pytest_cache`; на выполнение и результат тестов оно не повлияло. - `git diff --check`: чист. +Актуальный общий gate после добавления Lab046C указан в начале документа. + ## Ограничения и следующий шаг -RF TX = 0, аппаратная проверка не проводилась. Erasure-код 12+3 GF(256) не -задействован (будущее восстановление потери целых фрагментов). Оптимизация -преамбулы/guard на серию кадров, QPSK, JPEG и видео в этой лабе не делались. -Следующий этап определяется после анализа (Lab046 первым смотрит ChatGPT). +Для Lab046A/B RF TX = 0; первый аппаратный Lab046C завершён с RESULT C и одним +RF TX без повтора. Erasure-код 12+3 GF(256) не задействован. Порог PRBS7, +gain, PHY, FEC и detector после результата не менялись. Следующий этап +определяется отдельной постановкой; новый RF-сеанс автоматически не разрешён. diff --git a/experiments/lab046c_hackrf_bpsk_object.py b/experiments/lab046c_hackrf_bpsk_object.py new file mode 100644 index 0000000..de44866 --- /dev/null +++ b/experiments/lab046c_hackrf_bpsk_object.py @@ -0,0 +1,886 @@ +"""Lab046C: подготовка одного непрерывного аппаратного BPSK-сеанса. + +Модуль связывает уже проверенные части Lab043--Lab046B. Подготовка создаёт +новый случайный объект 16 KiB и последовательно записывает 68 Lab045 waveform +в один cs8-файл. Приёмник читает будущий cs8 capture окнами через memmap и не +получает TX offsets, ожидаемый порядок фрагментов или переданный объект. + +Аппаратный режим закрыт двумя явными ключами. ``--preflight-only`` выполняет +только read-only inventory и разрешённые короткие RX общего clock-gate. Сам +RF-сеанс этим модулем в Lab046C preparation не запускается: RF TX = 0. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import secrets +import subprocess +import sys +import time +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np + +from experiments import lab044_hackrf_bpsk_packet as lab044 +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab046_bpsk_object_transport as lab046 +from experiments import lab046b_continuous_iq as lab046b + + +OBJECT_SIZE_BYTES = 16_384 +OBJECT_ID = lab046.DEFAULT_OBJECT_ID + +MASTER_SERIAL = lab044.lab043.MASTER_SERIAL +SLAVE_SERIAL = lab044.lab043.SLAVE_SERIAL +RF_HZ = 1_080_000_000 +SAMPLE_RATE_HZ = 8_000_000 +SYMBOL_RATE = 20_000 +SAMPLES_PER_SYMBOL = 400 +CARRIER_OFFSET_HZ = 50_000 +TX_VGA_DB = 10 +RX_LNA_DB = 24 +RX_VGA_DB = 24 + +RX_CAPTURE_SECONDS = 24.0 +RX_CAPTURE_SAMPLE_COUNT = 192_000_000 +RX_CAPTURE_FILE_BYTES = 384_000_000 +RX_LEAD_SECONDS = 0.75 +RX_WAIT_TIMEOUT_SECONDS = 35.0 + +DETECTION_CHUNK_SAMPLES = 16_000_000 +REFINEMENT_MARGIN_SAMPLES = 4_000 +MAX_FRAMES = 256 +MAX_SEARCH_ITERATIONS = 2_048 + +RECEIVER_ORACLE_KEYS = { + "tx_start_offset", + "frame_start_offsets", + "tx_waveform_lengths", + "expected_fragment_count", + "tx_packets_or_coded_bits", + "tx_object_before_completion_verdict", +} + + +def _receiver_oracle_declaration_is_safe(value: object) -> bool: + if not isinstance(value, dict): + return False + return ( + set(value) == RECEIVER_ORACLE_KEYS + and all(flag is False for flag in value.values()) + ) + + +def _validate_fixed_phy() -> None: + actual = ( + lab045.RF_HZ, + lab045.SAMPLE_RATE_HZ, + lab045.SYMBOL_RATE, + lab045.SAMPLES_PER_SYMBOL, + lab045.CARRIER_OFFSET_HZ, + lab045.TX_VGA_DB, + lab045.RX_LNA_DB, + lab045.RX_VGA_DB, + ) + expected = ( + RF_HZ, + SAMPLE_RATE_HZ, + SYMBOL_RATE, + SAMPLES_PER_SYMBOL, + CARRIER_OFFSET_HZ, + TX_VGA_DB, + RX_LNA_DB, + RX_VGA_DB, + ) + if actual != expected: + raise RuntimeError(f"Фактические PHY-параметры {actual} не совпали с {expected}") + if RX_CAPTURE_SAMPLE_COUNT != int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ): + raise RuntimeError("RX sample accounting не совпал") + if RX_CAPTURE_FILE_BYTES != 2 * RX_CAPTURE_SAMPLE_COUNT: + raise RuntimeError("RX cs8 byte accounting не совпал") + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + while block := stream.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def default_output_directory() -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return ( + Path.home() + / "Documents" + / "SDR_Rover_Lab046_raw" + / f"{timestamp}_bpsk_object" + ) + + +@dataclass(frozen=True) +class PreparedTransmission: + output_directory: Path + object_path: Path + stream_path: Path + metadata_path: Path + object_sha256: str + stream_sha256: str + fragment_count: int + frame_count: int + frame_samples: int + stream_sample_count: int + stream_file_bytes: int + stream_duration_seconds: float + + +ObjectFactory = Callable[[int], bytes] + + +def prepare_transmission( + output_directory: Path, + *, + object_factory: ObjectFactory = secrets.token_bytes, +) -> PreparedTransmission: + """Создать новый объект и один cs8 TX stream без RF и полных RAM-копий.""" + + _validate_fixed_phy() + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=False) + object_path = output_directory / "tx_object.bin" + stream_path = output_directory / "tx_stream.cs8" + metadata_path = output_directory / "tx_metadata.json" + + data = bytes(object_factory(OBJECT_SIZE_BYTES)) + if len(data) != OBJECT_SIZE_BYTES: + raise ValueError("object_factory вернул неверное число байт") + object_path.write_bytes(data) + object_digest = _sha256_file(object_path) + + frames = lab046.build_object_frames(data, OBJECT_ID) + if not frames: + raise RuntimeError("Фрагментация не создала кадров") + fragment_count = frames[0].fragment_count + frame_samples = frames[0].plan.waveform_sample_count + if len(frames) != fragment_count: + raise RuntimeError("Число FramePlan не совпало с fragment_count") + if any(frame.fragment_count != fragment_count for frame in frames): + raise RuntimeError("Фрагменты содержат разные fragment_count") + if any(frame.plan.waveform_sample_count != frame_samples for frame in frames): + raise RuntimeError("Lab046C ожидает одинаковую длину полных Lab045 waveform") + + stream_digest_builder = hashlib.sha256() + stream_file_bytes = 0 + with stream_path.open("xb") as stream: + for frame in frames: + waveform = lab045.build_tx_cs8(frame.plan) + expected_bytes = 2 * frame.plan.waveform_sample_count + if len(waveform) != expected_bytes: + raise RuntimeError("Lab045 waveform не прошёл cs8 accounting") + stream.write(waveform) + stream_digest_builder.update(waveform) + stream_file_bytes += len(waveform) + + stream_sample_count = sum(frame.plan.waveform_sample_count for frame in frames) + expected_stream_bytes = 2 * stream_sample_count + if stream_file_bytes != expected_stream_bytes or stream_path.stat().st_size != expected_stream_bytes: + raise RuntimeError("TX stream file size не совпал с frame accounting") + stream_digest = stream_digest_builder.hexdigest() + if _sha256_file(stream_path) != stream_digest: + raise RuntimeError("TX stream SHA-256 не совпал при повторном чтении") + + metadata = { + "created_utc": datetime.now(timezone.utc).isoformat(), + "stage": "LAB046C_PREPARATION_ONLY", + "rf_tx_count": 0, + "object": { + "path": str(object_path), + "bytes": len(data), + "sha256": object_digest, + "source": "cryptographic_random_bytes", + "deterministic_seed": None, + "object_id": OBJECT_ID, + }, + "fragmentation": { + "fragment_data_bytes": lab046.FRAGMENT_DATA_SIZE, + "fragment_count": fragment_count, + "last_fragment_data_bytes": frames[-1].encoded_payload_length + - lab046.fragments.FRAGMENT_HEADER_SIZE, + }, + "tx_stream": { + "path": str(stream_path), + "format": "cs8 interleaved signed I/Q", + "frame_count": len(frames), + "samples_per_frame": frame_samples, + "sample_count": stream_sample_count, + "file_bytes": stream_file_bytes, + "duration_seconds": stream_sample_count / SAMPLE_RATE_HZ, + "sha256": stream_digest, + "inter_frame_samples_added": 0, + }, + "planned_rx": { + "duration_seconds": RX_CAPTURE_SECONDS, + "sample_count": RX_CAPTURE_SAMPLE_COUNT, + "file_bytes": RX_CAPTURE_FILE_BYTES, + "format": "cs8 interleaved signed I/Q", + "justification": ( + "0.75 s before TX plus about 3.65 s after the 19.6044 s TX stream" + ), + }, + "hardware": hardware_parameters(), + "receiver_oracles": { + key: False for key in sorted(RECEIVER_ORACLE_KEYS) + }, + "recovered_object": None, + } + metadata_path.write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return PreparedTransmission( + output_directory, + object_path, + stream_path, + metadata_path, + object_digest, + stream_digest, + fragment_count, + len(frames), + frame_samples, + stream_sample_count, + stream_file_bytes, + stream_sample_count / SAMPLE_RATE_HZ, + ) + + +def hardware_parameters() -> dict: + return { + "master_tx_serial": MASTER_SERIAL, + "slave_rx_serial": SLAVE_SERIAL, + "frequency_hz": RF_HZ, + "sample_rate_hz": SAMPLE_RATE_HZ, + "symbol_rate": SYMBOL_RATE, + "samples_per_symbol": SAMPLES_PER_SYMBOL, + "digital_carrier_hz": CARRIER_OFFSET_HZ, + "tx_vga_db": TX_VGA_DB, + "tx_rf_amp": False, + "tx_bias_tee": False, + "rx_lna_db": RX_LNA_DB, + "rx_vga_db": RX_VGA_DB, + "rx_rf_amp": False, + "rx_bias_tee": False, + "master_clkout_to_slave_clkin": True, + "slave_clkout": False, + "clock_initial_settle_seconds": lab044.lab043.CLOCK_INITIAL_SETTLE_SECONDS, + "clock_poll_interval_seconds": lab044.lab043.CLOCK_POLL_INTERVAL_SECONDS, + "clock_timeout_seconds": lab044.lab043.CLOCK_TIMEOUT_SECONDS, + "expected_reg29": lab044.lab043.REG29_EXTERNAL_CLOCK, + } + + +def maximum_phy_waveform_samples() -> int: + coded_bits = lab045.conv.encoded_bit_count(lab045.MAX_PACKET_BYTES * 8) + symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT + coded_bits + return ( + (symbols + 2 * lab045.GUARD_SYMBOL_COUNT) * lab045.SAMPLES_PER_SYMBOL + + lab045.RRC_SPAN_SYMBOLS * lab045.SAMPLES_PER_SYMBOL + ) + + +def _cs8_window(raw: np.memmap, start_sample: int, end_sample: int) -> np.ndarray: + """Преобразовать только одно окно interleaved int8 в complex64.""" + + if start_sample < 0 or end_sample < start_sample: + raise ValueError("Некорректные границы cs8 window") + byte_start = 2 * start_sample + byte_end = 2 * end_sample + codes = raw[byte_start:byte_end] + if codes.size != 2 * (end_sample - start_sample): + raise ValueError("Усечённое cs8 window") + samples = np.empty(end_sample - start_sample, dtype=np.complex64) + samples.real = np.asarray(codes[0::2], dtype=np.float32) / np.float32(128.0) + samples.imag = np.asarray(codes[1::2], dtype=np.float32) / np.float32(128.0) + return samples + + +@dataclass(frozen=True) +class WindowedIqReceiveResult: + iq_sample_count: int + iq_file_bytes: int + iq_storage_dtype: str + processing_dtype: str + maximum_window_samples: int + maximum_window_bytes: int + preamble_candidates: int + processed_candidates: int + rejected_candidates: int + phy_header_pass_count: int + fec_pass_count: int + crc_pass_count: int + crc_fail_count: int + accepted_frames: tuple[lab046b.IqFrameReception, ...] + rejected_reasons: tuple[str, ...] + cursor_history: tuple[int, ...] + search_iterations: int + termination_reason: str + + +def scan_cs8_capture( + capture_path: Path, + *, + preamble_threshold: float = lab046b.IQ_PREAMBLE_THRESHOLD, + max_frames: int = MAX_FRAMES, + max_search_iterations: int = MAX_SEARCH_ITERATIONS, + chunk_samples: int = DETECTION_CHUNK_SAMPLES, +) -> WindowedIqReceiveResult: + """Оконно найти кадры в одном cs8 capture без каких-либо TX-границ.""" + + capture_path = Path(capture_path) + file_bytes = capture_path.stat().st_size + if file_bytes <= 0 or file_bytes % 2: + raise ValueError("cs8 capture должен иметь положительный чётный размер") + if max_frames < 0 or max_search_iterations < 0: + raise ValueError("safety limits не могут быть отрицательными") + overlap = maximum_phy_waveform_samples() + REFINEMENT_MARGIN_SAMPLES + decimation = lab044.lab043.DETECTION_DECIMATION + overlap = ((overlap + decimation - 1) // decimation) * decimation + chunk_samples = (chunk_samples // decimation) * decimation + if chunk_samples <= overlap: + raise ValueError("chunk_samples должен быть больше полного PHY window") + + sample_count = file_bytes // 2 + raw = np.memmap(capture_path, dtype=np.int8, mode="r") + step = chunk_samples - overlap + accepted_frames: list[lab046b.IqFrameReception] = [] + rejected_reasons: list[str] = [] + cursor = 0 + cursor_history = [0] + candidate_count = 0 + iterations = 0 + phy_header_pass = 0 + fec_pass = 0 + crc_pass = 0 + crc_fail = 0 + maximum_window = 0 + termination = "END_OF_STREAM" + + for window_start in range(0, sample_count, step): + if len(accepted_frames) >= max_frames: + termination = "MAX_FRAMES_REACHED" + break + if iterations >= max_search_iterations: + termination = "MAX_SEARCH_ITERATIONS_REACHED" + break + window_end = min(sample_count, window_start + chunk_samples) + ownership_end = sample_count if window_end == sample_count else window_start + step + samples = _cs8_window(raw, window_start, window_end) + maximum_window = max(maximum_window, samples.size) + candidates, _ = lab046b._detect_iq_preamble_candidates(samples, preamble_threshold) + for candidate in candidates: + global_coarse = window_start + candidate.coarse_start_sample + if global_coarse < window_start or global_coarse >= ownership_end: + continue + candidate_count += 1 + if global_coarse < cursor: + continue + if len(accepted_frames) >= max_frames: + termination = "MAX_FRAMES_REACHED" + break + if iterations >= max_search_iterations: + termination = "MAX_SEARCH_ITERATIONS_REACHED" + break + iterations += 1 + decoded = lab046b._decode_iq_candidate(samples, candidate, iterations) + phy_header_pass += int(decoded.phy_header_pass) + fec_pass += int(decoded.fec_pass) + crc_pass += int(decoded.crc == lab045.CRC_PASS) + crc_fail += int(decoded.crc == lab045.CRC_FAIL) + global_next = window_start + int(decoded.next_sample) + if decoded.accepted and decoded.frame is not None: + frame = replace( + decoded.frame, + detection_number=iterations, + coarse_start_sample=window_start + decoded.frame.coarse_start_sample, + refined_start_sample=window_start + decoded.frame.refined_start_sample, + frame_end_sample=window_start + decoded.frame.frame_end_sample, + ) + accepted_frames.append(frame) + else: + rejected_reasons.append(decoded.reason) + cursor = max(cursor + 1, global_next) + cursor_history.append(cursor) + if termination != "END_OF_STREAM": + break + if window_end == sample_count: + break + + return WindowedIqReceiveResult( + iq_sample_count=sample_count, + iq_file_bytes=file_bytes, + iq_storage_dtype="interleaved int8 I/Q", + processing_dtype="complex64", + maximum_window_samples=maximum_window, + maximum_window_bytes=maximum_window * np.dtype(np.complex64).itemsize, + preamble_candidates=candidate_count, + processed_candidates=iterations, + rejected_candidates=len(rejected_reasons), + phy_header_pass_count=phy_header_pass, + fec_pass_count=fec_pass, + crc_pass_count=crc_pass, + crc_fail_count=crc_fail, + accepted_frames=tuple(accepted_frames), + rejected_reasons=tuple(rejected_reasons), + cursor_history=tuple(cursor_history), + search_iterations=iterations, + termination_reason=termination, + ) + + +@dataclass(frozen=True) +class ObjectReceiveResult: + iq: WindowedIqReceiveResult + valid_fragments: int + duplicates: int + conflicting: int + rejected_fragments: int + missing_fragments: tuple[int, ...] + object_complete: bool + recovered_object: bytes | None + + +def receive_object_from_cs8( + capture_path: Path, + *, + preamble_threshold: float = lab046b.IQ_PREAMBLE_THRESHOLD, + max_frames: int = MAX_FRAMES, + max_search_iterations: int = MAX_SEARCH_ITERATIONS, + chunk_samples: int = DETECTION_CHUNK_SAMPLES, +) -> ObjectReceiveResult: + """Независимо принять объект; TX object/count/offsets в API отсутствуют.""" + + iq = scan_cs8_capture( + capture_path, + preamble_threshold=preamble_threshold, + max_frames=max_frames, + max_search_iterations=max_search_iterations, + chunk_samples=chunk_samples, + ) + assembler = lab046.ObjectAssembler() + for frame in iq.accepted_frames: + payload = frame.reception.payload_bytes + fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None + assembler.offer(fragment) + recovered = assembler.recovered_object() + return ObjectReceiveResult( + iq=iq, + valid_fragments=assembler.counters.accepted, + duplicates=assembler.counters.duplicates, + conflicting=assembler.counters.conflicting, + rejected_fragments=assembler.counters.rejected, + missing_fragments=tuple(assembler.missing_indexes()), + object_complete=assembler.object_complete(), + recovered_object=recovered, + ) + + +def run_common_clock_preflight_only( + runner: lab044.lab043.CommandRunner | None = None, + *, + process_checker: lab045.ProcessChecker = lab045._running_hackrf_transfer_processes, +) -> dict: + """Проверить inventory/common clock без RF TX и гарантированно выключить CLKOUT.""" + + runner = runner or lab044.lab043._default_command_runner + guard = lab044.lab043.TransmitGuard() + cleanup: dict = {} + failure: Exception | None = None + inventory: dict | None = None + preflight: dict | None = None + try: + running_before = process_checker() + if running_before: + raise RuntimeError(f"До preflight уже запущен hackrf_transfer: {running_before}") + inventory = lab045._verify_device_inventory(runner) + preflight = lab044.lab043.common_clock_preflight(runner, guard) + except Exception as error: + failure = error + finally: + clock_errors = lab044.lab043._disable_clock_outputs(runner) + cleanup["clock_outputs_off"] = not clock_errors + cleanup["clock_output_errors"] = clock_errors + try: + lingering = process_checker() + except Exception as error: + cleanup["lingering_hackrf_transfer"] = None + cleanup["process_check_error"] = str(error) + else: + cleanup["lingering_hackrf_transfer"] = lingering + cleanup["no_lingering_hackrf_transfer"] = not lingering + cleanup_failure = None + if not cleanup.get("clock_outputs_off", False): + cleanup_failure = "CLKOUT cleanup не подтверждён" + if cleanup.get("no_lingering_hackrf_transfer") is not True: + cleanup_failure = "после preflight остался или не проверен hackrf_transfer" + if failure is not None: + if cleanup_failure is not None: + raise RuntimeError(f"{failure}; {cleanup_failure}") from failure + raise failure + if cleanup_failure is not None: + raise RuntimeError(cleanup_failure) + assert inventory is not None and preflight is not None + if guard.transmit_calls != 0: + raise RuntimeError("Preflight-only нарушил RF TX count = 0") + return { + "passed": True, + "inventory": inventory, + "common_clock": preflight, + "cleanup": cleanup, + "rf_tx_count": 0, + } + + +def _load_and_validate_prepared(output_directory: Path) -> PreparedTransmission: + metadata_path = output_directory / "tx_metadata.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + object_path = output_directory / "tx_object.bin" + stream_path = output_directory / "tx_stream.cs8" + tx = metadata["tx_stream"] + obj = metadata["object"] + prepared = PreparedTransmission( + output_directory, + object_path, + stream_path, + metadata_path, + str(obj["sha256"]), + str(tx["sha256"]), + int(metadata["fragmentation"]["fragment_count"]), + int(tx["frame_count"]), + int(tx["samples_per_frame"]), + int(tx["sample_count"]), + int(tx["file_bytes"]), + float(tx["duration_seconds"]), + ) + if metadata.get("stage") != "LAB046C_PREPARATION_ONLY": + raise RuntimeError("tx_metadata.json имеет неверный stage") + if metadata.get("rf_tx_count") != 0: + raise RuntimeError("Подготовленный metadata уже объявляет RF TX") + if int(obj.get("object_id", -1)) != OBJECT_ID: + raise RuntimeError("Object ID не совпал с фиксированным Lab046C") + if Path(obj.get("path", "")).resolve() != object_path.resolve(): + raise RuntimeError("Object path в metadata не совпал с каталогом") + if Path(tx.get("path", "")).resolve() != stream_path.resolve(): + raise RuntimeError("TX stream path в metadata не совпал с каталогом") + if object_path.stat().st_size != OBJECT_SIZE_BYTES: + raise RuntimeError("tx_object.bin имеет неверный размер") + tx_object = object_path.read_bytes() + expected_frames = lab046.build_object_frames(tx_object, OBJECT_ID) + expected_frame_count = len(expected_frames) + expected_frame_samples = expected_frames[0].plan.waveform_sample_count + expected_stream_samples = sum( + frame.plan.waveform_sample_count for frame in expected_frames + ) + expected_stream_bytes = 2 * expected_stream_samples + expected_duration = expected_stream_samples / SAMPLE_RATE_HZ + fragmentation = metadata.get("fragmentation", {}) + exact_accounting = ( + prepared.fragment_count == expected_frame_count == 68 + and prepared.frame_count == expected_frame_count + and prepared.frame_samples == expected_frame_samples == 2_306_400 + and prepared.stream_sample_count == expected_stream_samples == 156_835_200 + and prepared.stream_file_bytes == expected_stream_bytes == 313_670_400 + and abs(prepared.stream_duration_seconds - expected_duration) <= 1e-12 + and int(fragmentation.get("fragment_data_bytes", -1)) == lab046.FRAGMENT_DATA_SIZE + and int(fragmentation.get("fragment_count", -1)) == expected_frame_count + and int(fragmentation.get("last_fragment_data_bytes", -1)) == 36 + and int(tx.get("inter_frame_samples_added", -1)) == 0 + ) + if not exact_accounting: + raise RuntimeError("Metadata не прошёл точный Lab046C frame/stream accounting") + if metadata.get("hardware") != hardware_parameters(): + raise RuntimeError("Hardware parameters в metadata не совпали с фиксированными") + oracle_flags = metadata.get("receiver_oracles", {}) + if not _receiver_oracle_declaration_is_safe(oracle_flags): + raise RuntimeError("Metadata допускает TX oracle в приёмнике") + if stream_path.stat().st_size != prepared.stream_file_bytes: + raise RuntimeError("tx_stream.cs8 имеет неверный размер") + if _sha256_file(object_path) != prepared.object_sha256: + raise RuntimeError("tx_object.bin SHA-256 не совпал с metadata") + if _sha256_file(stream_path) != prepared.stream_sha256: + raise RuntimeError("tx_stream.cs8 SHA-256 не совпал с metadata") + return prepared + + +def run_hardware_experiment( + output_directory: Path, + runner: lab044.lab043.CommandRunner | None = None, + process_starter: lab044.lab043.ProcessStarter | None = None, + sleep_function: lab044.lab043.SleepFunction = time.sleep, + process_checker: lab045.ProcessChecker = lab045._running_hackrf_transfer_processes, + receiver: Callable[[Path], ObjectReceiveResult] = receive_object_from_cs8, +) -> dict: + """Один будущий RF-сеанс без retry; вызывать только после отдельного разрешения.""" + + _validate_fixed_phy() + runner = runner or lab044.lab043._default_command_runner + process_starter = process_starter or lab044.lab043._default_process_starter + output_directory = Path(output_directory) + prepared = _load_and_validate_prepared(output_directory) + guard = lab044.lab043.TransmitGuard() + rx_process: lab044.lab043.RunningCommand | None = None + cleanup: dict = {} + final_result: dict | None = None + try: + if process_checker(): + raise RuntimeError("До Lab046C уже запущен hackrf_transfer") + inventory = lab045._verify_device_inventory(runner) + preflight = lab044.lab043.common_clock_preflight(runner, guard) + rx_path = output_directory / "rx_stream.cs8" + rx_stdout_path = output_directory / "rx_stdout.txt" + rx_stderr_path = output_directory / "rx_stderr.txt" + tx_stdout_path = output_directory / "tx_stdout.txt" + tx_stderr_path = output_directory / "tx_stderr.txt" + transfer_tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe") + rx_command = [ + transfer_tool, + "-d", SLAVE_SERIAL, + "-r", str(rx_path), + "-f", str(RF_HZ), + "-s", str(SAMPLE_RATE_HZ), + "-n", str(RX_CAPTURE_SAMPLE_COUNT), + "-l", str(RX_LNA_DB), + "-g", str(RX_VGA_DB), + "-a", "0", + "-p", "0", + "-B", + ] + rx_process = process_starter(rx_command, rx_stdout_path, rx_stderr_path) + sleep_function(RX_LEAD_SECONDS) + if rx_process.poll() is not None: + rx_process.close_logs() + detail = rx_stderr_path.read_text(encoding="utf-8", errors="replace") + raise RuntimeError(f"RX завершился до разрешения TX: {detail.strip()}") + + tx_command = [ + transfer_tool, + "-d", MASTER_SERIAL, + "-t", str(prepared.stream_path), + "-f", str(RF_HZ), + "-s", str(SAMPLE_RATE_HZ), + "-n", str(prepared.stream_sample_count), + "-x", str(TX_VGA_DB), + "-a", "0", + "-p", "0", + "-B", + ] + guard.register_transmit() + tx_result = runner(tx_command) + tx_stdout_path.write_text(tx_result.stdout or "", encoding="utf-8") + tx_stderr_path.write_text(tx_result.stderr or "", encoding="utf-8") + if tx_result.returncode != 0: + raise RuntimeError(f"Единственный TX завершился с кодом {tx_result.returncode}") + if rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) != 0: + raise RuntimeError("RX завершился с ошибкой") + if rx_path.stat().st_size != RX_CAPTURE_FILE_BYTES: + raise RuntimeError("rx_stream.cs8 имеет неверный размер") + rx_log = rx_stdout_path.read_text(encoding="utf-8", errors="replace") + rx_stderr_path.read_text( + encoding="utf-8", errors="replace" + ) + tx_log = (tx_result.stdout or "") + (tx_result.stderr or "") + if not lab044.lab043._transfer_log_is_clean(rx_log): + raise RuntimeError("RX log содержит overrun/underrun") + if not lab044.lab043._transfer_log_is_clean(tx_log): + raise RuntimeError("TX log содержит overrun/underrun") + + received = receiver(rx_path) + recovered = received.recovered_object + if recovered is not None: + (output_directory / "recovered_object.bin").write_bytes(recovered) + # Только после независимого прохода всего RX и verdict assembler-а + # читается TX object для диагностического сравнения. + tx_object = prepared.object_path.read_bytes() + tx_frames = { + frame.fragment_index: frame + for frame in lab046.build_object_frames(tx_object, OBJECT_ID) + } + frame_diagnostics: list[dict] = [] + total_pre_fec_errors = 0 + total_post_fec_errors = 0 + for frame in received.iq.accepted_frames: + payload = frame.reception.payload_bytes + fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None + fragment_index = fragment.fragment_index if fragment is not None else None + pre_fec_errors = None + post_fec_errors = None + if fragment_index in tx_frames: + comparison = lab045.diagnostic_pre_and_post_fec( + frame.reception, + frame.post_preamble_soft, + tx_frames[fragment_index].plan, + ) + pre_fec_errors = comparison["pre_fec_coded"]["bit_errors"] + post_fec_errors = comparison["post_fec_packet"]["bit_errors"] + if pre_fec_errors is not None: + total_pre_fec_errors += int(pre_fec_errors) + if post_fec_errors is not None: + total_post_fec_errors += int(post_fec_errors) + frame_diagnostics.append({ + "detection_number": frame.detection_number, + "sample_offset": frame.refined_start_sample, + "preamble_errors": frame.preamble_errors, + "peak_over_median_db": frame.over_median_db, + "estimated_cfo_hz": frame.estimated_cfo_hz, + "estimated_phase_rad": frame.estimated_phase_rad, + "snr_db": frame.snr_db, + "phy_header_pass": True, + "fec_pass": bool(frame.reception.analysis["fec_decode_pass"]), + "crc": str(frame.reception.analysis["crc"]), + "fragment_index": fragment_index, + "pre_fec_coded_bit_errors_after_crc": pre_fec_errors, + "post_fec_protocol_bit_errors_after_crc": post_fec_errors, + }) + byte_errors = None + bit_errors = None + sha_match = False + if recovered is not None and len(recovered) == len(tx_object): + tx_values = np.frombuffer(tx_object, dtype=np.uint8) + rx_values = np.frombuffer(recovered, dtype=np.uint8) + xor = np.bitwise_xor(tx_values, rx_values) + byte_errors = int(np.count_nonzero(xor)) + bit_errors = int(np.unpackbits(xor).sum()) + sha_match = hashlib.sha256(recovered).hexdigest() == prepared.object_sha256 + if received.object_complete and recovered is not None and sha_match and byte_errors == 0: + classification = "A" + elif received.valid_fragments > 0: + classification = "B" + else: + classification = "C" + result = { + "classification": classification, + "rf_tx_count": guard.transmit_calls, + "inventory": inventory, + "preflight": preflight, + "rx_capture_duration_seconds": RX_CAPTURE_SECONDS, + "rx_iq_sample_count": received.iq.iq_sample_count, + "preamble_candidates": received.iq.preamble_candidates, + "rejected_candidates": received.iq.rejected_candidates, + "valid_frames": len(received.iq.accepted_frames), + "phy_header_pass": received.iq.phy_header_pass_count, + "fec_pass": received.iq.fec_pass_count, + "crc_pass": received.iq.crc_pass_count, + "crc_fail": received.iq.crc_fail_count, + "valid_fragments": received.valid_fragments, + "duplicates": received.duplicates, + "missing_fragments": list(received.missing_fragments), + "object_complete": received.object_complete, + "total_pre_fec_coded_bit_errors": total_pre_fec_errors, + "total_post_fec_protocol_bit_errors": total_post_fec_errors, + "object_byte_errors": byte_errors, + "object_bit_errors": bit_errors, + "sha_match": sha_match, + "frames": frame_diagnostics, + "rx_sha256": _sha256_file(rx_path), + "recovered_object": ( + str(output_directory / "recovered_object.bin") if recovered is not None else None + ), + "cleanup": cleanup, + } + final_result = result + finally: + if rx_process is not None: + try: + rx_process.terminate() + except Exception as error: + cleanup.setdefault("rx_process_errors", []).append(str(error)) + cleanup["clock_output_errors"] = lab044.lab043._disable_clock_outputs(runner) + try: + cleanup["lingering_hackrf_transfer"] = process_checker() + except Exception as error: + cleanup["process_check_error"] = str(error) + assert final_result is not None + cleanup["clock_outputs_off"] = not cleanup.get("clock_output_errors") + cleanup["no_lingering_hackrf_transfer"] = ( + cleanup.get("lingering_hackrf_transfer") == [] + and "process_check_error" not in cleanup + ) + cleanup_passed = bool( + cleanup["clock_outputs_off"] + and cleanup["no_lingering_hackrf_transfer"] + ) + final_result["cleanup_passed"] = cleanup_passed + if not cleanup_passed: + final_result["classification"] = "HARDWARE_ERROR_AFTER_TX" + (output_directory / "result.json").write_text( + json.dumps(final_result, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return final_result + + +def run_functional_tests() -> None: + _validate_fixed_phy() + frames = lab046.build_object_frames(bytes(OBJECT_SIZE_BYTES), OBJECT_ID) + assert len(frames) == 68 + assert frames[0].plan.waveform_sample_count == 2_306_400 + assert sum(frame.plan.waveform_sample_count for frame in frames) == 156_835_200 + assert maximum_phy_waveform_samples() > frames[0].plan.waveform_sample_count + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prepare", action="store_true") + parser.add_argument("--preflight-only", action="store_true") + parser.add_argument("--hardware", action="store_true") + parser.add_argument("--i-understand-hardware", action="store_true") + parser.add_argument("--authorize-one-rf-tx", action="store_true") + parser.add_argument("--output-directory", type=Path) + arguments = parser.parse_args() + selected_modes = sum((arguments.prepare, arguments.preflight_only, arguments.hardware)) + if selected_modes > 1: + parser.error("Выберите только один режим") + if arguments.hardware: + if not arguments.i_understand_hardware or not arguments.authorize_one_rf_tx: + print("Аппаратный RF-режим заблокирован без двух явных ключей; RF TX = 0") + return 2 + if arguments.output_directory is None: + parser.error("--hardware требует --output-directory с готовыми artifacts") + result = run_hardware_experiment(arguments.output_directory) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if arguments.preflight_only: + if not arguments.i_understand_hardware: + print("Common-clock preflight заблокирован без --i-understand-hardware; RF TX = 0") + return 2 + result = run_common_clock_preflight_only() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if arguments.prepare: + prepared = prepare_transmission( + arguments.output_directory or default_output_directory() + ) + print(json.dumps({ + "output_directory": str(prepared.output_directory), + "object_sha256": prepared.object_sha256, + "stream_sha256": prepared.stream_sha256, + "frame_count": prepared.frame_count, + "stream_samples": prepared.stream_sample_count, + "stream_file_bytes": prepared.stream_file_bytes, + "stream_duration_seconds": prepared.stream_duration_seconds, + "rf_tx_count": 0, + }, ensure_ascii=False, indent=2)) + return 0 + run_functional_tests() + print("LAB046C SOFTWARE PREFLIGHT = PASS") + print("RF TX = 0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lab046c_hackrf_bpsk_object.py b/tests/test_lab046c_hackrf_bpsk_object.py new file mode 100644 index 0000000..2aec47c --- /dev/null +++ b/tests/test_lab046c_hackrf_bpsk_object.py @@ -0,0 +1,350 @@ +"""Аппаратно-независимые проверки Lab046C preparation и fail-closed runner.""" + +from __future__ import annotations + +import inspect +import json +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from experiments import lab046c_hackrf_bpsk_object as lab046c + + +def small_waveform_setup(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(lab046c, "OBJECT_SIZE_BYTES", 300) + monkeypatch.setattr(lab046c, "_validate_fixed_phy", lambda: None) + monkeypatch.setattr( + lab046c.lab045, + "build_tx_cs8", + lambda plan: bytes([plan.payload[12] & 0x7F, 0]) * plan.waveform_sample_count, + ) + + +def test_frame_and_stream_accounting_matches_actual_code() -> None: + frames = lab046c.lab046.build_object_frames(bytes(lab046c.OBJECT_SIZE_BYTES)) + assert len(frames) == 68 + assert frames[-1].encoded_payload_length - 12 == 36 + assert {frame.plan.waveform_sample_count for frame in frames} == {2_306_400} + assert sum(frame.plan.waveform_sample_count for frame in frames) == 156_835_200 + assert 2 * sum(frame.plan.waveform_sample_count for frame in frames) == 313_670_400 + assert sum(frame.plan.airtime_seconds for frame in frames) == pytest.approx(19.6044) + + +def test_preparation_writes_one_stream_sequentially_and_hashes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import hashlib + + small_waveform_setup(monkeypatch) + output = tmp_path / "prepared" + prepared = lab046c.prepare_transmission( + output, + object_factory=lambda count: bytes((index * 17 + 3) % 256 for index in range(count)), + ) + metadata = json.loads(prepared.metadata_path.read_text(encoding="utf-8")) + assert prepared.object_path.stat().st_size == 300 + assert prepared.frame_count == 2 + assert prepared.fragment_count == 2 + assert prepared.stream_path.stat().st_size == prepared.stream_file_bytes + assert hashlib.sha256(prepared.object_path.read_bytes()).hexdigest() == prepared.object_sha256 + assert hashlib.sha256(prepared.stream_path.read_bytes()).hexdigest() == prepared.stream_sha256 + assert metadata["rf_tx_count"] == 0 + assert metadata["object"]["deterministic_seed"] is None + assert metadata["tx_stream"]["inter_frame_samples_added"] == 0 + assert metadata["recovered_object"] is None + + +def test_receiver_api_has_no_tx_oracle() -> None: + parameters = set(inspect.signature(lab046c.receive_object_from_cs8).parameters) + assert parameters == { + "capture_path", + "preamble_threshold", + "max_frames", + "max_search_iterations", + "chunk_samples", + } + forbidden = { + "tx_starts", + "frame_starts", + "frame_lengths", + "expected_fragment_count", + "tx_object", + "tx_packets", + "tx_coded_bits", + } + assert parameters.isdisjoint(forbidden) + + +def test_cs8_window_is_complex64_and_exact(tmp_path: Path) -> None: + path = tmp_path / "capture.cs8" + path.write_bytes(np.asarray([64, -64, -128, 127, 0, 1], dtype=np.int8).tobytes()) + raw = np.memmap(path, dtype=np.int8, mode="r") + samples = lab046c._cs8_window(raw, 0, 3) + assert samples.dtype == np.complex64 + assert samples[0] == pytest.approx(0.5 - 0.5j) + assert samples[1].real == pytest.approx(-1.0) + assert samples[1].imag == pytest.approx(127 / 128) + + +def fake_frame(local_start: int, local_end: int) -> lab046c.lab046b.IqFrameReception: + reception = lab046c.lab045.IndependentReception( + {"fec_decode_pass": True, "crc": lab046c.lab045.CRC_PASS}, + b"packet", + bytes(lab046c.lab045.PAYLOAD_SIZE_BYTES), + ) + return lab046c.lab046b.IqFrameReception( + detection_number=1, + coarse_start_sample=local_start, + refined_start_sample=local_start, + frame_end_sample=local_end, + preamble_score=0.9, + over_median_db=20.0, + over_second_db=0.0, + timing_offset_samples=0, + symbol_sample_phase=0, + nominal_sample=0, + estimated_cfo_hz=0.0, + estimated_phase_rad=0.0, + bpsk_polarity="resolved from preamble", + preamble_errors=0, + snr_db=20.0, + post_preamble_soft=np.empty(0), + reception=reception, + ) + + +def test_windowed_scanner_owns_each_candidate_once_and_cursor_moves_forward( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "capture.cs8" + path.write_bytes(bytes(2 * 80)) + monkeypatch.setattr(lab046c, "maximum_phy_waveform_samples", lambda: 16) + monkeypatch.setattr(lab046c, "REFINEMENT_MARGIN_SAMPLES", 0) + monkeypatch.setattr(lab046c.lab044.lab043, "DETECTION_DECIMATION", 8) + + def detect(samples, threshold): + del threshold + starts = [8, 24, 40] if len(samples) == 48 else [8] + return tuple( + lab046c.lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0) + for start in starts + ), 0 + + def decode(samples, candidate, detection_number): + del samples + start = candidate.coarse_start_sample + frame = fake_frame(start, start + 4) + return lab046c.lab046b.CandidateDecode( + start, + start + 4, + True, + True, + lab046c.lab045.CRC_PASS, + True, + "CRC_PASS", + frame, + ) + + monkeypatch.setattr(lab046c.lab046b, "_detect_iq_preamble_candidates", detect) + monkeypatch.setattr(lab046c.lab046b, "_decode_iq_candidate", decode) + result = lab046c.scan_cs8_capture(path, chunk_samples=48) + starts = [frame.refined_start_sample for frame in result.accepted_frames] + assert starts == [8, 24, 40, 56, 72] + assert len(starts) == len(set(starts)) + assert all(after > before for before, after in zip(result.cursor_history, result.cursor_history[1:])) + assert result.processing_dtype == "complex64" + assert result.maximum_window_samples <= 48 + + +def test_windowed_scanner_safety_limits_fail_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "capture.cs8" + path.write_bytes(bytes(2 * 80)) + monkeypatch.setattr(lab046c, "maximum_phy_waveform_samples", lambda: 16) + monkeypatch.setattr(lab046c, "REFINEMENT_MARGIN_SAMPLES", 0) + monkeypatch.setattr(lab046c.lab044.lab043, "DETECTION_DECIMATION", 8) + monkeypatch.setattr( + lab046c.lab046b, + "_detect_iq_preamble_candidates", + lambda samples, threshold: ( + tuple(lab046c.lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0) for start in (8, 16, 24)), + 0, + ), + ) + monkeypatch.setattr( + lab046c.lab046b, + "_decode_iq_candidate", + lambda samples, candidate, number: lab046c.lab046b.CandidateDecode( + candidate.coarse_start_sample, + candidate.coarse_start_sample + 1, + False, + False, + lab046c.lab045.CRC_NOT_REACHED, + False, + "BAD", + None, + ), + ) + result = lab046c.scan_cs8_capture(path, chunk_samples=48, max_search_iterations=1) + assert result.search_iterations == 1 + assert result.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED" + + +class FakeRunner: + def __init__(self) -> None: + self.commands: list[list[str]] = [] + + def __call__(self, command: list[str]): + self.commands.append(command) + return subprocess.CompletedProcess(command, 0, "", "") + + +def test_preflight_only_never_registers_tx(monkeypatch: pytest.MonkeyPatch) -> None: + runner = FakeRunner() + monkeypatch.setattr(lab046c.lab045, "_verify_device_inventory", lambda runner: {"passed": True}) + + def preflight(runner, guard): + guard.allow() + return {"passed": True, "master_reg29": 0x26, "slave_reg29": 0x26} + + monkeypatch.setattr(lab046c.lab044.lab043, "common_clock_preflight", preflight) + monkeypatch.setattr(lab046c.lab044.lab043, "_disable_clock_outputs", lambda runner: []) + result = lab046c.run_common_clock_preflight_only(runner, process_checker=lambda: []) + assert result["rf_tx_count"] == 0 + assert not any("-t" in command for command in runner.commands) + + +@pytest.mark.parametrize( + ("clock_errors", "processes", "message"), + [ + (["CLKOUT OFF failed"], [], "CLKOUT cleanup"), + ([], ["pid 123"], "hackrf_transfer"), + ], +) +def test_preflight_cleanup_failure_is_fail_closed( + monkeypatch: pytest.MonkeyPatch, + clock_errors: list[str], + processes: list[str], + message: str, +) -> None: + monkeypatch.setattr(lab046c.lab045, "_verify_device_inventory", lambda runner: {"passed": True}) + + def preflight(runner, guard): + guard.allow() + return {"passed": True} + + calls = 0 + + def process_checker(): + nonlocal calls + calls += 1 + return [] if calls == 1 else processes + + monkeypatch.setattr(lab046c.lab044.lab043, "common_clock_preflight", preflight) + monkeypatch.setattr( + lab046c.lab044.lab043, + "_disable_clock_outputs", + lambda runner: clock_errors, + ) + with pytest.raises(RuntimeError, match=message): + lab046c.run_common_clock_preflight_only(FakeRunner(), process_checker=process_checker) + + +def test_prepared_metadata_cannot_self_declare_short_stream( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import hashlib + + output = tmp_path / "forged" + output.mkdir() + object_path = output / "tx_object.bin" + stream_path = output / "tx_stream.cs8" + object_path.write_bytes(bytes(lab046c.OBJECT_SIZE_BYTES)) + stream_path.write_bytes(b"\x00\x00") + metadata = { + "stage": "LAB046C_PREPARATION_ONLY", + "rf_tx_count": 0, + "object": { + "path": str(object_path), + "sha256": hashlib.sha256(object_path.read_bytes()).hexdigest(), + "object_id": lab046c.OBJECT_ID, + }, + "fragmentation": { + "fragment_data_bytes": lab046c.lab046.FRAGMENT_DATA_SIZE, + "fragment_count": 1, + "last_fragment_data_bytes": 1, + }, + "tx_stream": { + "path": str(stream_path), + "sha256": hashlib.sha256(stream_path.read_bytes()).hexdigest(), + "frame_count": 1, + "samples_per_frame": 1, + "sample_count": 1, + "file_bytes": 2, + "duration_seconds": 1 / lab046c.SAMPLE_RATE_HZ, + "inter_frame_samples_added": 0, + }, + "hardware": lab046c.hardware_parameters(), + "receiver_oracles": {"tx_start_offset": False}, + } + (output / "tx_metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + monkeypatch.setattr(lab046c, "_sha256_file", lambda path: hashlib.sha256(path.read_bytes()).hexdigest()) + with pytest.raises(RuntimeError, match="accounting"): + lab046c._load_and_validate_prepared(output) + + +def test_prepared_metadata_requires_exact_oracle_declaration() -> None: + exact = {key: False for key in lab046c.RECEIVER_ORACLE_KEYS} + assert lab046c._receiver_oracle_declaration_is_safe(exact) is True + assert lab046c._receiver_oracle_declaration_is_safe( + {"tx_start_offset": False} + ) is False + with_enabled_oracle = exact | {"tx_start_offset": True} + assert lab046c._receiver_oracle_declaration_is_safe(with_enabled_oracle) is False + + +def test_hardware_entry_point_requires_two_explicit_keys() -> None: + project_root = Path(__file__).resolve().parents[1] + script = project_root / "experiments" / "lab046c_hackrf_bpsk_object.py" + result = subprocess.run( + [sys.executable, str(script), "--hardware"], + cwd=project_root, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 2 + assert "RF TX = 0" in result.stdout + + +def test_hardware_sequence_has_rx_before_one_tx_and_cleanup_in_finally() -> None: + source = inspect.getsource(lab046c.run_hardware_experiment) + assert source.index("process_starter(rx_command") < source.index("guard.register_transmit()") + assert source.count("guard.register_transmit()") == 1 + assert "finally:" in source + assert "_disable_clock_outputs" in source + assert '"-d", MASTER_SERIAL' in source + assert '"-d", SLAVE_SERIAL' in source + + +def test_direct_invocation_is_software_only() -> None: + project_root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, str(project_root / "experiments" / "lab046c_hackrf_bpsk_object.py")], + cwd=project_root, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert "LAB046C SOFTWARE PREFLIGHT = PASS" in result.stdout + assert "RF TX = 0" in result.stdout