Lab046A: validate continuous soft-symbol frame scanning

This commit is contained in:
LittleSam129
2026-09-01 13:18:42 +03:00
parent 1027eef53d
commit e8d85ae4b7
4 changed files with 879 additions and 6 deletions

View File

@@ -719,3 +719,33 @@ Software gate: quick_gate PASS (438 тестов ядра, 6 функциона
## Следующий этап ## Следующий этап
По решению ChatGPT после анализа Lab046: либо оптимизация накладных расходов серии кадров (преамбула/guard на кадр доминируют в goodput), либо подключение erasure-кода 12+3 для восстановления потери целых фрагментов поверх уже готового bit-level FEC, либо аппаратный прогон многопакетной передачи. RF, QPSK, JPEG и видео в этой сессии не запускались. По решению ChatGPT после анализа Lab046: либо оптимизация накладных расходов серии кадров (преамбула/guard на кадр доминируют в goodput), либо подключение erasure-кода 12+3 для восстановления потери целых фрагментов поверх уже готового bit-level FEC, либо аппаратный прогон многопакетной передачи. RF, QPSK, JPEG и видео в этой сессии не запускались.
# Запись 026
## Дата
1 сентября 2026 года
## Тема
Lab046A: continuous multi-frame receive для 16 KiB объекта в одном soft-stream - только программно, RF TX = 0, аппаратная проверка не проводилась.
## Выполнено
Статус: **CONTINUOUS STREAM VALIDATION = PASS (NORMALIZED SOFT-SYMBOL LEVEL). CONTINUOUS SAMPLE/IQ VALIDATION = NOT PERFORMED. RF TX = 0. HARDWARE VALIDATION = NOT PERFORMED.** HackRF, hackrf_transfer, RF RX/TX, QPSK, JPEG и видео не запускались.
Проведён аудит Lab043-Lab046. Установлено, что предыдущий Lab046 уже собирал 16 KiB объект из 68 Lab045 кадров, но делал это по отдельным кадрам из TX-плана и не доказывал поиск нескольких кадров в одном continuous receive stream. Добавлен Lab046A слой в `experiments/lab046_bpsk_object_transport.py`: приёмник получает только один `float32` normalized soft-symbol stream, самостоятельно ищет PRBS7 candidates, fail-closed декодирует защищённый PHY header, вычисляет длину coded body только из принятого header, запускает существующие Lab045 FEC/CRC и передаёт fragment в `ObjectAssembler`.
TX offsets, список длин кадров, ожидаемое число 68, порядок/индексы TX, исходный объект, TX packets и coded bits scanner-у не передаются. TX-side `frame_starts` остаётся только в генераторе stream и используется тестами только для постфактум-сравнения после независимого receive. Scanner fail-closed: rejected candidate не останавливает поиск; cursor монотонен; `MAX_FRAMES` и `MAX_SEARCH_ITERATIONS` дают явную причину остановки.
Verifier read-only нашёл реальный дефект первого варианта: глобальный non-maximum suppression мог удалить настоящий старт кадра, если перед ним был более сильный ложный PRBS7 peak. Исправление: список candidates теперь содержит все позиции выше порога в порядке stream; повторный decode уже принятого кадра предотвращается только монотонным cursor после accepted frame. Добавлен регрессионный тест `test_stronger_false_peak_does_not_delete_overlapping_later_frame`.
Фактические continuous-сценарии для детерминированного объекта 16384 байта, SHA-256 `748013b5f670c82bd52650be5454555355dbd8528538753df4558f1c3408d515`: ideal continuous - candidates 68, valid headers 68, FEC PASS 68, CRC PASS 68/68, fragments 68, complete=True, SHA совпал, 0 байт-ошибок; background до/между/после 37/37/43 soft-символа - valid frames 68, SHA совпал; noisy continuous sigma=0.40 seed=46001 - pre-FEC 1791, post-FEC 0, CRC 68/0, complete=True, SHA совпал; malformed preamble + bad protected header - candidates 71, rejected 3, последующие valid frames 68, complete=True, SHA совпал.
Учёт: 68 фрагментов, 389 232 soft-symbols, 156 835 200 эквивалентных waveform samples, baseline airtime 19,604400 с при 8 MS/s, useful goodput 6,685846 кбит/с. Основной soft stream занимает 1 556 928 байт; correlation scores 1 551 812 байт; cumulative-energy 3 113 864 байта. Полный continuous `complex64` IQ stream не материализуется; при 156 835 200 samples он занял бы около 1,17 GiB до временных копий.
Документация обновлена в `docs/lab046_current_state.md`. Прямой запуск `python experiments/lab046_bpsk_object_transport.py --object-size 300 --sigma 0.4` проверен и работает. Software gate после исправлений: `python tools/quick_gate.py` PASS, `python -m pytest tests/ -q` 454 passed, 0 failed, 0 errors, 0 skipped, `git diff --check` чист. Git add/commit/fetch/pull/push не выполнялись.
## Следующий этап
Сначала ChatGPT смотрит Lab046A software result. После отдельного явного разрешения можно выбирать следующий шаг: аппаратная проверка многокадровой передачи, оптимизация накладных расходов серии кадров или erasure-FEC для потери целых фрагментов. RF TX, QPSK, JPEG и video автоматически не запускать.

View File

@@ -2,7 +2,10 @@
## Статус ## Статус
**SOFTWARE VALIDATED. RF TX = 0. **LAB046 SOFTWARE OBJECT TRANSPORT = COMPLETE.**
**CONTINUOUS STREAM VALIDATION = PASS (NORMALIZED SOFT-SYMBOL LEVEL). RF TX = 0.
CONTINUOUS SAMPLE/IQ VALIDATION = NOT PERFORMED.
HARDWARE VALIDATION = NOT PERFORMED.** HARDWARE VALIDATION = NOT PERFORMED.**
Lab046 — только программная лаба. HackRF и `hackrf_transfer` не Lab046 — только программная лаба. HackRF и `hackrf_transfer` не
@@ -46,6 +49,77 @@ fragment_count u16, original_size u32).
использован и НЕ изменён — он остаётся будущим расширением для потери целых использован и НЕ изменён — он остаётся будущим расширением для потери целых
фрагментов, а не для битовых ошибок (это работа свёрточного кода). фрагментов, а не для битовых ошибок (это работа свёрточного кода).
После этого Lab046A закрыла оставшийся пробел: до неё Lab046 передавала каждый
кадр отдельным вызовом и выбирала его из TX-плана по известному индексу. Функции
поиска нескольких кадров в одном потоке не было. Теперь
`scan_continuous_soft_stream` получает только один массив soft-символов,
самостоятельно ищет PRBS7 после монотонного cursor, проверяет защищённый PHY
header, вычисляет длину coded body из принятого packet length, выполняет готовые
FEC/CRC Lab045 и передаёт принятый fragment в `ObjectAssembler`.
TX offsets, список длин кадров, ожидаемое число 68, порядок/индексы TX,
исходный объект, TX packets и coded bits scanner-у не передаются. TX-side
`frame_starts` остаётся только в генераторе потока и используется тестом для
сравнения уже после независимого receive.
## Граница доказательства Lab046A
Lab046A работает на уровне **одного непрерывного нормализованного soft BPSK
stream** (`float32`). Это разрешённый интерфейс после уже проверенного в Lab045
sample-level front-end: PRBS7 detection, matched filtering, timing, CFO и
phase/polarity recovery. Внутри нового длинного теста эти sample-level операции
заново не моделируются для каждого кадра. Поэтому результат доказывает
самостоятельный поиск границ, header-driven длину, FEC/CRC и сборку объекта в
непрерывном soft stream, но не является аппаратной или непрерывной IQ-проверкой.
Каждый фрагмент содержит полный активный Lab045 PHY frame: 1280 бит PRBS7,
144 бита защищённого header и 4300 coded bits. Точная полная sample-level форма
с guard и хвостом ФРК учитывается как 2 306 400 отсчётов на кадр, однако все
156 835 200 IQ-отсчётов одновременно в памяти не создаются. Это намеренное
ограничение памяти, а не использование известных границ приёмником.
Scanner fail-closed:
- cursor всегда растёт, после принятого кадра переносится за вычисленный конец;
- все PRBS7 candidates выше порога рассматриваются в порядке stream; после
принятого кадра cursor переносится за вычисленный конец и не даёт
декодировать этот кадр повторно;
- плохой header, усечённое body, ошибка FEC/CRC отвергают candidate, но поиск
следующего продолжается;
- `MAX_FRAMES` и `MAX_SEARCH_ITERATIONS` дают явную причину остановки, а не
частичный объект, объявленный полным;
- scan продолжается до конца capture и не останавливается на числе 68.
## Сценарии continuous stream
| Сценарий | Получено |
|---|---|
| Идеальный, кадры подряд | candidates 68, valid headers 68, FEC PASS 68, CRC PASS 68/68, fragments 68, complete=True, SHA совпал, 0 байт-ошибок |
| Фон до/между/после | 37/37/43 soft-символа, valid frames 68, complete=True, SHA совпал |
| Непрерывный AWGN, sigma=0.40, seed=46001 | frames 68, pre-FEC 1791, post-FEC 0, CRC 68/0, complete=True, SHA совпал |
| Valid-looking preamble + bad protected header | candidates 71, rejected 3, последующие valid frames 68, complete=True, SHA совпал |
Фон между кадрами добавлен только после кадров 7, 23 и 51: 11, 19 и 7
soft-символов, всего 37. В baseline дополнительного inter-frame gap нет.
Отдельный тест с валидным header для 12-байтного packet и повреждённым body
проверяет, что scanner берёт длину именно из принятого header, отвергает этот
candidate и затем находит настоящий кадр.
Verifier нашёл опасный вариант прежнего глобального non-maximum suppression:
более сильный ложный PRBS7 peak мог удалить более поздний настоящий старт
кадра. Поэтому suppression убран из списка кандидатов, а добавленный
регрессионный тест проверяет, что ложный peak не скрывает настоящий кадр.
## Память continuous stream
Основной 68-кадровый soft stream содержит 389 232 значения `float32` и занимает
1 556 928 байт (1,485 MiB). Массив correlation scores занимает 1 551 812 байт,
накопленная энергия `float64` — 3 113 864 байта. FFT и denominator создают
временное рабочее пространство, размер которого зависит от SciPy, но стратегия
остается в пределах десятков MiB, а не нескольких GiB. Для сравнения, один
материализованный continuous `complex64` IQ stream из 156 835 200 отсчётов
занял бы около 1,17 GiB ещё до временных копий.
## Детерминированный объект ## Детерминированный объект
- Размер **16384 байта (16 KiB)**. Это не JPEG, а воспроизводимый - Размер **16384 байта (16 KiB)**. Это не JPEG, а воспроизводимый
@@ -118,8 +192,12 @@ count/original_size, невозможный индекс, конфликтующ
| Число BPSK-кадров | 68 | | Число BPSK-кадров | 68 |
| Всего информационных BPSK-символов | 389 232 (5724 × 68) | | Всего информационных BPSK-символов | 389 232 (5724 × 68) |
| Всего отсчётов формы сигнала | 156 835 200 (2 306 400 × 68) | | Всего отсчётов формы сигнала | 156 835 200 (2 306 400 × 68) |
| Оценка эфирного времени | 19,60 с | | Baseline: дополнительный inter-frame gap | 0 отсчётов |
| Полезный goodput | 835,7 байт/с = 6,69 кбит/с | | Baseline: полная длительность | 19,604400 с |
| Baseline: полезный goodput | 835,7 байт/с = 6,685846 кбит/с |
| Фоновый тест: leading/inter-frame/trailing | 14 800 / 14 800 / 17 200 отсчётов |
| Фоновый тест: полная длина и длительность | 156 882 000 отсчётов, 19,610250 с |
| Фоновый тест: фактический goodput | 6,683852 кбит/с |
| Полезных бит на символ | ≈ 0,337 | | Полезных бит на символ | ≈ 0,337 |
Учёт кадра совпадает с Lab045: 2144 информационных бита, 6 tail-бит, 4300 Учёт кадра совпадает с Lab045: 2144 информационных бита, 6 tail-бит, 4300
@@ -149,15 +227,17 @@ Goodput 6,69 кбит/с — это честная baseline при 20 ксимв
проходят (фрагмент туда-обратно, короткий последний фрагмент, чужой проходят (фрагмент туда-обратно, короткий последний фрагмент, чужой
object_id, невозможный индекс, несогласованный счётчик, усечённые и абсурдные object_id, невозможный индекс, несогласованный счётчик, усечённые и абсурдные
метаданные без крупной аллокации, полный тракт PHY для сценариев A–F, метаданные без крупной аллокации, полный тракт PHY для сценариев A–F,
отсутствие оракула, детерминизм и SHA объекта). отсутствие оракула, последовательный continuous scan, rejected candidate,
MAX_FRAMES/MAX_SEARCH_ITERATIONS, регрессия против удаления настоящего кадра
ложным PRBS7 peak, детерминизм и SHA объекта).
- Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка` - Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка`
проверен для всех шести сценариев значениями выше. проверен для всех шести сценариев значениями выше.
## Software gate ## Software gate
- `python tools/quick_gate.py`: PASS; 438 тестов ядра, 6 функциональных - `python tools/quick_gate.py`: PASS; 454 теста ядра, 6 функциональных
проверок лабораторий выполнено, 13 штатно пропущено. проверок лабораторий выполнено, 13 штатно пропущено.
- `python -m pytest tests/ -q`: 438 passed, 0 failed, 0 errors, 0 skipped. - `python -m pytest tests/ -q`: 454 passed, 0 failed, 0 errors, 0 skipped.
Единственное предупреждение связано с отсутствием прав на создание Единственное предупреждение связано с отсутствием прав на создание
`.pytest_cache`; на выполнение и результат тестов оно не повлияло. `.pytest_cache`; на выполнение и результат тестов оно не повлияло.
- `git diff --check`: чист. - `git diff --check`: чист.

View File

@@ -32,11 +32,16 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import sys
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path
from typing import Callable from typing import Callable
import numpy as np import numpy as np
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from experiments import lab045_hackrf_bpsk_fec as lab045 from experiments import lab045_hackrf_bpsk_fec as lab045
from protocol import image_fragments as fragments from protocol import image_fragments as fragments
@@ -49,6 +54,10 @@ DEFAULT_OBJECT_SIZE = 16384 # 16 KiB, ровный размер, не JPEG
DEFAULT_OBJECT_ID = 0x4C303436 # "L046" DEFAULT_OBJECT_ID = 0x4C303436 # "L046"
DEFAULT_OBJECT_SEED = 46 DEFAULT_OBJECT_SEED = 46
CONTINUOUS_PREAMBLE_THRESHOLD = 0.80
DEFAULT_MAX_FRAMES = 256
DEFAULT_MAX_SEARCH_ITERATIONS = 1024
# ============================================================ Детерминированный объект # ============================================================ Детерминированный объект
@@ -421,6 +430,453 @@ def run_object_transport(
) )
# ============================================================ Lab046A: один continuous soft stream
def _bits_to_float32_soft(bits: np.ndarray) -> np.ndarray:
"""Преобразовать BPSK-биты в компактный нормализованный soft stream."""
values = np.asarray(bits)
if values.ndim != 1 or not np.all((values == 0) | (values == 1)):
raise ValueError("bits должны быть одномерной двоичной последовательностью")
soft = values.astype(np.float32)
soft *= np.float32(2.0)
soft -= np.float32(1.0)
return soft
def malformed_header_candidate_soft() -> np.ndarray:
"""Преамбула PRBS7 и намеренно неверная контрольная сумма PHY header."""
header = lab045.encode_phy_header(lab045.MIN_PACKET_BYTES).copy()
checksum_bit = lab045.PHY_HEADER_FIELDS_BITS - 1
first_copy = checksum_bit * lab045.HEADER_REPETITION
header[first_copy:first_copy + 2] ^= 1
candidate_bits = np.concatenate((lab045.lab044.preamble_bits(), header))
return _bits_to_float32_soft(candidate_bits)
@dataclass(frozen=True)
class ContinuousSoftStreamPlan:
"""TX-side описание потока; ``frame_starts`` запрещено передавать scanner-у."""
soft_symbols: np.ndarray
frame_plans: tuple[FramePlan, ...]
frame_starts: tuple[int, ...]
leading_background_symbols: int
inter_frame_background_symbols: int
trailing_background_symbols: int
malformed_candidate_symbols: int
noise_sigma: float
@property
def total_frame_symbols(self) -> int:
return sum(frame.plan.frame_symbol_count for frame in self.frame_plans)
@property
def frame_waveform_samples(self) -> int:
return sum(frame.plan.waveform_sample_count for frame in self.frame_plans)
@property
def extra_background_samples(self) -> int:
symbols = (
self.leading_background_symbols
+ self.inter_frame_background_symbols
+ self.trailing_background_symbols
)
return symbols * lab045.SAMPLES_PER_SYMBOL
@property
def equivalent_total_samples(self) -> int:
"""Полные Lab045-формы плюс намеренно добавленные фоновые области."""
auxiliary = self.malformed_candidate_symbols * lab045.SAMPLES_PER_SYMBOL
return self.frame_waveform_samples + self.extra_background_samples + auxiliary
@property
def equivalent_duration_seconds(self) -> float:
return self.equivalent_total_samples / lab045.SAMPLE_RATE_HZ
def build_continuous_soft_stream(
frame_plans: list[FramePlan] | tuple[FramePlan, ...],
*,
leading_background_symbols: int = 0,
inter_frame_background: dict[int, int] | None = None,
trailing_background_symbols: int = 0,
background_sigma: float = 0.12,
noise_sigma: float = 0.0,
seed: int = 46001,
prepend_malformed_candidate: bool = False,
) -> ContinuousSoftStreamPlan:
"""Собрать один float32 stream без передачи RX известных границ кадров.
В stream представлены нормализованные soft BPSK-символы на выходе уже
проверенного Lab045 timing/CFO/phase front-end. Полные sample-level формы
Lab045 не материализуются: их точная длина учитывается свойствами плана.
"""
frames = tuple(frame_plans)
if not frames:
raise ValueError("нужен хотя бы один кадр")
if leading_background_symbols < 0 or trailing_background_symbols < 0:
raise ValueError("длина фоновой области не может быть отрицательной")
if background_sigma < 0.0 or noise_sigma < 0.0:
raise ValueError("sigma не может быть отрицательной")
gaps = dict(inter_frame_background or {})
for frame_index, symbol_count in gaps.items():
if not 0 <= frame_index < len(frames) - 1 or symbol_count < 0:
raise ValueError("некорректная inter-frame область")
random_generator = np.random.default_rng(seed)
chunks: list[np.ndarray] = []
frame_starts: list[int] = []
current_offset = 0
def append_background(symbol_count: int) -> None:
nonlocal current_offset
if symbol_count == 0:
return
background = random_generator.normal(
0.0,
background_sigma,
size=symbol_count,
).astype(np.float32)
chunks.append(background)
current_offset += symbol_count
append_background(leading_background_symbols)
malformed_symbols = 0
if prepend_malformed_candidate:
malformed = malformed_header_candidate_soft()
chunks.append(malformed)
malformed_symbols = int(malformed.size)
current_offset += malformed_symbols
inter_frame_symbols = 0
for frame_index, frame in enumerate(frames):
frame_starts.append(current_offset)
frame_soft = _bits_to_float32_soft(frame.plan.frame_bits)
chunks.append(frame_soft)
current_offset += int(frame_soft.size)
if frame_index < len(frames) - 1:
gap = int(gaps.get(frame_index, 0))
inter_frame_symbols += gap
append_background(gap)
append_background(trailing_background_symbols)
stream = np.concatenate(chunks, dtype=np.float32)
if noise_sigma > 0.0:
noise = random_generator.normal(0.0, noise_sigma, size=stream.size)
stream += noise.astype(np.float32)
return ContinuousSoftStreamPlan(
soft_symbols=stream,
frame_plans=frames,
frame_starts=tuple(frame_starts),
leading_background_symbols=leading_background_symbols,
inter_frame_background_symbols=inter_frame_symbols,
trailing_background_symbols=trailing_background_symbols,
malformed_candidate_symbols=malformed_symbols,
noise_sigma=noise_sigma,
)
def _normalized_soft_preamble_correlation(soft_stream: np.ndarray) -> np.ndarray:
"""Нормированная корреляция PRBS7 через проверенный helper Lab043."""
preamble = _bits_to_float32_soft(lab045.lab044.preamble_bits())
if soft_stream.size < preamble.size:
return np.empty(0, dtype=np.float32)
scores = np.asarray(
lab045.lab044.lab043._normalized_sliding_correlation(soft_stream, preamble),
dtype=np.float32,
)
scores[~np.isfinite(scores)] = 0.0
return np.clip(scores, 0.0, 1.0)
def _preamble_candidate_indexes(scores: np.ndarray, threshold: float) -> np.ndarray:
"""Вернуть все пороговые кандидаты в порядке потока, ничего не удаляя заранее."""
return np.flatnonzero(scores >= threshold)
@dataclass(frozen=True)
class ContinuousFrameReception:
candidate_start: int
frame_end: int
preamble_score: float
packet_bytes: int
fragment_index: int | None
reception: lab045.IndependentReception
@dataclass(frozen=True)
class ContinuousReceiveResult:
stream_symbol_count: int
preamble_candidates: int
valid_phy_headers: int
fec_pass_count: int
crc_pass_count: int
crc_fail_count: int
valid_frame_count: int
rejected_candidates: int
valid_fragments: int
duplicate_fragments: int
conflicting_fragments: int
rejected_fragments: int
missing_indexes: tuple[int, ...]
object_complete: bool
recovered_object: bytes | None
candidate_starts: tuple[int, ...]
cursor_history: tuple[int, ...]
search_iterations: int
termination_reason: str
max_frames_reached: bool
max_search_iterations_reached: bool
correlation_storage_bytes: int
frame_receptions: tuple[ContinuousFrameReception, ...]
def scan_continuous_soft_stream(
soft_stream: np.ndarray,
*,
preamble_threshold: float = CONTINUOUS_PREAMBLE_THRESHOLD,
max_frames: int = DEFAULT_MAX_FRAMES,
max_search_iterations: int = DEFAULT_MAX_SEARCH_ITERATIONS,
) -> ContinuousReceiveResult:
"""Независимо найти и декодировать все кадры в одном soft stream.
Единственный вход с данными — сам stream. TX offsets, число кадров,
fragment order, packets, coded bits и исходный объект функции неизвестны.
"""
try:
stream = np.asarray(soft_stream, dtype=np.float32)
except (TypeError, ValueError):
stream = np.empty((0, 0), dtype=np.float32)
if not 0.0 < preamble_threshold <= 1.0:
raise ValueError("preamble_threshold должен лежать в (0, 1]")
if max_frames < 0 or max_search_iterations < 0:
raise ValueError("safety limits не могут быть отрицательными")
assembler = ObjectAssembler()
scores = np.empty(0, dtype=np.float32)
if stream.ndim == 1 and stream.size and np.all(np.isfinite(stream)):
scores = _normalized_soft_preamble_correlation(stream)
candidate_indexes = _preamble_candidate_indexes(scores, preamble_threshold)
cursor = 0
cursor_history = [cursor]
candidate_starts: list[int] = []
frame_receptions: list[ContinuousFrameReception] = []
preamble_candidates = 0
valid_headers = 0
fec_pass = 0
crc_pass = 0
crc_fail = 0
valid_frames = 0
rejected_candidates = 0
search_iterations = 0
termination_reason = "END_OF_STREAM"
max_frames_reached = False
max_iterations_reached = False
while True:
position = int(np.searchsorted(candidate_indexes, cursor, side="left"))
if position >= candidate_indexes.size:
break
candidate_start = int(candidate_indexes[position])
if search_iterations >= max_search_iterations:
termination_reason = "MAX_SEARCH_ITERATIONS_REACHED"
max_iterations_reached = True
break
if valid_frames >= max_frames:
termination_reason = "MAX_FRAMES_REACHED"
max_frames_reached = True
break
search_iterations += 1
preamble_candidates += 1
candidate_starts.append(candidate_start)
preamble_end = candidate_start + lab045.PREAMBLE_BIT_COUNT
header_end = preamble_end + lab045.PHY_HEADER_BIT_COUNT
header = lab045.decode_phy_header_soft(stream[preamble_end:header_end])
if not header["passed"]:
rejected_candidates += 1
cursor = candidate_start + 1
cursor_history.append(cursor)
continue
valid_headers += 1
packet_bytes = int(header["packet_bytes"])
coded_length = lab045.conv.encoded_bit_count(packet_bytes * 8)
frame_end = header_end + coded_length
if frame_end > stream.size:
rejected_candidates += 1
cursor = candidate_start + 1
cursor_history.append(cursor)
continue
reception = lab045.analyze_post_preamble_soft(stream[preamble_end:frame_end])
if reception.analysis["fec_decode_pass"]:
fec_pass += 1
fragment = None
fragment_index = None
if reception.analysis["crc"] == lab045.CRC_PASS and reception.payload_bytes is not None:
crc_pass += 1
valid_frames += 1
fragment = recover_fragment_from_payload(reception.payload_bytes)
if fragment is not None:
fragment_index = int(fragment.fragment_index)
assembler.offer(fragment)
else:
if reception.analysis["crc"] == lab045.CRC_FAIL:
crc_fail += 1
rejected_candidates += 1
frame_receptions.append(ContinuousFrameReception(
candidate_start=candidate_start,
frame_end=frame_end,
preamble_score=float(scores[candidate_start]),
packet_bytes=packet_bytes,
fragment_index=fragment_index,
reception=reception,
))
cursor = frame_end
cursor_history.append(cursor)
recovered = assembler.recovered_object()
if stream.ndim != 1 or not stream.size or not np.all(np.isfinite(stream)):
termination_reason = "INVALID_STREAM"
return ContinuousReceiveResult(
stream_symbol_count=int(stream.size),
preamble_candidates=preamble_candidates,
valid_phy_headers=valid_headers,
fec_pass_count=fec_pass,
crc_pass_count=crc_pass,
crc_fail_count=crc_fail,
valid_frame_count=valid_frames,
rejected_candidates=rejected_candidates,
valid_fragments=assembler.counters.accepted,
duplicate_fragments=assembler.counters.duplicates,
conflicting_fragments=assembler.counters.conflicting,
rejected_fragments=assembler.counters.rejected,
missing_indexes=tuple(assembler.missing_indexes()),
object_complete=assembler.object_complete(),
recovered_object=recovered,
candidate_starts=tuple(candidate_starts),
cursor_history=tuple(cursor_history),
search_iterations=search_iterations,
termination_reason=termination_reason,
max_frames_reached=max_frames_reached,
max_search_iterations_reached=max_iterations_reached,
correlation_storage_bytes=int(scores.nbytes),
frame_receptions=tuple(frame_receptions),
)
@dataclass(frozen=True)
class ContinuousValidationResult:
stream_plan: ContinuousSoftStreamPlan
receive: ContinuousReceiveResult
object_sha256: str
recovered_object_sha256: str | None
object_byte_errors: int | None
sha_match: bool
pre_fec_coded_bit_errors: int
post_fec_protocol_bit_errors: int
def _continuous_post_receive_diagnostics(
receive: ContinuousReceiveResult,
stream_plan: ContinuousSoftStreamPlan,
) -> tuple[int, int]:
"""Сравнить с TX только после независимых detector/header/FEC/CRC решений."""
frame_by_index = {frame.fragment_index: frame for frame in stream_plan.frame_plans}
pre_fec_errors = 0
post_fec_errors = 0
for record in receive.frame_receptions:
if record.fragment_index is None:
continue
frame = frame_by_index.get(record.fragment_index)
if frame is None:
continue
post_start = record.candidate_start + lab045.PREAMBLE_BIT_COUNT
post_soft = stream_plan.soft_symbols[post_start:record.frame_end]
diagnostic = lab045.diagnostic_pre_and_post_fec(
record.reception,
post_soft,
frame.plan,
)
pre = diagnostic["pre_fec_coded"]["bit_errors"]
post = diagnostic["post_fec_packet"]["bit_errors"]
if pre is not None:
pre_fec_errors += int(pre)
if post is not None:
post_fec_errors += int(post)
return pre_fec_errors, post_fec_errors
def run_continuous_object_transport(
data: bytes,
*,
object_id: int = DEFAULT_OBJECT_ID,
leading_background_symbols: int = 0,
inter_frame_background: dict[int, int] | None = None,
trailing_background_symbols: int = 0,
noise_sigma: float = 0.0,
seed: int = 46001,
prepend_malformed_candidate: bool = False,
max_frames: int = DEFAULT_MAX_FRAMES,
max_search_iterations: int = DEFAULT_MAX_SEARCH_ITERATIONS,
) -> ContinuousValidationResult:
"""Построить TX stream, независимо принять его и лишь затем сравнить с TX."""
frame_plans = build_object_frames(data, object_id)
stream_plan = build_continuous_soft_stream(
frame_plans,
leading_background_symbols=leading_background_symbols,
inter_frame_background=inter_frame_background,
trailing_background_symbols=trailing_background_symbols,
noise_sigma=noise_sigma,
seed=seed,
prepend_malformed_candidate=prepend_malformed_candidate,
)
receive = scan_continuous_soft_stream(
stream_plan.soft_symbols,
max_frames=max_frames,
max_search_iterations=max_search_iterations,
)
pre_fec_errors, post_fec_errors = _continuous_post_receive_diagnostics(
receive,
stream_plan,
)
recovered = receive.recovered_object
recovered_sha = object_sha256(recovered) if recovered is not None else None
original_sha = object_sha256(data)
if recovered is None:
byte_errors = None
elif len(recovered) != len(data):
byte_errors = max(len(recovered), len(data))
else:
byte_errors = int(np.count_nonzero(
np.frombuffer(recovered, dtype=np.uint8)
!= np.frombuffer(data, dtype=np.uint8)
))
return ContinuousValidationResult(
stream_plan=stream_plan,
receive=receive,
object_sha256=original_sha,
recovered_object_sha256=recovered_sha,
object_byte_errors=byte_errors,
sha_match=(recovered is not None and recovered_sha == original_sha),
pre_fec_coded_bit_errors=pre_fec_errors,
post_fec_protocol_bit_errors=post_fec_errors,
)
# ============================================================ Airtime / goodput # ============================================================ Airtime / goodput
@@ -451,6 +907,35 @@ def goodput_report(result: ObjectTransportResult) -> dict:
} }
def continuous_accounting_report(result: ContinuousValidationResult) -> dict:
"""Длины полной Lab045-формы и фактических тестовых областей Lab046A."""
plan = result.stream_plan
baseline_duration = plan.frame_waveform_samples / lab045.SAMPLE_RATE_HZ
actual_duration = plan.equivalent_duration_seconds
useful_bits = len(result.receive.recovered_object or b"") * 8
return {
"total_frame_symbols": plan.total_frame_symbols,
"frame_waveform_samples": plan.frame_plans[0].plan.waveform_sample_count,
"all_frame_waveform_samples": plan.frame_waveform_samples,
"leading_samples": plan.leading_background_symbols * lab045.SAMPLES_PER_SYMBOL,
"inter_frame_gap_samples": plan.inter_frame_background_symbols * lab045.SAMPLES_PER_SYMBOL,
"trailing_samples": plan.trailing_background_symbols * lab045.SAMPLES_PER_SYMBOL,
"total_stream_samples": plan.equivalent_total_samples,
"baseline_duration_seconds": baseline_duration,
"actual_duration_seconds": actual_duration,
"baseline_goodput_kbit_per_second": (
useful_bits / baseline_duration / 1000.0 if baseline_duration else 0.0
),
"actual_goodput_kbit_per_second": (
useful_bits / actual_duration / 1000.0 if actual_duration else 0.0
),
"soft_stream_bytes": int(plan.soft_symbols.nbytes),
"correlation_scores_bytes": result.receive.correlation_storage_bytes,
"cumulative_energy_bytes": int((plan.soft_symbols.size + 1) * 8),
}
# ============================================================ Функциональные тесты # ============================================================ Функциональные тесты
@@ -477,6 +962,12 @@ def run_functional_tests() -> None:
recovered = recover_fragment_from_payload(padded) recovered = recover_fragment_from_payload(padded)
assert recovered is not None and recovered.data == last.data assert recovered is not None and recovered.data == last.data
# Короткая Lab046A-проверка: два кадра находятся в одном stream без offsets.
continuous = run_continuous_object_transport(obj[:300])
assert continuous.receive.valid_frame_count == 2
assert continuous.receive.preamble_candidates == 2
assert continuous.sha_match
# ============================================================ main # ============================================================ main
@@ -495,6 +986,30 @@ def main() -> int:
channel=make_noisy_channel(arguments.sigma, base_seed=46000), channel=make_noisy_channel(arguments.sigma, base_seed=46000),
) )
report = goodput_report(ideal) report = goodput_report(ideal)
background_gaps = {
frame_index: symbol_count
for frame_index, symbol_count in ((7, 11), (23, 19), (51, 7))
if frame_index < ideal.fragment_count - 1
}
continuous_ideal = run_continuous_object_transport(obj)
continuous_background = run_continuous_object_transport(
obj,
leading_background_symbols=37,
inter_frame_background=background_gaps,
trailing_background_symbols=43,
seed=464,
)
continuous_noisy = run_continuous_object_transport(
obj,
noise_sigma=arguments.sigma,
seed=46001,
)
continuous_malformed = run_continuous_object_transport(
obj,
prepend_malformed_candidate=True,
)
continuous_accounting = continuous_accounting_report(continuous_ideal)
background_accounting = continuous_accounting_report(continuous_background)
print("Lab046 software object transport") print("Lab046 software object transport")
print(f"object size = {ideal.object_size} bytes") print(f"object size = {ideal.object_size} bytes")
@@ -515,6 +1030,32 @@ def main() -> int:
print(f"post-FEC protocol errors = {noisy.post_fec_protocol_bit_errors}") print(f"post-FEC protocol errors = {noisy.post_fec_protocol_bit_errors}")
print(f"crc pass/fail = {noisy.crc_pass_count}/{noisy.crc_fail_count}") print(f"crc pass/fail = {noisy.crc_pass_count}/{noisy.crc_fail_count}")
print(f"object complete = {noisy.object_complete}, sha match = {noisy.sha_match}, byte errors = {noisy.object_byte_errors}") print(f"object complete = {noisy.object_complete}, sha match = {noisy.sha_match}, byte errors = {noisy.object_byte_errors}")
print("--- Lab046A ideal continuous soft stream ---")
print(f"preamble candidates = {continuous_ideal.receive.preamble_candidates}")
print(f"valid headers/frames = {continuous_ideal.receive.valid_phy_headers}/{continuous_ideal.receive.valid_frame_count}")
print(f"fec/crc pass = {continuous_ideal.receive.fec_pass_count}/{continuous_ideal.receive.crc_pass_count}")
print(f"rejected candidates = {continuous_ideal.receive.rejected_candidates}")
print(f"object complete = {continuous_ideal.receive.object_complete}, sha match = {continuous_ideal.sha_match}, byte errors = {continuous_ideal.object_byte_errors}")
print("--- Lab046A background continuous soft stream ---")
print(f"leading/inter-frame/trailing symbols = {continuous_background.stream_plan.leading_background_symbols}/{continuous_background.stream_plan.inter_frame_background_symbols}/{continuous_background.stream_plan.trailing_background_symbols}")
print(f"valid frames = {continuous_background.receive.valid_frame_count}, sha match = {continuous_background.sha_match}")
print(f"actual equivalent samples/duration = {background_accounting['total_stream_samples']}/{background_accounting['actual_duration_seconds']:.6f} s")
print(f"--- Lab046A noisy continuous soft stream (sigma={arguments.sigma}) ---")
print(f"valid frames = {continuous_noisy.receive.valid_frame_count}")
print(f"pre/post-FEC errors = {continuous_noisy.pre_fec_coded_bit_errors}/{continuous_noisy.post_fec_protocol_bit_errors}")
print(f"crc pass/fail = {continuous_noisy.receive.crc_pass_count}/{continuous_noisy.receive.crc_fail_count}")
print(f"object complete = {continuous_noisy.receive.object_complete}, sha match = {continuous_noisy.sha_match}")
print("--- Lab046A malformed candidate ---")
print(f"preamble candidates = {continuous_malformed.receive.preamble_candidates}")
print(f"rejected candidates = {continuous_malformed.receive.rejected_candidates}")
print(f"valid frames = {continuous_malformed.receive.valid_frame_count}, sha match = {continuous_malformed.sha_match}")
print("--- Lab046A accounting ---")
print(f"frame waveform samples = {continuous_accounting['frame_waveform_samples']}")
print(f"all frame waveform samples = {continuous_accounting['all_frame_waveform_samples']}")
print(f"inter-frame gap samples = {continuous_accounting['inter_frame_gap_samples']}")
print(f"airtime = {continuous_accounting['baseline_duration_seconds']:.6f} s")
print(f"useful goodput = {continuous_accounting['baseline_goodput_kbit_per_second']:.6f} kbit/s")
print(f"soft/correlation/cumulative-energy bytes = {continuous_accounting['soft_stream_bytes']}/{continuous_accounting['correlation_scores_bytes']}/{continuous_accounting['cumulative_energy_bytes']}")
print("RF TX = 0 (software-only)") print("RF TX = 0 (software-only)")
return 0 return 0

View File

@@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
import inspect
import struct import struct
import numpy as np import numpy as np
@@ -243,3 +244,224 @@ def test_receiver_ignores_original_object_on_corruption() -> None:
def test_functional_self_check_runs() -> None: def test_functional_self_check_runs() -> None:
lab046.run_functional_tests() lab046.run_functional_tests()
# ------------------------------------------------------------ Lab046A: continuous stream
def _small_continuous_stream(
*,
leading: int = 0,
gaps: dict[int, int] | None = None,
trailing: int = 0,
) -> tuple[bytes, lab046.ContinuousSoftStreamPlan]:
obj = lab046.make_deterministic_object(300, seed=460)
plan = lab046.build_continuous_soft_stream(
lab046.build_object_frames(obj),
leading_background_symbols=leading,
inter_frame_background=gaps,
trailing_background_symbols=trailing,
seed=461,
)
return obj, plan
def test_two_sequential_frames_found_without_known_offsets() -> None:
obj, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.valid_frame_count == 2
assert received.recovered_object == obj
# Сравнение с TX offsets разрешено только здесь, после независимого receive.
assert received.candidate_starts == stream_plan.frame_starts
def test_many_sequential_frames_are_found() -> None:
obj = lab046.make_deterministic_object(244 * 10, seed=462)
result = lab046.run_continuous_object_transport(obj)
assert result.receive.valid_frame_count == 10
assert result.receive.preamble_candidates == 10
assert result.sha_match
def test_first_frame_need_not_start_at_zero() -> None:
obj, stream_plan = _small_continuous_stream(leading=37)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.candidate_starts[0] == 37
assert received.recovered_object == obj
def test_trailing_noise_is_ignored() -> None:
obj, stream_plan = _small_continuous_stream(trailing=43)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.valid_frame_count == 2
assert received.preamble_candidates == 2
assert received.recovered_object == obj
def test_inter_frame_background_does_not_hide_next_frame() -> None:
obj, stream_plan = _small_continuous_stream(gaps={0: 19})
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.candidate_starts[1] - received.candidate_starts[0] == 5724 + 19
assert received.recovered_object == obj
def test_same_frame_is_not_decoded_twice() -> None:
_, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert len(received.candidate_starts) == len(set(received.candidate_starts))
assert received.preamble_candidates == received.valid_frame_count == 2
def test_malformed_candidate_rejected_and_later_good_frame_found() -> None:
obj = lab046.make_deterministic_object(300, seed=463)
result = lab046.run_continuous_object_transport(
obj,
prepend_malformed_candidate=True,
)
assert result.receive.rejected_candidates > 0
assert result.receive.valid_frame_count == 2
assert result.receive.candidate_starts[0] == 0
assert result.sha_match
def test_stronger_false_peak_does_not_delete_overlapping_later_frame() -> None:
obj = lab046.make_deterministic_object(100, seed=999)
frame = lab046.build_object_frames(obj)[0]
preamble = lab046._bits_to_float32_soft(lab046.lab045.lab044.preamble_bits())
true_frame = lab046._bits_to_float32_soft(frame.plan.frame_bits)
true_start = 508 # четыре периода PRBS7 после ложного старта
stream = np.zeros(true_start + true_frame.size, dtype=np.float32)
stream[:preamble.size] = preamble
stream[true_start:true_start + true_frame.size] = true_frame
stream[preamble.size:true_start + preamble.size] *= np.float32(0.7)
scores = lab046._normalized_soft_preamble_correlation(stream)
assert scores[0] > scores[true_start]
received = lab046.scan_continuous_soft_stream(stream)
assert received.rejected_candidates > 0
assert true_start in received.candidate_starts
assert received.valid_frame_count == 1
assert received.recovered_object == obj
def test_validated_header_drives_candidate_body_length() -> None:
obj, stream_plan = _small_continuous_stream()
packet_bytes = lab046.lab045.MIN_PACKET_BYTES
coded_symbols = lab046.lab045.conv.encoded_bit_count(packet_bytes * 8)
prefix_bits = np.concatenate((
lab046.lab045.lab044.preamble_bits(),
lab046.lab045.encode_phy_header(packet_bytes),
np.zeros(coded_symbols, dtype=np.uint8),
))
stream = np.concatenate((
lab046._bits_to_float32_soft(prefix_bits),
stream_plan.soft_symbols,
))
received = lab046.scan_continuous_soft_stream(stream)
assert received.frame_receptions[0].packet_bytes == packet_bytes
assert received.frame_receptions[0].frame_end == len(prefix_bits)
assert received.rejected_candidates >= 1
assert received.recovered_object == obj
def test_cursor_is_strictly_monotonic() -> None:
_, stream_plan = _small_continuous_stream(leading=7, gaps={0: 13}, trailing=5)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert all(
later > earlier
for earlier, later in zip(received.cursor_history, received.cursor_history[1:])
)
def test_max_search_iterations_is_fail_closed() -> None:
_, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(
stream_plan.soft_symbols,
max_search_iterations=1,
)
assert received.max_search_iterations_reached
assert received.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED"
assert received.valid_frame_count == 1
assert not received.object_complete
def test_max_frames_is_fail_closed() -> None:
_, stream_plan = _small_continuous_stream()
received = lab046.scan_continuous_soft_stream(
stream_plan.soft_symbols,
max_frames=1,
)
assert received.max_frames_reached
assert received.termination_reason == "MAX_FRAMES_REACHED"
assert received.valid_frame_count == 1
assert not received.object_complete
def test_continuous_ideal_16_kib_object_is_exact() -> None:
obj = lab046.make_deterministic_object()
result = lab046.run_continuous_object_transport(obj)
assert result.receive.preamble_candidates == 68
assert result.receive.valid_phy_headers == 68
assert result.receive.fec_pass_count == 68
assert result.receive.crc_pass_count == 68
assert result.receive.valid_fragments == 68
assert result.receive.object_complete
assert result.sha_match
assert result.object_byte_errors == 0
assert result.stream_plan.soft_symbols.dtype == np.float32
assert result.stream_plan.soft_symbols.nbytes < 2_000_000
def test_continuous_background_16_kib_object_is_exact() -> None:
obj = lab046.make_deterministic_object()
result = lab046.run_continuous_object_transport(
obj,
leading_background_symbols=37,
inter_frame_background={7: 11, 23: 19, 51: 7},
trailing_background_symbols=43,
seed=464,
)
assert result.stream_plan.leading_background_symbols == 37
assert result.stream_plan.inter_frame_background_symbols == 37
assert result.stream_plan.trailing_background_symbols == 43
assert result.receive.valid_frame_count == 68
assert result.receive.object_complete
assert result.sha_match
def test_continuous_noisy_16_kib_object_is_exact_after_fec() -> None:
obj = lab046.make_deterministic_object()
result = lab046.run_continuous_object_transport(
obj,
noise_sigma=0.40,
seed=46001,
)
assert result.receive.valid_frame_count == 68
assert result.pre_fec_coded_bit_errors > 0
assert result.post_fec_protocol_bit_errors == 0
assert result.receive.crc_pass_count == 68
assert result.receive.crc_fail_count == 0
assert result.receive.object_complete
assert result.sha_match
def test_continuous_receiver_has_no_tx_oracle_parameters(monkeypatch) -> None:
obj, stream_plan = _small_continuous_stream(leading=5)
parameters = set(inspect.signature(lab046.scan_continuous_soft_stream).parameters)
forbidden = {
"frame_plans",
"frame_starts",
"frame_count",
"fragment_indexes",
"packets",
"coded_bits",
"original_object",
}
assert parameters.isdisjoint(forbidden)
def forbidden_tx_lookup(*args, **kwargs):
raise AssertionError("scanner обратился к TX-side plan")
monkeypatch.setattr(lab046, "build_object_frames", forbidden_tx_lookup)
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
assert received.recovered_object == obj