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