Lab052: suppress false preamble candidates

This commit is contained in:
LittleSam129
2026-09-08 17:29:52 +03:00
parent 9f33607d3a
commit cc38900cae
27 changed files with 77397 additions and 0 deletions

View File

@@ -0,0 +1,287 @@
"""Lab052: локальные группы пиков и дешёвая проверка частей PRBS7.
Кандидат никогда не становится пакетом без прежних PHY/FEC/CRC. Параметры
заданы длинами PHY и моделью шума, без ожидаемых стартов и содержимого TX.
"""
from __future__ import annotations
from collections import Counter
from contextlib import contextmanager
from dataclasses import dataclass
import math
from pathlib import Path
import sys
import time
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from scipy.signal import fftconvolve
from experiments import lab051_streaming_receiver as previous
phy = previous.phy
offline = previous.offline
SAMPLES_PER_SYMBOL = 16
PREAMBLE_SAMPLES = phy.lab045.PREAMBLE_BIT_COUNT * SAMPLES_PER_SYMBOL
COARSE_ERROR_SAMPLES = 2000 + 8 # исходное уточнение ±2000 плюс сетка децимации
MIN_FRAME_SAMPLES = (phy.lab045.PREAMBLE_BIT_COUNT + phy.lab045.PHY_HEADER_BIT_COUNT
+ phy.lab045.conv.encoded_bit_count(phy.lab045.MIN_PACKET_BYTES * 8)
+ 2 * phy.lab045.GUARD_SYMBOL_COUNT + phy.lab045.RRC_SPAN_SYMBOLS) * SAMPLES_PER_SYMBOL
CLUSTER_WIDTH_SAMPLES = PREAMBLE_SAMPLES
PART_FALSE_ALARM = .01
@dataclass(frozen=True)
class DetectorRule:
"""parts=0 оставляет только группировку; 1/2/3 нужны для сравнения правил."""
parts: int = 2
cluster_width_samples: int = CLUSTER_WIDTH_SAMPLES
def __post_init__(self):
if type(self.parts) is not int or not 0 <= self.parts <= 3:
raise ValueError("Нужно от нуля до трёх частей")
if (type(self.cluster_width_samples) is not int or self.cluster_width_samples < 0
or self.cluster_width_samples % 8
or self.cluster_width_samples >= MIN_FRAME_SAMPLES - 2 * COARSE_ERROR_SAMPLES):
raise ValueError("Окно должно различать два минимальных физических кадра")
def peak_groups(indexes: np.ndarray, width: int):
"""Полная ширина от первого пика, без продолжения по цепочке соседей.
На границе IQ-блока группа обрывается: возможна лишняя проверка, но ни
один следующий пик не теряется из-за представителя из другого блока.
"""
cursor = 0
while cursor < len(indexes):
end = int(np.searchsorted(indexes, indexes[cursor] + width, side="right"))
yield indexes[cursor:end]
cursor = end
def cluster_peak_indexes(scores: np.ndarray, width: int, threshold: float) -> np.ndarray:
"""Первые представители ограниченных групп; равенство → правый пик."""
peaks = offline._all_local_peak_indexes(scores)
peaks = peaks[scores[peaks] >= threshold]
return np.asarray([group[len(group) - 1 - int(np.argmax(scores[group[::-1]]))]
for group in peak_groups(peaks, width)], dtype=np.int64)
def part_correlations(values: np.ndarray, reference: np.ndarray, count: int) -> np.ndarray:
"""Несколько vdot на уже децимированном согласованном входе, без timing.
Модуль сохраняет неизвестную фазу/полярность и не вводит ограничения CFO
через согласование фаз между половинами. Каждая часть нормируется своей
энергией, поэтому усиление и смена мощности фона не требуют TX reference.
"""
if values.size != reference.size or count < 1:
return np.zeros(max(count, 0))
boundaries = np.linspace(0, reference.size, count + 1, dtype=int)
output = np.zeros(count)
for number, (low, high) in enumerate(zip(boundaries[:-1], boundaries[1:])):
part = values[low:high]
expected = reference[low:high]
energy = float(np.vdot(part, part).real) * float(np.vdot(expected, expected).real)
output[number] = abs(np.vdot(expected, part)) / math.sqrt(max(energy, 1e-30))
return output
def part_threshold(count: int) -> float:
"""Номинальная H0-модель: независимые комплексные отсчёты по символам.
|rho|²~Beta(1,N-1), alpha=0.01 на часть. Это исходная инженерная настройка,
не обещание точной вероятности после RRC/выбора пиков: фон проверяется отдельно.
Она фиксируется до положительных опытов и не подгоняется под их offsets.
"""
symbols = phy.lab045.PREAMBLE_BIT_COUNT // count
return math.sqrt(1 - PART_FALSE_ALARM ** (1 / (symbols - 1)))
class CandidateFrontEnd(previous.BlockFrontEnd):
"""Общая корреляция — согласованный фильтр преамбулы на двух выборках/символ.
Промежуточному этапу достаточно его исходных данных; полный общий RRC-
выход создаётся прежним строгим трактом только при прошедшем кандидате.
"""
def coarse_scores(self, samples, reference):
raw = self.decimator.get(samples, self.base)
mean = np.mean(raw, dtype=np.complex128).astype(np.complex64)
needed = math.ceil(self.owned_end / 8) + reference.size + 1
self.detection_values = raw[:needed].copy()
self.detection_values -= mean
self.detection_values *= previous.detection_rotation(self.detection_values.size, 8)
if self.detection_values.size < reference.size:
return np.empty(0, np.float32)
scores = previous.lab043._normalized_sliding_correlation(self.detection_values, reference).astype(np.float32)
scores[~np.isfinite(scores)] = 0
np.clip(scores, 0, 1, out=scores)
return scores
def split_scores(self, reference, count, score_count):
"""Общие корреляции частей для всех кандидатов, без per-candidate FFT.
Проверка выполняется ДО выбора представителя: сильный случайный пик
одной половины не может скрыть согласованный слабый кандидат рядом.
"""
output = np.empty((score_count, count), np.float32)
boundaries = np.linspace(0, reference.size, count + 1, dtype=int)
energy = np.concatenate(([0.], np.cumsum(np.abs(self.detection_values) ** 2, dtype=np.float64)))
for number, (low, high) in enumerate(zip(boundaries[:-1], boundaries[1:])):
expected = reference[low:high]
correlation = fftconvolve(self.detection_values[low:], np.conj(expected[::-1]), mode="valid")[:score_count]
local_energy = energy[high:high + score_count] - energy[low:low + score_count]
denominator = np.sqrt(np.maximum(local_energy * float(np.vdot(expected, expected).real), 1e-30))
output[:, number] = np.abs(correlation) / denominator
return output
class StreamingReceiver(previous.StreamingReceiver):
"""Ограниченный IQ и кандидаты только одного блока, API получает только IQ."""
def __init__(self, symbol_rate: int = 500000, *, block_samples: int = 65536,
rule: DetectorRule = DetectorRule(), observer=None):
super().__init__(symbol_rate, block_samples=block_samples)
self.rule, self.observer = rule, observer
self.detection_statistics = Counter()
self.cheap_seconds = self.cluster_seconds = 0.0
self.true_strict_seconds = self.false_strict_seconds = 0.0
self.peak_cluster_count = self.peak_eligible_count = self.candidate_state_count = 0
self.working_detection_arrays_peak_bytes = 0
with previous.previous.streaming_mode(self.mode):
self.raw_reference = phy.lab045.lab044._preamble_reference_samples().copy()
self.matched_taps = previous.lab043._matched_filter_taps().copy()
self.preamble_bits = phy.lab045.lab044.preamble_bits().copy()
for values in (self.raw_reference, self.matched_taps, self.preamble_bits):
values.flags.writeable = False
@contextmanager
def constant_templates(self):
"""Старый адаптер очищает lru на каждом push; эти три константы локальны.
Возвращаем именно функции до выхода streaming_mode, чтобы его
cache_clear и соседний старый режим работали по прежним правилам.
"""
targets = ((phy.lab045.lab044, "_preamble_reference_samples", lambda: self.raw_reference),
(previous.lab043, "_matched_filter_taps", lambda: self.matched_taps),
(phy.lab045.lab044, "preamble_bits", lambda: self.preamble_bits))
originals = [(owner, name, getattr(owner, name)) for owner, name, function in targets]
try:
for owner, name, function in targets:
setattr(owner, name, function)
yield
finally:
for owner, name, function in reversed(originals):
setattr(owner, name, function)
def _scan(self, owned_end):
with self.constant_templates():
return self._scan_with_templates(owned_end)
def _scan_with_templates(self, owned_end):
width = self.rule.cluster_width_samples // self.decimation
front = CandidateFrontEnd(self.buffer, owned_end - self.base_sample,
self.front_statistics, decimator=self.decimator, base=self.base_sample)
started = time.perf_counter()
scores = front.coarse_scores(self.buffer, self.reference)
self.coarse_seconds += time.perf_counter() - started
threshold = np.float32(phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD)
peaks = offline._all_local_peak_indexes(scores)
offsets = self.base_sample + peaks * self.decimation
owned = (offsets >= self.scan_sample) & (offsets < owned_end)
owned_peaks = peaks[owned]
first_stage = owned_peaks[scores[owned_peaks] >= threshold]
self.detection_statistics["total_local_peaks"] += len(owned_peaks)
self.detection_statistics["first_stage_candidates"] += len(first_stage)
self.peak_candidate_count = max(self.peak_candidate_count, len(first_stage))
if self.observer is not None:
self.observer.scores(scores, self.base_sample, self.scan_sample, owned_end, self.decimation)
started = time.perf_counter()
parts = (front.split_scores(self.reference, self.rule.parts, len(scores))
if self.rule.parts and len(scores) else np.empty((len(scores), 0)))
passed = (np.all(parts[first_stage] >= part_threshold(self.rule.parts), axis=1)
if self.rule.parts else np.ones(len(first_stage), dtype=bool))
self.cheap_seconds += time.perf_counter() - started
self.detection_statistics["cheap_stage_checked"] += len(first_stage)
self.detection_statistics["cheap_stage_rejected"] += int(np.sum(~passed))
indexes = first_stage[passed]
if self.observer is not None and hasattr(self.observer, "cheap"):
for index, keep in zip(first_stage, passed):
self.observer.cheap(self.base_sample + int(index) * self.decimation,
float(scores[index]), parts[index], bool(keep))
started = time.perf_counter()
groups = list(peak_groups(indexes, width))
self.cluster_seconds += time.perf_counter() - started
self.detection_statistics["clusters"] += len(groups)
self.peak_cluster_count = max(self.peak_cluster_count, len(groups))
self.candidate_state_count = len(indexes)
self.peak_eligible_count = max(self.peak_eligible_count, len(indexes))
self.working_detection_arrays_peak_bytes = max(self.working_detection_arrays_peak_bytes,
front.detection_values.nbytes + scores.nbytes * 4 + parts.nbytes + (len(peaks) + len(indexes)) * 24)
emitted = []
try:
with previous.optimized_context(front):
# Внутри группы — самый сильный первым. Отказ не даёт права
# подавить альтернативы: пробуем следующий. Только CRC PASS
# разрешает прежний пропуск уже принятого физического кадра.
ordered = [group[np.argsort(scores[group], kind="stable")[::-1]] for group in groups]
attempted_groups = set()
for group_number, index in ((number, item) for number, group in enumerate(ordered) for item in group):
absolute = self.base_sample + int(index) * self.decimation
# Сетка грубого поиска может округлить следующий стык на
# одну выборку назад. Не терять соседний физический кадр.
if absolute < self.accepted_until - self.decimation:
self.detection_statistics["inside_accepted_frame"] += 1
continue
self.detection_statistics["alternative_checks" if group_number in attempted_groups
else "representative_checks"] += 1
attempted_groups.add(group_number)
self.candidate_count += 1
candidate = offline.CoarseCandidate(int(index) * self.decimation, float(scores[index]))
started = time.perf_counter()
outcome = offline.decode_candidate_from_memmap(
self.buffer.view(np.float32), self.buffer.size, candidate, self.candidate_count,
read_iq_window=lambda raw, low, high: raw[2 * low:2 * high].view(np.complex64))
elapsed = time.perf_counter() - started
self.strict_seconds += elapsed
if self.observer is not None:
self.observer.outcome(self.base_sample, candidate, outcome)
if outcome.accepted and outcome.crc == phy.lab045.CRC_PASS and outcome.frame is not None:
self.true_strict_seconds += elapsed
self.accepted_until = self.base_sample + outcome.next_sample
self.packet_count += 1
emitted.append(previous.previous.ReceivedPacket(
self.base_sample + outcome.refined_start_sample, self.accepted_until,
self.total_samples, bytes(outcome.frame.reception.payload_bytes)))
else:
self.false_strict_seconds += elapsed
self.rejected_reasons[outcome.reason] += 1
finally:
self.candidate_state_count = 0
self.blocks_processed += 1
self.scan_sample = owned_end
retain_start = max(0, self.scan_sample - self.margin)
trim = retain_start - self.base_sample
if trim > 0:
self.buffer = self.buffer[trim:].copy()
self.base_sample = retain_start
return emitted
def run_functional_tests() -> None:
assert CLUSTER_WIDTH_SAMPLES < MIN_FRAME_SAMPLES - 2 * COARSE_ERROR_SAMPLES
assert .08 < part_threshold(2) < .09
assert StreamingReceiver().finish() == []
def main() -> None:
run_functional_tests()
print("Lab052 detector functional checks PASS; RF TX=0")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,157 @@
"""Lab052: распределения и численные проверки после независимых решений RX."""
from __future__ import annotations
import json
from pathlib import Path
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from experiments.lab052_detector_study import OUTPUT, quantiles, write_result
from experiments import lab052_candidate_detection as detection
from experiments.lab051_receiver_profile import array_observation
def measure_arrays() -> dict:
"""Отдельный короткий RX replay, без смешивания tracing с замером скорости.
ndarray в Python-locals даёт нижнюю границу наблюдаемых массивов; C/SciPy
могут иметь невидимые временные выделения. tracemalloc peak — отдельная
измеренная величина, не RSS и не универсальная верхняя граница памяти.
"""
physical = detection.phy.lab045
payload = bytes(range(256))
plan = physical.build_transmission_plan(payload)
raw = detection.phy.build_parameterized_tx_cs8(plan, detection.phy.PhyMode(500000))
components = np.frombuffer(raw, np.int8).astype(np.float32)
values = components[0::2] + 1j * components[1::2]
random_generator = np.random.default_rng(52040)
sigma = np.sqrt(float(np.sum(abs(values)**2)) / plan.frame_symbol_count / (2 * 10**.7))
values += sigma * (random_generator.normal(size=len(values)) + 1j*random_generator.normal(size=len(values)))
samples = .2 * (random_generator.normal(size=63217 + len(values) + 350000)
+ 1j * random_generator.normal(size=63217 + len(values) + 350000))
samples[63217:63217 + len(values)] = values
samples = samples.astype(np.complex64)
def replay():
receiver = detection.StreamingReceiver()
packets = []
for start in range(0, len(samples), receiver.block_samples):
packets.extend(receiver.push_iq_block(samples[start:start + receiver.block_samples]))
packets.extend(receiver.finish())
assert len(packets) == 1 and packets[0].payload == payload
result = {**array_observation(replay), "iq_seconds": len(samples) / 8000000,
"packets": 1, "interpretation": "observed Python ndarray lower bound; tracked RX allocation peak; input IQ prepared before tracing",
"timing_not_used_as_benchmark": True, "rf_tx": 0}
write_result("allocations.json", result)
return result
def analyze() -> dict:
baseline = json.loads((OUTPUT / "baseline_profile.json").read_text(encoding="utf-8"))
optimized = json.loads((OUTPUT / "parts2.json").read_text(encoding="utf-8"))
before = np.load(OUTPUT / "baseline_observations.npz")
after = np.load(OUTPUT / "parts2_observations.npz")
peaks, outcomes = before["peaks"], before["outcomes"]
true = outcomes[outcomes[:, 3] == 1]
false = outcomes[outcomes[:, 3] == 0]
starts = np.sort(true[:, 2].astype(np.int64))
new_starts = np.sort(after["outcomes"][after["outcomes"][:, 3] == 1, 2].astype(np.int64))
missing = np.setdiff1d(starts, new_starts)
unexpected = np.setdiff1d(new_starts, starts)
main_scores = []
for start in starts:
low, high = np.searchsorted(peaks[:, 0], [start - detection.COARSE_ERROR_SAMPLES,
start + detection.COARSE_ERROR_SAMPLES], side="left")
main_scores.append(float(peaks[low:high, 1].max()))
# Окна чистого фона выделяются ТОЛЬКО после независимых принятых кадров.
# В этом опыте decoded PHY packet 268B => waveform 92256 samples.
frame_samples = (detection.phy.lab045.PREAMBLE_BIT_COUNT + detection.phy.lab045.PHY_HEADER_BIT_COUNT
+ detection.phy.lab045.conv.encoded_bit_count((256 + detection.phy.lab045.MIN_PACKET_BYTES) * 8)
+ 42) * 16
blocks = baseline["detection_profile"]["blocks"]
noise_blocks = []
for block in blocks:
low, high = block["start_sample"], block["end_sample"] + (1280 + 42) * 16
if not np.any((starts < high) & (starts + frame_samples > low)):
noise_blocks.append(block)
noise_statistics = {name: quantiles([block["score_statistics"][name] for block in noise_blocks])
for name in ("median", "mad", "p90", "p99", "p999")}
cheap = np.load(OUTPUT / "parts2_cheap.npy")
accepted_coarse = after["outcomes"][after["outcomes"][:, 3] == 1, 0]
is_true = np.isin(cheap[:, 0], accepted_coarse)
part_min = cheap[:, 3:].min(axis=1)
false_count = len(false)
new_false = optimized["detector"]["strict_false_candidates"]
result = {
"classification": "strict PHY/FEC/CRC verdict first; no diagnostic feedback into RX",
"baseline_true_unique_starts": len(np.unique(starts)),
"optimized_true_unique_starts": len(np.unique(new_starts)),
"missing_true_starts": missing.tolist(), "unexpected_true_starts": unexpected.tolist(),
"true_main_peak_scores": quantiles(main_scores), "false_strict_scores": quantiles(false[:, 1]),
"baseline_false_refined_positions": len(np.unique(false[:, 2])),
"baseline_false_repeated_refinements": len(false) - len(np.unique(false[:, 2])),
"noise_only_blocks": len(noise_blocks), "noise_block_statistics": noise_statistics,
"split_minimum_true_strict": quantiles(part_min[is_true]),
"split_minimum_other_candidates": quantiles(part_min[~is_true]),
"strict_false_reduction": false_count / new_false,
"strict_false_reduction_fraction": 1 - new_false / false_count,
"first_stage_threshold_unchanged": detection.phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
"part_threshold": detection.part_threshold(2), "rf_tx": 0,
}
write_result("statistics.json", result)
# Научные статические рисунки сохраняются самостоятельными артефактами.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
figure, axes = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
axis = axes[0, 0]
bins = np.linspace(0, 1, 101)
axis.hist(false[:, 1], bins, histtype="step", label="Ложные строгие кандидаты", log=True)
axis.hist(main_scores, bins, histtype="step", label="Главный пик настоящей преамбулы", log=True)
axis.axvline(result["first_stage_threshold_unchanged"], color="gray", linestyle="--", label="Первый порог")
axis.set(xlabel="Нормированная корреляция", ylabel="Количество", title="Классификация после PHY/FEC/CRC")
axis.legend(fontsize=8)
axis = axes[0, 1]
for name, label in (("median", "Медиана"), ("mad", "MAD"), ("p99", "Квантиль 99%")):
axis.plot([block["start_sample"] / 8000000 for block in noise_blocks],
[block["score_statistics"][name] for block in noise_blocks], ".", markersize=2, label=label)
axis.set(xlabel="Время IQ, с", ylabel="Корреляционная статистика", title="Блоки чистого фона, определённые после RX")
axis.legend(fontsize=8)
axis = axes[1, 0]
axis.hist(part_min[~is_true], bins, histtype="step", log=True, label="Остальные локальные кандидаты")
axis.hist(part_min[is_true], bins, histtype="step", log=True, label="Кандидаты с последующим CRC PASS")
axis.axvline(detection.part_threshold(2), color="gray", linestyle="--", label="Порог каждой половины")
axis.set(xlabel="Меньшая корреляция двух половин", ylabel="Количество", title="Дешёвая проверка обеих частей")
axis.legend(fontsize=8)
axis = axes[1, 1]
rule_data = json.loads((OUTPUT / "rule_comparison.json").read_text(encoding="utf-8"))["rules"]
counts = [row["detector"]["strict_false_candidates"] for row in rule_data]
bars = axis.bar(["Группы", "1 часть", "2 части", "3 части"], counts)
axis.set_yscale("log")
for bar, count in zip(bars, counts):
axis.text(bar.get_x() + bar.get_width()/2, count*1.15, str(count), ha="center", fontsize=9)
axis.set(ylabel="Ложные строгие проверки", title="Все четыре варианта сохранили 240 пакетов")
figure.savefig(OUTPUT / "detector_distributions.png", dpi=160)
figure.savefig(OUTPUT / "detector_distributions.svg")
plt.close(figure)
return result
def run_functional_tests() -> None:
assert detection.part_threshold(2) < detection.part_threshold(3)
def main() -> None:
result = analyze()
print(json.dumps(result, ensure_ascii=False, indent=2))
print(json.dumps(measure_arrays(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,374 @@
"""Lab052: воспроизводимый профиль обнаружителя, только программный IQ.
Диагностическая история принадлежит эксперименту. Приёмник не получает её
обратно; эталон используется только после независимых решений PHY/FEC/CRC.
"""
from __future__ import annotations
import argparse
import cProfile
from collections import Counter
from dataclasses import replace
import hashlib
import json
from pathlib import Path
import sys
import time
from types import SimpleNamespace
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from PIL import Image
from experiments import lab051_streaming_receiver as previous
from experiments import lab051_receiver_performance as performance
from experiments.lab051_receiver_profile import process_memory
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab052"
def write_result(name: str, value: dict) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / name).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def sequence_fixture():
"""Тот же генератор Lab051, готовность JPEG фиксируется первым замером.
Прежний опыт не сохранял encode_seconds, поэтому побитную историческую
временную сетку восстановить нельзя. Все новые сравнения используют одну
сохранённую сетку, один seed и те же байты JPEG, включая реальную encode cost.
"""
with Image.open(ROOT / "data/raw/lab011_scene.jpg") as source:
sequence = performance.video.encode_sequence(source, performance.video.PROFILES[2], 30)
path = OUTPUT / "source_fixture.json"
if path.exists():
fixture = json.loads(path.read_text(encoding="utf-8"))
for item, recorded in zip(sequence, fixture["images"], strict=True):
assert hashlib.sha256(item.jpeg).hexdigest() == recorded["sha256"]
return [replace(item, encode_seconds=recorded["encode_seconds"])
for item, recorded in zip(sequence, fixture["images"], strict=True)]
write_result(path.name, {"seed": 50050, "description": "Lab051 generator; frozen measured JPEG readiness",
"images": [{"image_id": item.image_id, "bytes": len(item.jpeg),
"encode_seconds": item.encode_seconds,
"sha256": hashlib.sha256(item.jpeg).hexdigest()} for item in sequence]})
return sequence
def quantiles(values) -> dict:
values = np.asarray(values, dtype=np.float64)
if not values.size:
return {"count": 0}
median = float(np.median(values))
return {"count": values.size, "min": float(values.min()), "median": median,
"mad": float(np.median(np.abs(values - median))),
"p90": float(np.quantile(values, .9)), "p99": float(np.quantile(values, .99)),
"p999": float(np.quantile(values, .999)), "max": float(values.max())}
class DetectionObserver:
"""Полная история только для конечного лабораторного профиля, вне RX."""
def __init__(self):
self.peaks = []
self.blocks = []
self.outcomes = []
self.cheap_rows = []
def scores(self, scores, base, low, high, decimation):
indexes = previous.offline._all_local_peak_indexes(scores)
offsets = base + indexes * decimation
selected = (offsets >= low) & (offsets < high)
self.peaks.append(np.column_stack((offsets[selected], scores[indexes[selected]])))
first = max(0, (low - base + decimation - 1) // decimation)
last = min(len(scores), (high - base + decimation - 1) // decimation)
self.blocks.append({"start_sample": low, "end_sample": high,
"score_statistics": quantiles(scores[first:last]),
"peak_statistics": quantiles(scores[indexes[selected]])})
def outcome(self, base, candidate, outcome):
self.outcomes.append((base + candidate.sample_offset, candidate.score,
base + outcome.refined_start_sample, int(outcome.accepted)))
def cheap(self, absolute, score, parts, passed):
self.cheap_rows.append([absolute, score, int(passed), *parts])
def save(self, name, result):
peaks = np.concatenate(self.peaks) if self.peaks else np.empty((0, 2))
outcomes = np.asarray(self.outcomes).reshape(-1, 4)
np.savez_compressed(OUTPUT / f"{name}_observations.npz", peaks=peaks, outcomes=outcomes)
if self.cheap_rows:
np.save(OUTPUT / f"{name}_cheap.npy", np.asarray(self.cheap_rows))
threshold = previous.phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD
valid = outcomes[:, 3] == 1
# Сначала строгий вердикт, затем классификация корреляционной статистики.
result["detection_profile"] = {
"total_local_peaks": len(peaks), "first_stage_candidates": int(np.sum(peaks[:, 1] >= threshold)),
"strict_candidates": len(outcomes), "strict_true_candidates": int(np.sum(valid)),
"strict_false_candidates": int(np.sum(~valid)),
"candidates_per_rf_second": len(outcomes) / result["iq_seconds"],
"false_candidates_per_rf_second": int(np.sum(~valid)) / result["iq_seconds"],
"all_local_peaks": quantiles(peaks[:, 1]),
"strict_true_scores": quantiles(outcomes[valid, 1]),
"strict_false_scores": quantiles(outcomes[~valid, 1]),
"blocks": self.blocks,
}
class ProfileBaseline(previous.StreamingReceiver):
"""Штатный Lab051 с наблюдением после корреляции и после решения."""
def __init__(self, observer):
super().__init__()
self.observer = observer
def _scan(self, owned_end):
front = previous.BlockFrontEnd(self.buffer, owned_end - self.base_sample, self.front_statistics,
decimator=self.decimator, base=self.base_sample)
original_scores = front.coarse_scores
def scores(samples, reference):
values = original_scores(samples, reference)
self.observer.scores(values, self.base_sample, self.scan_sample, owned_end, self.decimation)
return values
front.coarse_scores = scores
original_decode = previous.offline.decode_candidate_from_memmap
def decode(raw, count, candidate, number, **kwargs):
outcome = original_decode(raw, count, candidate, number, **kwargs)
self.observer.outcome(self.base_sample, candidate, outcome)
return outcome
try:
previous.offline.decode_candidate_from_memmap = decode
with previous.optimized_context(front):
return previous.previous.StreamingReceiver._scan(self, owned_end)
finally:
previous.offline.decode_candidate_from_memmap = original_decode
def measured_stream(sequence, factory):
"""Повторно использовать исходную событийную шкалу с фактическими push times."""
instances = []
def create():
receiver = factory()
instances.append(receiver)
return receiver
original = performance.fast
try:
performance.fast = SimpleNamespace(StreamingReceiver=create)
result = performance.run_stream(sequence)
finally:
performance.fast = original
receiver = instances[0]
result["coarse_seconds"] = receiver.coarse_seconds
result["strict_seconds"] = receiver.strict_seconds
result["peak_candidates_in_block"] = receiver.peak_candidate_count
if hasattr(receiver, "detection_statistics"):
result["detector"] = {**dict(receiver.detection_statistics),
"strict_true_candidates": receiver.packet_count,
"strict_false_candidates": receiver.candidate_count - receiver.packet_count,
"strict_checks_per_rf_second": receiver.candidate_count / result["iq_seconds"],
"cheap_seconds": receiver.cheap_seconds, "cluster_seconds": receiver.cluster_seconds,
"true_strict_seconds": receiver.true_strict_seconds,
"false_strict_seconds": receiver.false_strict_seconds,
"peak_cluster_count": receiver.peak_cluster_count,
"peak_eligible_count": receiver.peak_eligible_count,
"candidate_state_after_finish": receiver.candidate_state_count,
"detection_arrays_peak_estimate_bytes": receiver.working_detection_arrays_peak_bytes}
return result, receiver
def profile_baseline(sequence):
observer = DetectionObserver()
result, receiver = measured_stream(sequence, lambda: ProfileBaseline(observer))
observer.save("baseline", result)
result["timing_includes_diagnostic_observer"] = True
write_result("baseline_profile.json", result)
print(json.dumps({key: value for key, value in result.items() if key in (
"candidate_count", "received_images", "rx_physical_packets", "processing_per_rf_second",
"coarse_seconds", "strict_seconds")}), flush=True)
print(json.dumps({key: value for key, value in result["detection_profile"].items() if key != "blocks"}), flush=True)
def unprofiled_baseline(sequence):
result, receiver = measured_stream(sequence, previous.StreamingReceiver)
result["timing_includes_diagnostic_observer"] = False
write_result("baseline_stream.json", result)
print(json.dumps({key: result[key] for key in ("candidate_count", "processing_seconds",
"processing_per_rf_second", "rx_physical_packets", "received_images", "object_jpeg_integrity")}), flush=True)
def run_functional_tests() -> None:
assert quantiles([1, 2, 3])["median"] == 2
def compare_rules(sequence):
from experiments import lab052_candidate_detection as detection
baseline = json.loads((OUTPUT / "baseline_profile.json").read_text(encoding="utf-8"))
results = []
for parts in (0, 1, 2, 3):
observer = DetectionObserver()
rule = detection.DetectorRule(parts=parts)
result, receiver = measured_stream(sequence, lambda: detection.StreamingReceiver(rule=rule, observer=observer))
observer.save(f"parts{parts}", result)
result["rule"] = {"parts": parts, "width_samples": rule.cluster_width_samples,
"part_threshold": detection.part_threshold(parts) if parts else None}
result["true_candidates_lost"] = baseline["rx_physical_packets"] - result["rx_physical_packets"]
false_count = result["detector"]["strict_false_candidates"]
baseline_false = baseline["detection_profile"]["strict_false_candidates"]
result["false_candidate_reduction"] = baseline_false / false_count if false_count else None
result["false_candidate_reduction_fraction"] = 1 - false_count / baseline_false
result["timing_includes_diagnostic_observer"] = True
write_result(f"parts{parts}.json", result)
compact = {key: result[key] for key in ("rule", "received_images", "object_jpeg_integrity",
"true_candidates_lost", "false_candidate_reduction", "detector",
"processing_per_rf_second", "fresh_display_fps")}
results.append(compact)
print(json.dumps(compact), flush=True)
write_result("rule_comparison.json", {"rules": results, "rf_tx": 0})
def optimized_stream(sequence):
from experiments import lab052_candidate_detection as detection
results = []
for number in range(1, 4):
result, receiver = measured_stream(sequence, detection.StreamingReceiver)
result["timing_includes_diagnostic_observer"] = False
write_result(f"stream_repeat_{number}.json", result)
results.append(result)
print(json.dumps({"repeat": number, "ratio": result["processing_per_rf_second"],
"fresh_fps": result["fresh_display_fps"], "packets": result["rx_physical_packets"]}), flush=True)
result = sorted(results, key=lambda row: row["processing_seconds"])[1]
result["selection"] = "median processing time of three complete measured passes; one whole timeline"
write_result("stream.json", result)
print(json.dumps({key: value for key, value in result.items() if key not in (
"blocks", "images", "rss_samples", "fresh_image_age_seconds")}), flush=True)
def profile_cpu(sequence):
from experiments import lab052_candidate_detection as detection
import pstats
from io import StringIO
profiler = cProfile.Profile()
class ProfiledReceiver(detection.StreamingReceiver):
def push_iq_block(self, samples):
return profiler.runcall(super().push_iq_block, samples)
def finish(self):
return profiler.runcall(super().finish)
result, receiver = measured_stream(sequence, ProfiledReceiver)
profiler.dump_stats(str(OUTPUT / "receiver_cpu.prof"))
report = StringIO()
stats = pstats.Stats(profiler, stream=report).strip_dirs()
stats.sort_stats("tottime").print_stats(30)
stats.sort_stats("cumtime").print_stats(30)
(OUTPUT / "receiver_cpu.txt").write_text(report.getvalue(), encoding="utf-8")
result["timing_includes_cprofile"] = True
write_result("cpu_profile.json", result)
print(report.getvalue(), flush=True)
def long_state_test(sequence=None, block_count=2048):
"""16,78 с распределённого шума, меняющаяся мощность, без истории в RX."""
from experiments import lab052_candidate_detection as detection
receiver = detection.StreamingReceiver()
random_generator = np.random.default_rng(52030)
checkpoints = []
accepted = 0
for number in range(block_count):
components = random_generator.standard_normal((2, receiver.block_samples), dtype=np.float32)
scale = (.002, .2, 20., .02)[(number // 257) % 4]
values = (components[0] + 1j * components[1]) * scale
accepted += len(receiver.push_iq_block(values))
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert receiver.candidate_state_count == 0
if (number + 1) % 256 == 0:
checkpoints.append({"block": number + 1, "iq_bytes": receiver.buffer.nbytes,
"decimated_bytes": receiver.decimator.values.nbytes,
"retained_candidates": receiver.candidate_state_count,
"peak_eligible_candidates": receiver.peak_eligible_count,
"counter_keys": len(receiver.detection_statistics) + len(receiver.rejected_reasons),
**process_memory()})
accepted += len(receiver.finish())
result = {"blocks": block_count, "iq_seconds": block_count * receiver.block_samples / 8000000,
"background_scales": [.002, .2, 20, .02], "accepted_noise_packets": accepted,
"peak_retained_iq_bytes": receiver.peak_buffer_samples * 8,
"iq_limit_bytes": receiver.buffer_limit_samples * 8,
"retained_after_finish_bytes": receiver.buffer.nbytes + receiver.decimator.values.nbytes,
"constant_template_bytes": receiver.raw_reference.nbytes + receiver.matched_taps.nbytes + receiver.preamble_bits.nbytes,
"candidate_state_after_finish": receiver.candidate_state_count,
"peak_eligible_candidates": receiver.peak_eligible_count,
"peak_cluster_count": receiver.peak_cluster_count,
"detector": dict(receiver.detection_statistics), "strict_candidates": receiver.candidate_count,
"processing_seconds": receiver.processing_seconds, "checkpoints": checkpoints,
"process_memory": process_memory(), "rf_tx": 0}
write_result("memory.json", result)
print(json.dumps(result), flush=True)
def sensitivity_test(sequence=None):
"""До/после на одинаковом IQ около границы работоспособности FEC."""
from experiments import lab052_candidate_detection as detection
physical = previous.phy.lab045
payload = bytes(range(256))
plan = physical.build_transmission_plan(payload)
raw = previous.phy.build_parameterized_tx_cs8(plan, previous.phy.PhyMode(500000))
components = np.frombuffer(raw, np.int8).astype(np.float32)
clean = components[0::2] + 1j * components[1::2]
energy = float(np.sum(np.abs(clean) ** 2)) / plan.frame_symbol_count
rows = []
for ebn0 in (0, 1, 2, 3, 7):
for cfo in (-100, 10, 100):
for seed, scale in ((52031, .002), (52032, .2), (52033, 1.)):
random_generator = np.random.default_rng(seed)
sigma = np.sqrt(energy / (2 * 10 ** (ebn0 / 10)))
phase = .73 + (seed % 2) * np.pi
values = clean * np.exp(1j * (phase + 2*np.pi*cfo*np.arange(len(clean))/8000000))
values += sigma * (random_generator.normal(size=len(clean)) + 1j*random_generator.normal(size=len(clean)))
samples = np.concatenate((np.zeros(63217, np.complex64), (values * scale).astype(np.complex64),
np.zeros(350000, np.complex64)))
outcomes = []
for constructor in (previous.StreamingReceiver, detection.StreamingReceiver):
receiver = constructor()
packets = []
for start in range(0, len(samples), receiver.block_samples):
packets.extend(receiver.push_iq_block(samples[start:start + receiver.block_samples]))
packets.extend(receiver.finish())
# Оценка содержимого только после завершения независимого RX.
outcomes.append({"accepted": len(packets),
"payload_match": len(packets) == 1 and packets[0].payload == payload,
"reasons": dict(receiver.rejected_reasons)})
rows.append({"ebn0_db": ebn0, "cfo_hz": cfo, "seed": seed, "amplitude_scale": scale,
"baseline": outcomes[0], "optimized": outcomes[1],
"lost_baseline_packet": outcomes[0]["payload_match"] and not outcomes[1]["payload_match"]})
result = {"cases": rows, "count": len(rows),
"baseline_correct": sum(row["baseline"]["payload_match"] for row in rows),
"optimized_correct": sum(row["optimized"]["payload_match"] for row in rows),
"lost_baseline_packets": sum(row["lost_baseline_packet"] for row in rows), "rf_tx": 0}
write_result("sensitivity.json", result)
print(json.dumps({key: value for key, value in result.items() if key != "cases"}), flush=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage", choices=("profile", "baseline", "rules", "stream", "cpu", "memory", "sensitivity"), default="profile")
arguments = parser.parse_args()
OUTPUT.mkdir(parents=True, exist_ok=True)
sequence = sequence_fixture()
print(f"Lab052 {arguments.stage}: START; RF TX=0", flush=True)
{"profile": profile_baseline, "baseline": unprofiled_baseline, "rules": compare_rules, "stream": optimized_stream,
"cpu": profile_cpu, "memory": long_state_test, "sensitivity": sensitivity_test}[arguments.stage](sequence)
if __name__ == "__main__":
main()