1065 lines
45 KiB
Python
1065 lines
45 KiB
Python
"""Lab046: многопакетная передача большого бинарного объекта поверх Lab045.
|
||
|
||
Разделены два уровня, они не смешиваются:
|
||
|
||
- Уровень 1 (внутри одного радиокадра) — Lab045: свёрточный код R=1/2 K=7,
|
||
перемежитель, мягкий декодер Витерби, CRC пакета. Исправляет битовые
|
||
ошибки внутри принятого кадра.
|
||
- Уровень 2 (большой объект) — существующий ``protocol/image_fragments``:
|
||
фрагментация, нумерация фрагментов, идентификатор объекта, сборка. Собирает
|
||
объект из нескольких независимо переданных кадров.
|
||
|
||
Новый код здесь минимален: он только связывает эти два готовых уровня и
|
||
добавляет fail-closed сборщик объекта, который при недостающем или
|
||
повреждённом фрагменте возвращает ``object_complete = False`` без исключений
|
||
и без фиктивного объекта. Формат фрагмента не дублируется — используется
|
||
существующий 12-байтовый заголовок ``image_fragments`` (``>IHHI``:
|
||
object_id, fragment_index, fragment_count, original_size).
|
||
|
||
Раскладка нагрузки пакета (256 байт, как в доказанной точке Lab045):
|
||
|
||
[12 байт метаданных фрагмента][244 байта данных фрагмента]
|
||
|
||
Последний фрагмент короче: его нагрузка добивается нулями до 256 байт перед
|
||
Lab045 и обрезается на приёме по длине, вычисляемой из полей заголовка
|
||
(object size и fragment count). Erasure-код 12+3 здесь НЕ используется — это
|
||
отдельное будущее расширение для потери целых фрагментов.
|
||
|
||
RF в этой лабе не запускается: RF TX = 0, только программная проверка.
|
||
"""
|
||
|
||
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
|
||
|
||
|
||
# ============================================================ Константы Lab046
|
||
|
||
PACKET_PAYLOAD_SIZE = lab045.PAYLOAD_SIZE_BYTES # 256, доказанная точка Lab045
|
||
FRAGMENT_DATA_SIZE = PACKET_PAYLOAD_SIZE - fragments.FRAGMENT_HEADER_SIZE # 244
|
||
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
|
||
|
||
|
||
# ============================================================ Детерминированный объект
|
||
|
||
|
||
def make_deterministic_object(
|
||
size: int = DEFAULT_OBJECT_SIZE,
|
||
seed: int = DEFAULT_OBJECT_SEED,
|
||
) -> bytes:
|
||
"""Собрать детерминированный псевдослучайный бинарный объект (не JPEG)."""
|
||
|
||
if size <= 0:
|
||
raise ValueError("Размер объекта должен быть положительным")
|
||
generator = np.random.default_rng(seed)
|
||
return generator.integers(0, 256, size=size, dtype=np.uint8).tobytes()
|
||
|
||
|
||
def object_sha256(data: bytes) -> str:
|
||
return hashlib.sha256(data).hexdigest()
|
||
|
||
|
||
# ============================================================ Уровень объекта -> кадры
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FramePlan:
|
||
"""Один фрагмент объекта, уже упакованный в кадр Lab045."""
|
||
|
||
fragment_index: int
|
||
fragment_count: int
|
||
encoded_payload_length: int # реальная длина 12+data до добивки нулями
|
||
plan: lab045.TransmissionPlan
|
||
|
||
|
||
def _pad_payload_to_packet(payload: bytes) -> bytes:
|
||
if len(payload) > PACKET_PAYLOAD_SIZE:
|
||
raise ValueError("Нагрузка фрагмента больше размера пакета Lab045")
|
||
return payload + bytes(PACKET_PAYLOAD_SIZE - len(payload))
|
||
|
||
|
||
def build_object_frames(
|
||
data: bytes,
|
||
object_id: int = DEFAULT_OBJECT_ID,
|
||
) -> list[FramePlan]:
|
||
"""Разбить объект и упаковать каждый фрагмент в кадр Lab045."""
|
||
|
||
object_fragments = fragments.split_image_bytes(
|
||
data,
|
||
object_id,
|
||
fragment_data_size=FRAGMENT_DATA_SIZE,
|
||
)
|
||
frame_plans: list[FramePlan] = []
|
||
for fragment in object_fragments:
|
||
payload = fragments.encode_image_fragment(fragment)
|
||
packet_payload = _pad_payload_to_packet(payload)
|
||
plan = lab045.build_transmission_plan(packet_payload)
|
||
frame_plans.append(
|
||
FramePlan(
|
||
fragment_index=fragment.fragment_index,
|
||
fragment_count=fragment.fragment_count,
|
||
encoded_payload_length=len(payload),
|
||
plan=plan,
|
||
)
|
||
)
|
||
return frame_plans
|
||
|
||
|
||
def expected_fragment_data_length(
|
||
fragment_index: int,
|
||
fragment_count: int,
|
||
original_size: int,
|
||
) -> int | None:
|
||
"""Длина данных фрагмента, вычисленная только из полей заголовка.
|
||
|
||
Возвращает None при несогласованных полях (fail-closed), не бросая.
|
||
"""
|
||
|
||
if fragment_count <= 0 or original_size <= 0:
|
||
return None
|
||
if not 0 <= fragment_index < fragment_count:
|
||
return None
|
||
full = FRAGMENT_DATA_SIZE
|
||
# Верхняя граница: объект из fragment_count кусков по full байт не может
|
||
# быть больше fragment_count*full. Это отсекает абсурдный original_size
|
||
# для любого индекса и не даёт полю управлять размером аллокаций.
|
||
if original_size > fragment_count * full:
|
||
return None
|
||
if (fragment_count - 1) * full >= original_size and fragment_count > 1:
|
||
return None
|
||
if fragment_index < fragment_count - 1:
|
||
return full
|
||
last = original_size - (fragment_count - 1) * full
|
||
if not 1 <= last <= full:
|
||
return None
|
||
return last
|
||
|
||
|
||
def recover_fragment_from_payload(packet_payload: bytes) -> fragments.ImageFragment | None:
|
||
"""Снять добивку и разобрать фрагмент из 256-байтной нагрузки. Fail-closed.
|
||
|
||
Использует только принятую нагрузку. Переданный объект неизвестен.
|
||
"""
|
||
|
||
try:
|
||
fragment = fragments.decode_image_fragment(packet_payload)
|
||
except (fragments.ImageFragmentError, TypeError, ValueError):
|
||
return None
|
||
real_length = expected_fragment_data_length(
|
||
fragment.fragment_index,
|
||
fragment.fragment_count,
|
||
fragment.original_size,
|
||
)
|
||
if real_length is None or len(fragment.data) < real_length:
|
||
return None
|
||
return replace(fragment, data=fragment.data[:real_length])
|
||
|
||
|
||
# ============================================================ Fail-closed сборщик
|
||
|
||
|
||
@dataclass
|
||
class AssemblerCounters:
|
||
accepted: int = 0
|
||
duplicates: int = 0
|
||
conflicting: int = 0
|
||
rejected: int = 0
|
||
|
||
|
||
class ObjectAssembler:
|
||
"""Собрать объект из независимо принятых фрагментов, ничего не бросая.
|
||
|
||
Любой плохой вход (чужой object_id, несогласованный счётчик/размер,
|
||
невозможный индекс, конфликтующий дубликат, malformed) отвергается и не
|
||
выходит наружу исключением. Пока не собраны все индексы —
|
||
``object_complete()`` равен False, ``recovered_object()`` равен None.
|
||
Фиктивный объект не создаётся.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._object_id: int | None = None
|
||
self._fragment_count: int | None = None
|
||
self._original_size: int | None = None
|
||
self._by_index: dict[int, fragments.ImageFragment] = {}
|
||
self.counters = AssemblerCounters()
|
||
|
||
def offer(self, fragment: fragments.ImageFragment | None) -> str:
|
||
"""Предложить фрагмент. Вернуть accepted/duplicate/conflicting/rejected."""
|
||
|
||
if fragment is None:
|
||
self.counters.rejected += 1
|
||
return "rejected"
|
||
try:
|
||
index = int(fragment.fragment_index)
|
||
count = int(fragment.fragment_count)
|
||
size = int(fragment.original_size)
|
||
object_id = int(fragment.image_id)
|
||
if count <= 0 or size <= 0 or not 0 <= index < count:
|
||
self.counters.rejected += 1
|
||
return "rejected"
|
||
if len(fragment.data) < 1:
|
||
self.counters.rejected += 1
|
||
return "rejected"
|
||
if self._object_id is None:
|
||
self._object_id = object_id
|
||
self._fragment_count = count
|
||
self._original_size = size
|
||
elif (
|
||
object_id != self._object_id
|
||
or count != self._fragment_count
|
||
or size != self._original_size
|
||
):
|
||
self.counters.rejected += 1
|
||
return "rejected"
|
||
existing = self._by_index.get(index)
|
||
if existing is not None:
|
||
if existing.data == fragment.data:
|
||
self.counters.duplicates += 1
|
||
return "duplicate"
|
||
self.counters.conflicting += 1
|
||
return "conflicting"
|
||
self._by_index[index] = fragment
|
||
self.counters.accepted += 1
|
||
return "accepted"
|
||
except Exception:
|
||
self.counters.rejected += 1
|
||
return "rejected"
|
||
|
||
def missing_indexes(self) -> list[int]:
|
||
if self._fragment_count is None:
|
||
return []
|
||
return [i for i in range(self._fragment_count) if i not in self._by_index]
|
||
|
||
def object_complete(self) -> bool:
|
||
return (
|
||
self._fragment_count is not None
|
||
and len(self._by_index) == self._fragment_count
|
||
and not self.missing_indexes()
|
||
)
|
||
|
||
def recovered_object(self) -> bytes | None:
|
||
"""Собрать объект, только если он полон. Иначе None. Не бросает."""
|
||
|
||
if not self.object_complete():
|
||
return None
|
||
try:
|
||
return fragments.reassemble_image(list(self._by_index.values()))
|
||
except fragments.ImageFragmentError:
|
||
return None
|
||
|
||
|
||
# ============================================================ Канал (программный)
|
||
|
||
|
||
ChannelFunction = Callable[[lab045.TransmissionPlan, int], np.ndarray]
|
||
|
||
|
||
def ideal_channel(plan: lab045.TransmissionPlan, frame_index: int) -> np.ndarray:
|
||
del frame_index
|
||
return lab045.modulate_bits_to_ideal_soft(plan.post_preamble_bits)
|
||
|
||
|
||
def make_noisy_channel(
|
||
noise_sigma: float,
|
||
base_seed: int,
|
||
) -> ChannelFunction:
|
||
"""Детерминированный гауссов канал на уровне soft BPSK-символов.
|
||
|
||
Гауссов шум реалистичнее жёстких переворотов знака и сохраняет мягкую
|
||
информацию, которую использует декодер Витерби. Каждый кадр получает свой
|
||
воспроизводимый seed ``base_seed + frame_index``. Уровень sigma выбран
|
||
так, чтобы soft Viterbi исправлял все кадры (см. docs/lab046_current_state):
|
||
при большем sigma отдельный кадр может не декодироваться, и без erasure-
|
||
FEC объект окажется неполным — это отдельный сценарий, а не подгон.
|
||
"""
|
||
|
||
def channel(plan: lab045.TransmissionPlan, frame_index: int) -> np.ndarray:
|
||
return lab045.simulate_soft_channel(
|
||
plan,
|
||
noise_sigma=noise_sigma,
|
||
seed=base_seed + frame_index,
|
||
)
|
||
|
||
return channel
|
||
|
||
|
||
# ============================================================ Прогон объекта
|
||
|
||
|
||
@dataclass
|
||
class ObjectTransportResult:
|
||
object_size: int
|
||
object_sha256: str
|
||
fragment_count: int
|
||
protocol_packet_bytes: int
|
||
bpsk_frame_count: int
|
||
total_bpsk_symbols: int
|
||
total_waveform_samples: int
|
||
airtime_seconds: float
|
||
crc_pass_count: int
|
||
crc_fail_count: int
|
||
pre_fec_coded_bit_errors: int
|
||
post_fec_protocol_bit_errors: int
|
||
valid_fragments: int
|
||
duplicates: int
|
||
conflicting: int
|
||
rejected: int
|
||
missing_indexes: list[int]
|
||
object_complete: bool
|
||
recovered_object_sha256: str | None
|
||
object_byte_errors: int | None
|
||
sha_match: bool
|
||
|
||
|
||
def run_object_transport(
|
||
data: bytes,
|
||
object_id: int = DEFAULT_OBJECT_ID,
|
||
channel: ChannelFunction = ideal_channel,
|
||
delivery_order: list[int] | None = None,
|
||
duplicate_indexes: tuple[int, ...] = (),
|
||
drop_indexes: tuple[int, ...] = (),
|
||
corrupt_indexes: tuple[int, ...] = (),
|
||
) -> ObjectTransportResult:
|
||
"""Полный программный тракт объект -> кадры -> канал -> приём -> сборка.
|
||
|
||
Приёмник независим: TX-объект и TX-биты не используются для решений о
|
||
приёме, FEC, метаданных или завершении объекта. Диагностическое сравнение
|
||
с оригиналом делается только после независимой сборки.
|
||
"""
|
||
|
||
frame_plans = build_object_frames(data, object_id)
|
||
frame_by_index = {frame.fragment_index: frame for frame in frame_plans}
|
||
|
||
order = delivery_order if delivery_order is not None else [f.fragment_index for f in frame_plans]
|
||
|
||
assembler = ObjectAssembler()
|
||
crc_pass = 0
|
||
crc_fail = 0
|
||
pre_fec_errors = 0
|
||
post_fec_errors = 0
|
||
|
||
def deliver(frame_index: int, corrupt: bool) -> None:
|
||
nonlocal crc_pass, crc_fail, pre_fec_errors, post_fec_errors
|
||
frame = frame_by_index[frame_index]
|
||
soft = channel(frame.plan, frame_index)
|
||
if corrupt:
|
||
# Детерминированно инвертируем весь coded-поток (после защищённого
|
||
# PHY header): CRC гарантированно не пройдёт, пакет отвергается.
|
||
soft = soft.copy()
|
||
soft[lab045.PHY_HEADER_BIT_COUNT:] = -soft[lab045.PHY_HEADER_BIT_COUNT:]
|
||
reception = lab045.analyze_post_preamble_soft(soft)
|
||
# Диагностика pre/post-FEC только после независимого CRC-решения.
|
||
diagnostic = lab045.diagnostic_pre_and_post_fec(reception, 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 += pre
|
||
if post is not None:
|
||
post_fec_errors += post
|
||
if reception.analysis["crc"] == lab045.CRC_PASS and reception.payload_bytes is not None:
|
||
crc_pass += 1
|
||
fragment = recover_fragment_from_payload(reception.payload_bytes)
|
||
assembler.offer(fragment)
|
||
else:
|
||
crc_fail += 1
|
||
|
||
for frame_index in order:
|
||
deliver(frame_index, corrupt=frame_index in corrupt_indexes)
|
||
for frame_index in duplicate_indexes:
|
||
deliver(frame_index, corrupt=False)
|
||
for frame_index in drop_indexes:
|
||
assembler._by_index.pop(frame_index, None)
|
||
|
||
recovered = assembler.recovered_object()
|
||
recovered_sha = object_sha256(recovered) if recovered is not None else None
|
||
original_sha = object_sha256(data)
|
||
if recovered is not None and len(recovered) == len(data):
|
||
byte_errors = int(np.count_nonzero(
|
||
np.frombuffer(recovered, dtype=np.uint8) != np.frombuffer(data, dtype=np.uint8)
|
||
))
|
||
elif recovered is not None:
|
||
byte_errors = max(len(recovered), len(data))
|
||
else:
|
||
byte_errors = None
|
||
|
||
total_symbols = sum(f.plan.frame_symbol_count for f in frame_plans)
|
||
total_samples = sum(f.plan.waveform_sample_count for f in frame_plans)
|
||
airtime = sum(f.plan.airtime_seconds for f in frame_plans)
|
||
|
||
return ObjectTransportResult(
|
||
object_size=len(data),
|
||
object_sha256=original_sha,
|
||
fragment_count=len(frame_plans),
|
||
protocol_packet_bytes=len(frame_plans[0].plan.packet),
|
||
bpsk_frame_count=len(frame_plans),
|
||
total_bpsk_symbols=total_symbols,
|
||
total_waveform_samples=total_samples,
|
||
airtime_seconds=airtime,
|
||
crc_pass_count=crc_pass,
|
||
crc_fail_count=crc_fail,
|
||
pre_fec_coded_bit_errors=pre_fec_errors,
|
||
post_fec_protocol_bit_errors=post_fec_errors,
|
||
valid_fragments=assembler.counters.accepted,
|
||
duplicates=assembler.counters.duplicates,
|
||
conflicting=assembler.counters.conflicting,
|
||
rejected=assembler.counters.rejected,
|
||
missing_indexes=assembler.missing_indexes(),
|
||
object_complete=assembler.object_complete(),
|
||
recovered_object_sha256=recovered_sha,
|
||
object_byte_errors=byte_errors,
|
||
sha_match=(recovered_sha == original_sha and recovered is not None),
|
||
)
|
||
|
||
|
||
# ============================================================ 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
|
||
|
||
|
||
def goodput_report(result: ObjectTransportResult) -> dict:
|
||
"""Честная baseline-оценка эфирного времени и полезной пропускной способности."""
|
||
|
||
airtime = result.airtime_seconds
|
||
object_bytes = result.object_size
|
||
goodput_bytes_per_second = object_bytes / airtime if airtime > 0 else 0.0
|
||
goodput_kbit_per_second = goodput_bytes_per_second * 8 / 1000.0
|
||
useful_data_bits = object_bytes * 8
|
||
total_symbols = result.total_bpsk_symbols
|
||
return {
|
||
"airtime_seconds": airtime,
|
||
"goodput_bytes_per_second": goodput_bytes_per_second,
|
||
"goodput_kbit_per_second": goodput_kbit_per_second,
|
||
"useful_data_bits": useful_data_bits,
|
||
"total_bpsk_symbols": total_symbols,
|
||
"useful_bits_per_symbol": useful_data_bits / total_symbols if total_symbols else 0.0,
|
||
"overhead": {
|
||
"fragment_metadata_bytes_per_packet": fragments.FRAGMENT_HEADER_SIZE,
|
||
"protocol_header_plus_crc_bytes_per_packet": lab045.HEADER_SIZE + lab045.CRC_SIZE,
|
||
"convolutional_rate": "1/2",
|
||
"phy_preamble_bits_per_frame": lab045.PREAMBLE_BIT_COUNT,
|
||
"protected_phy_header_bits_per_frame": lab045.PHY_HEADER_BIT_COUNT,
|
||
"guard_plus_rrc_symbol_times_per_frame": 2 * lab045.GUARD_SYMBOL_COUNT + lab045.RRC_SPAN_SYMBOLS,
|
||
},
|
||
}
|
||
|
||
|
||
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),
|
||
}
|
||
|
||
|
||
# ============================================================ Функциональные тесты
|
||
|
||
|
||
def run_functional_tests() -> None:
|
||
"""Быстрые самопроверки без данных эксперимента (вызывается quick_gate)."""
|
||
|
||
obj = make_deterministic_object(2048, seed=1)
|
||
ideal = run_object_transport(obj, channel=ideal_channel)
|
||
assert ideal.object_complete and ideal.sha_match and ideal.object_byte_errors == 0
|
||
assert ideal.crc_fail_count == 0
|
||
|
||
frames = build_object_frames(obj)
|
||
assert frames[0].plan.frame_symbol_count == 5724
|
||
assert all(len(f.plan.packet) == 268 for f in frames)
|
||
|
||
# Fragment round trip через существующий слой без канала.
|
||
fs = fragments.split_image_bytes(obj, DEFAULT_OBJECT_ID, FRAGMENT_DATA_SIZE)
|
||
rebuilt = fragments.reassemble_image(fs)
|
||
assert rebuilt == obj
|
||
|
||
# Последний короткий фрагмент восстанавливается по длине из заголовка.
|
||
last = fs[-1]
|
||
padded = _pad_payload_to_packet(fragments.encode_image_fragment(last))
|
||
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
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Lab046 multi-packet object transport (software)")
|
||
parser.add_argument("--object-size", type=int, default=DEFAULT_OBJECT_SIZE)
|
||
parser.add_argument("--seed", type=int, default=DEFAULT_OBJECT_SEED)
|
||
parser.add_argument("--sigma", type=float, default=0.40)
|
||
arguments = parser.parse_args()
|
||
|
||
obj = make_deterministic_object(arguments.object_size, arguments.seed)
|
||
ideal = run_object_transport(obj, channel=ideal_channel)
|
||
noisy = run_object_transport(
|
||
obj,
|
||
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")
|
||
print(f"object sha256 = {ideal.object_sha256}")
|
||
print(f"fragment data size = {FRAGMENT_DATA_SIZE} bytes")
|
||
print(f"fragment count = {ideal.fragment_count}")
|
||
print(f"protocol packet = {ideal.protocol_packet_bytes} bytes")
|
||
print(f"bpsk frames = {ideal.bpsk_frame_count}")
|
||
print(f"total bpsk symbols = {ideal.total_bpsk_symbols}")
|
||
print(f"total waveform samples = {ideal.total_waveform_samples}")
|
||
print(f"estimated airtime = {report['airtime_seconds']:.2f} s")
|
||
print(f"goodput = {report['goodput_bytes_per_second']:.1f} bytes/s = {report['goodput_kbit_per_second']:.2f} kbit/s")
|
||
print("--- ideal ---")
|
||
print(f"crc pass/fail = {ideal.crc_pass_count}/{ideal.crc_fail_count}")
|
||
print(f"object complete = {ideal.object_complete}, sha match = {ideal.sha_match}, byte errors = {ideal.object_byte_errors}")
|
||
print(f"--- noisy (gaussian sigma={arguments.sigma}) ---")
|
||
print(f"pre-FEC coded errors = {noisy.pre_fec_coded_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"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
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|