From e8d85ae4b7e91bf65cfc44671ec18b744ba0fcf9 Mon Sep 17 00:00:00 2001 From: LittleSam129 <1qwe432@gmail.com> Date: Tue, 1 Sep 2026 13:18:42 +0300 Subject: [PATCH] Lab046A: validate continuous soft-symbol frame scanning --- PROJECT_LOG.md | 30 ++ docs/lab046_current_state.md | 92 +++- experiments/lab046_bpsk_object_transport.py | 541 ++++++++++++++++++++ tests/test_lab046_bpsk_object_transport.py | 222 ++++++++ 4 files changed, 879 insertions(+), 6 deletions(-) diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index a400b63..31e85a1 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -719,3 +719,33 @@ Software gate: quick_gate PASS (438 тестов ядра, 6 функциона ## Следующий этап По решению 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 автоматически не запускать. diff --git a/docs/lab046_current_state.md b/docs/lab046_current_state.md index 6888695..2846b6e 100644 --- a/docs/lab046_current_state.md +++ b/docs/lab046_current_state.md @@ -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.** 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, а воспроизводимый @@ -118,8 +192,12 @@ count/original_size, невозможный индекс, конфликтующ | Число BPSK-кадров | 68 | | Всего информационных BPSK-символов | 389 232 (5724 × 68) | | Всего отсчётов формы сигнала | 156 835 200 (2 306 400 × 68) | -| Оценка эфирного времени | 19,60 с | -| Полезный goodput | 835,7 байт/с = 6,69 кбит/с | +| Baseline: дополнительный inter-frame gap | 0 отсчётов | +| 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 | Учёт кадра совпадает с Lab045: 2144 информационных бита, 6 tail-бит, 4300 @@ -149,15 +227,17 @@ Goodput 6,69 кбит/с — это честная baseline при 20 ксимв проходят (фрагмент туда-обратно, короткий последний фрагмент, чужой object_id, невозможный индекс, несогласованный счётчик, усечённые и абсурдные метаданные без крупной аллокации, полный тракт PHY для сценариев A–F, - отсутствие оракула, детерминизм и SHA объекта). + отсутствие оракула, последовательный continuous scan, rejected candidate, + MAX_FRAMES/MAX_SEARCH_ITERATIONS, регрессия против удаления настоящего кадра + ложным PRBS7 peak, детерминизм и SHA объекта). - Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка` проверен для всех шести сценариев значениями выше. ## Software gate -- `python tools/quick_gate.py`: PASS; 438 тестов ядра, 6 функциональных +- `python tools/quick_gate.py`: PASS; 454 теста ядра, 6 функциональных проверок лабораторий выполнено, 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`; на выполнение и результат тестов оно не повлияло. - `git diff --check`: чист. diff --git a/experiments/lab046_bpsk_object_transport.py b/experiments/lab046_bpsk_object_transport.py index 1e3f32f..a488150 100644 --- a/experiments/lab046_bpsk_object_transport.py +++ b/experiments/lab046_bpsk_object_transport.py @@ -32,11 +32,16 @@ from __future__ import annotations import argparse import hashlib +import sys from dataclasses import dataclass, replace +from pathlib import Path from typing import Callable 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 protocol import image_fragments as fragments @@ -49,6 +54,10 @@ DEFAULT_OBJECT_SIZE = 16384 # 16 KiB, ровный размер, не JPEG DEFAULT_OBJECT_ID = 0x4C303436 # "L046" 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 @@ -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) 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 @@ -495,6 +986,30 @@ def main() -> int: channel=make_noisy_channel(arguments.sigma, base_seed=46000), ) 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(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"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("--- 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)") return 0 diff --git a/tests/test_lab046_bpsk_object_transport.py b/tests/test_lab046_bpsk_object_transport.py index be73de8..b0b3fd2 100644 --- a/tests/test_lab046_bpsk_object_transport.py +++ b/tests/test_lab046_bpsk_object_transport.py @@ -7,6 +7,7 @@ from __future__ import annotations +import inspect import struct import numpy as np @@ -243,3 +244,224 @@ def test_receiver_ignores_original_object_on_corruption() -> None: def test_functional_self_check_runs() -> None: 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