Lab046C: record first continuous RF object attempt
This commit is contained in:
104
PROJECT_LOG.md
104
PROJECT_LOG.md
@@ -779,3 +779,107 @@ Lab046B: программная проверка нескольких полны
|
|||||||
## Следующий этап
|
## Следующий этап
|
||||||
|
|
||||||
Сначала ChatGPT анализирует Lab046B. Аппаратную Lab046, JPEG, video, QPSK, 12+3 erasure FEC, ARQ и адаптивную модуляцию автоматически не начинать.
|
Сначала 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 автоматически не
|
||||||
|
разрешены.
|
||||||
|
|||||||
@@ -8,12 +8,102 @@
|
|||||||
|
|
||||||
**ПРОГРАММНАЯ ПРОВЕРКА НЕПРЕРЫВНОГО СЫРОГО IQ-ПОТОКА = PASS.**
|
**ПРОГРАММНАЯ ПРОВЕРКА НЕПРЕРЫВНОГО СЫРОГО IQ-ПОТОКА = PASS.**
|
||||||
|
|
||||||
**RF TX = 0.**
|
**LAB046C HARDWARE RESULT = C.**
|
||||||
|
|
||||||
**АППАРАТНАЯ ПРОВЕРКА = НЕ ВЫПОЛНЯЛАСЬ.**
|
**RF TX COUNT = 1.**
|
||||||
|
|
||||||
Lab046 — только программная лаба. HackRF и `hackrf_transfer` не
|
**PREAMBLE CANDIDATES = 0. VALID FRAMES = 0. CRC PASS = 0.**
|
||||||
запускались, эфирной передачи нет. Ни JPEG, ни видео, ни QPSK.
|
|
||||||
|
**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 → канал → приём → сборка`
|
- Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка`
|
||||||
проверен для всех шести сценариев значениями выше.
|
проверен для всех шести сценариев значениями выше.
|
||||||
|
|
||||||
## Software gate
|
## Software gate Lab046B на момент его фиксации
|
||||||
|
|
||||||
- `python tools/quick_gate.py`: PASS; 468 тестов ядра, 7 функциональных
|
- `python tools/quick_gate.py`: PASS; 468 тестов ядра, 7 функциональных
|
||||||
проверок лабораторий выполнено, 13 штатно пропущено.
|
проверок лабораторий выполнено, 13 штатно пропущено.
|
||||||
@@ -301,9 +391,11 @@ Goodput 6,69 кбит/с — это честная baseline при 20 ксимв
|
|||||||
`.pytest_cache`; на выполнение и результат тестов оно не повлияло.
|
`.pytest_cache`; на выполнение и результат тестов оно не повлияло.
|
||||||
- `git diff --check`: чист.
|
- `git diff --check`: чист.
|
||||||
|
|
||||||
|
Актуальный общий gate после добавления Lab046C указан в начале документа.
|
||||||
|
|
||||||
## Ограничения и следующий шаг
|
## Ограничения и следующий шаг
|
||||||
|
|
||||||
RF TX = 0, аппаратная проверка не проводилась. Erasure-код 12+3 GF(256) не
|
Для Lab046A/B RF TX = 0; первый аппаратный Lab046C завершён с RESULT C и одним
|
||||||
задействован (будущее восстановление потери целых фрагментов). Оптимизация
|
RF TX без повтора. Erasure-код 12+3 GF(256) не задействован. Порог PRBS7,
|
||||||
преамбулы/guard на серию кадров, QPSK, JPEG и видео в этой лабе не делались.
|
gain, PHY, FEC и detector после результата не менялись. Следующий этап
|
||||||
Следующий этап определяется после анализа (Lab046 первым смотрит ChatGPT).
|
определяется отдельной постановкой; новый RF-сеанс автоматически не разрешён.
|
||||||
|
|||||||
886
experiments/lab046c_hackrf_bpsk_object.py
Normal file
886
experiments/lab046c_hackrf_bpsk_object.py
Normal file
@@ -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())
|
||||||
350
tests/test_lab046c_hackrf_bpsk_object.py
Normal file
350
tests/test_lab046c_hackrf_bpsk_object.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user