Lab046C: record first continuous RF object attempt

This commit is contained in:
LittleSam129
2026-09-01 15:42:00 +03:00
parent 39fa4b139d
commit b6654671b4
4 changed files with 1441 additions and 9 deletions

View File

@@ -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 автоматически не
разрешены.

View File

@@ -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-сеанс автоматически не разрешён.

View 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())

View 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