Lab053: achieve real-time 5 fps software video receiver
This commit is contained in:
213
experiments/lab053_packet_processing.py
Normal file
213
experiments/lab053_packet_processing.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Lab053: локальные вычисления настоящего пакета с неизменным PHY.
|
||||
|
||||
Старые функции получают частный словарь зависимостей: их Python-код не
|
||||
переписывается и globals старых лабораторий не изменяются. Это позволяет
|
||||
одновременно декодировать пакеты с одним набором неизменяемых констант.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from types import FunctionType, SimpleNamespace
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import numpy as np
|
||||
from scipy.signal import oaconvolve
|
||||
|
||||
from experiments import lab052_candidate_detection as detection
|
||||
from experiments.lab053_viterbi import viterbi_decode_soft, warmup
|
||||
|
||||
previous = detection.previous
|
||||
physical = previous.lab045
|
||||
lab043 = previous.lab043
|
||||
SAMPLE_RATE = 8000000
|
||||
SYMBOL_RATE = 500000
|
||||
SAMPLES_PER_SYMBOL = 16
|
||||
|
||||
|
||||
def bind(function, **dependencies):
|
||||
"""Связать тот же code object с частными константами и зависимостями.
|
||||
|
||||
Без exec/eval и setattr модулей. Неподменённые protocol/parser helpers
|
||||
остаются штатными. Копируется словарь ссылок, а не массивы или IQ.
|
||||
"""
|
||||
function = getattr(function, "__wrapped__", function)
|
||||
namespace = dict(function.__globals__, **dependencies)
|
||||
result = FunctionType(function.__code__, namespace, function.__name__, function.__defaults__, function.__closure__)
|
||||
result.__kwdefaults__ = function.__kwdefaults__
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class PacketMeasure:
|
||||
seconds: Counter = field(default_factory=Counter)
|
||||
exclusive_seconds: Counter = field(default_factory=Counter)
|
||||
calls: Counter = field(default_factory=Counter)
|
||||
stack: list = field(default_factory=list)
|
||||
cache: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
CURRENT: ContextVar[PacketMeasure | None] = ContextVar("lab053_packet_measure", default=None)
|
||||
|
||||
|
||||
def measured(name):
|
||||
def decorate(function):
|
||||
def call(*args, **kwargs):
|
||||
context = CURRENT.get()
|
||||
if context is None:
|
||||
return function(*args, **kwargs)
|
||||
entry = [0.0]
|
||||
context.stack.append(entry)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
return function(*args, **kwargs)
|
||||
finally:
|
||||
elapsed = time.perf_counter() - started
|
||||
context.stack.pop()
|
||||
if context.stack:
|
||||
context.stack[-1][0] += elapsed
|
||||
context.seconds[name] += elapsed
|
||||
context.exclusive_seconds[name] += elapsed - entry[0]
|
||||
context.calls[name] += 1
|
||||
return call
|
||||
return decorate
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def rotation(length: int) -> np.ndarray:
|
||||
indexes = np.arange(length)
|
||||
values = np.exp(-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE).astype(np.complex64)
|
||||
values.flags.writeable = False
|
||||
return values
|
||||
|
||||
|
||||
class PacketProcessor:
|
||||
"""Параметры фиксированы; состояние кандидата принадлежит ContextVar.
|
||||
|
||||
Уточнение использует прежние ±2000 отсчётов, timing — все 1601 смещение.
|
||||
Полное тело по-прежнему запрашивается только после валидного PHY header.
|
||||
"""
|
||||
|
||||
def __init__(self, *, viterbi=viterbi_decode_soft):
|
||||
self.startup = warmup() if viterbi is viterbi_decode_soft else {"backend": "CUSTOM", "warmup_seconds": 0}
|
||||
constants = {"SAMPLES_PER_SYMBOL": SAMPLES_PER_SYMBOL, "SYMBOL_RATE": SYMBOL_RATE}
|
||||
scale = bind(physical.lab044._scale_and_carrier, **constants)
|
||||
self.bits = physical.lab044.preamble_bits().copy()
|
||||
waveform = scale(previous.iq.radio.bpsk_modulate(self.bits))
|
||||
self.reference = physical.lab044.signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
|
||||
self.taps = bind(lab043._matched_filter_taps, **constants)()
|
||||
self.expected = previous.iq.radio.bpsk_modulate(self.bits)
|
||||
self.kernel = np.zeros((len(self.bits) - 1) * SAMPLES_PER_SYMBOL + 1, np.complex64)
|
||||
self.kernel[::SAMPLES_PER_SYMBOL] = np.conj(self.expected)
|
||||
self.kernel = self.kernel[::-1].copy()
|
||||
for values in (self.bits, self.reference, self.taps, self.expected, self.kernel):
|
||||
values.flags.writeable = False
|
||||
slow_offsets = bind(lab043._timing_offsets, **constants)
|
||||
slow_timing = bind(previous.ORIGINAL_TIMING, _timing_offsets=slow_offsets, **constants)
|
||||
self.timing = measured("timing")(bind(previous.fast_preamble_timing,
|
||||
lab045=SimpleNamespace(SAMPLES_PER_SYMBOL=SAMPLES_PER_SYMBOL),
|
||||
timing_kernel=lambda unused: self.kernel, ORIGINAL_TIMING=slow_timing))
|
||||
estimate = measured("cfo_estimation")(bind(lab043._estimate_cfo_gain, **constants))
|
||||
self.carrier = measured("phase_polarity")(bind(lab043._evaluate_symbols,
|
||||
_estimate_cfo_gain=estimate, **constants))
|
||||
self.refine = measured("candidate_refinement")(bind(previous.ORIGINAL_REFINE, **constants))
|
||||
header = measured("phy_header")(physical.decode_phy_header_soft)
|
||||
body = bind(physical.analyze_post_preamble_soft, decode_phy_header_soft=header,
|
||||
conv=SimpleNamespace(encoded_bit_count=physical.conv.encoded_bit_count,
|
||||
viterbi_decode_soft=measured("viterbi")(viterbi)),
|
||||
parse_packet=measured("protocol_crc")(physical.parse_packet))
|
||||
physical_namespace = SimpleNamespace(**dict(vars(physical), **constants))
|
||||
physical_namespace.decode_phy_header_soft = header
|
||||
physical_namespace.analyze_post_preamble_soft = measured("body_processing")(body)
|
||||
preamble_namespace = SimpleNamespace(_preamble_reference_samples=lambda: self.reference,
|
||||
preamble_bits=lambda: self.bits,
|
||||
lab043=SimpleNamespace(_refine_frame_start=self.refine))
|
||||
self.strict = bind(previous.offline.decode_candidate_from_memmap,
|
||||
lab046c=SimpleNamespace(lab044=preamble_namespace, lab045=physical_namespace),
|
||||
lab046b=SimpleNamespace(_extract_soft_window=self.extract,
|
||||
IqFrameReception=previous.iq.IqFrameReception))
|
||||
|
||||
@measured("iq_window_preparation")
|
||||
def read_window(self, raw, low, high):
|
||||
return raw[2 * low:2 * high].view(np.complex64)
|
||||
|
||||
@measured("frequency_shift")
|
||||
def shift(self, segment):
|
||||
return (segment - np.mean(segment)) * rotation(len(segment))
|
||||
|
||||
@measured("matched_rrc_filtering")
|
||||
def filter(self, values):
|
||||
return oaconvolve(values, self.taps, mode="full")
|
||||
|
||||
@measured("soft_symbol_extraction")
|
||||
def normalize(self, matched, nominal, timing, count, carrier):
|
||||
symbols = matched[nominal + timing:nominal + timing + count * SAMPLES_PER_SYMBOL:SAMPLES_PER_SYMBOL].astype(np.complex128)
|
||||
if symbols.size != count:
|
||||
return None
|
||||
symbol_time = (np.arange(count, dtype=np.float64) + .5) / SYMBOL_RATE
|
||||
normalized = symbols * np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time)
|
||||
normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
|
||||
return normalized.real
|
||||
|
||||
@measured("extract_total")
|
||||
def extract(self, samples, frame_start, symbol_count):
|
||||
length = (symbol_count + 42) * SAMPLES_PER_SYMBOL
|
||||
if frame_start < 0 or frame_start + length > samples.size:
|
||||
return previous.iq.SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW")
|
||||
segment = samples[frame_start:frame_start + length]
|
||||
matched = self.filter(self.shift(segment))
|
||||
nominal = 16 * SAMPLES_PER_SYMBOL + self.taps.size - 1
|
||||
timing = self.timing(matched, nominal, self.expected)
|
||||
carrier = self.carrier(matched, nominal, timing, self.bits, len(self.bits))
|
||||
if carrier["abs_h"] == 0:
|
||||
return previous.iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "ZERO_GAIN")
|
||||
values = self.normalize(matched, nominal, timing, symbol_count, carrier)
|
||||
if values is None:
|
||||
return previous.iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS")
|
||||
return previous.iq.SoftWindow(True, values, int(timing),
|
||||
int((frame_start + nominal + timing) % SAMPLES_PER_SYMBOL), nominal, carrier, None)
|
||||
|
||||
def decode(self, samples, candidate, number=1):
|
||||
context = PacketMeasure()
|
||||
token = CURRENT.set(context)
|
||||
started = time.perf_counter()
|
||||
cpu_started = time.thread_time()
|
||||
try:
|
||||
outcome = self.strict(samples.view(np.float32), samples.size, candidate, number,
|
||||
read_iq_window=self.read_window)
|
||||
elapsed = time.perf_counter() - started
|
||||
row = {"accepted": outcome.accepted, "reason": outcome.reason, "seconds": elapsed,
|
||||
"thread_cpu_seconds": time.thread_time() - cpu_started,
|
||||
"stages": dict(context.seconds), "exclusive_stages": dict(context.exclusive_seconds),
|
||||
"calls": dict(context.calls), "coarse": candidate.sample_offset,
|
||||
"refined": outcome.refined_start_sample}
|
||||
if outcome.frame is not None:
|
||||
row.update(timing=outcome.frame.timing_offset_samples, cfo_hz=outcome.frame.estimated_cfo_hz,
|
||||
phase_rad=outcome.frame.estimated_phase_rad, polarity=outcome.frame.bpsk_polarity)
|
||||
return outcome, row
|
||||
finally:
|
||||
CURRENT.reset(token)
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
processor = PacketProcessor()
|
||||
assert len(processor.bits) == 1280 and len(processor.taps) == 161
|
||||
assert not processor.reference.flags.writeable
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run_functional_tests()
|
||||
print("Lab053 packet processing functional checks PASS; RF TX=0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
312
experiments/lab053_pipeline.py
Normal file
312
experiments/lab053_pipeline.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Lab053: ограниченный CPU-конвейер с фактическим временем выдачи JPEG.
|
||||
|
||||
Непрерывный IQ приходит по часовому расписанию либо с обратным давлением
|
||||
для измерения пропускной способности. Переполнение не склеивает разрыв IQ:
|
||||
сессия останавливается с явной причиной. Оборудование не используется.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from queue import Empty, Full, Queue
|
||||
import sys
|
||||
from threading import Event, Thread
|
||||
import time
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import numpy as np
|
||||
|
||||
from experiments import lab052_detector_study as study
|
||||
from experiments.lab051_receiver_performance import ImageSink
|
||||
from experiments.lab051_receiver_profile import process_memory
|
||||
from experiments.lab053_receiver_profile import SOURCE, OUTPUT, distribution, write_result
|
||||
from experiments.lab053_streaming_receiver import StreamingReceiver
|
||||
from experiments.lab053_packet_processing import PacketProcessor, previous
|
||||
|
||||
|
||||
class BoundedQueue(Queue):
|
||||
"""Пик фиксируется под тем же lock, что помещение в очередь."""
|
||||
|
||||
def __init__(self, capacity):
|
||||
if type(capacity) is not int or capacity <= 0:
|
||||
raise ValueError("Очередь должна иметь положительную целую вместимость")
|
||||
super().__init__(maxsize=capacity)
|
||||
self.peak = self.overflows = 0
|
||||
|
||||
def _put(self, item):
|
||||
super()._put(item)
|
||||
self.peak = max(self.peak, self._qsize())
|
||||
|
||||
def offer(self, item):
|
||||
try:
|
||||
self.put_nowait(item)
|
||||
except Full:
|
||||
self.overflows += 1
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_pipeline(*, workers=1, paced=True, source=SOURCE, frame_count=30,
|
||||
iq_capacity=8, candidate_capacity=4, output_capacity=16,
|
||||
processing_delay=0.0, collect_packets=True, numpy_viterbi=False) -> dict:
|
||||
"""Эталон доступен только оценке после сборки/decode; RX получает только IQ."""
|
||||
if type(frame_count) is not int or frame_count <= 0:
|
||||
raise ValueError("Число входных изображений должно быть положительным целым")
|
||||
if not math.isfinite(processing_delay) or processing_delay < 0:
|
||||
raise ValueError("Задержка должна быть конечной и неотрицательной")
|
||||
try:
|
||||
source_bytes = Path(source).stat().st_size
|
||||
except OSError:
|
||||
source_bytes = None # produce запишет точное исключение открытия.
|
||||
sequence = study.sequence_fixture()
|
||||
encoded = {number + 1: sequence[number % len(sequence)].jpeg for number in range(frame_count)}
|
||||
capture_times = {number + 1: number / 5 for number in range(frame_count)}
|
||||
processor = PacketProcessor(viterbi=previous.fast_viterbi) if numpy_viterbi else None
|
||||
receiver = StreamingReceiver(workers=workers, candidate_capacity=candidate_capacity, processor=processor)
|
||||
startup = receiver.processor.startup
|
||||
if numpy_viterbi:
|
||||
startup = {**startup, "backend": "NUMPY_LAB051"}
|
||||
iq_queue, output_queue = BoundedQueue(iq_capacity), BoundedQueue(output_capacity)
|
||||
sink = ImageSink()
|
||||
source_done, receiver_done, stop = Event(), Event(), Event()
|
||||
completed, block_rows, packet_rows, fragment_rows, rss_rows, errors = [], [], [], [], [], []
|
||||
input_statistics = {"read_seconds": 0.0, "read_blocks": 0, "read_samples": 0,
|
||||
"maximum_release_lateness_seconds": 0.0, "backpressure_seconds": 0.0}
|
||||
output_statistics = {"freshness_seconds": 0.0, "service_seconds": 0.0}
|
||||
receiver_service = receiver_wait = 0.0
|
||||
process_started = time.process_time()
|
||||
epoch = time.perf_counter()
|
||||
|
||||
def fail(reason, error=None):
|
||||
errors.append({"reason": reason, "error": None if error is None else repr(error)})
|
||||
stop.set()
|
||||
|
||||
def produce():
|
||||
try:
|
||||
with Path(source).open("rb") as capture:
|
||||
while not stop.is_set():
|
||||
started = time.perf_counter()
|
||||
raw = capture.read(receiver.block_samples * 8)
|
||||
if len(raw) % 8:
|
||||
raise ValueError("Усечённый complex64 в источнике")
|
||||
samples = np.frombuffer(raw, dtype=np.complex64)
|
||||
input_statistics["read_seconds"] += time.perf_counter() - started
|
||||
if not samples.size:
|
||||
if not input_statistics["read_samples"]:
|
||||
fail("EMPTY_IQ_SOURCE")
|
||||
break
|
||||
input_statistics["read_samples"] += samples.size
|
||||
input_statistics["read_blocks"] += 1
|
||||
arrival = input_statistics["read_samples"] / 8000000
|
||||
if paced:
|
||||
stop.wait(max(0.0, epoch + arrival - time.perf_counter()))
|
||||
if stop.is_set():
|
||||
break
|
||||
lateness = max(0.0, time.perf_counter() - epoch - arrival)
|
||||
input_statistics["maximum_release_lateness_seconds"] = max(
|
||||
lateness, input_statistics["maximum_release_lateness_seconds"])
|
||||
if not iq_queue.offer((samples, arrival)):
|
||||
fail("IQ_QUEUE_OVERFLOW")
|
||||
break
|
||||
else:
|
||||
started = time.perf_counter()
|
||||
while not stop.is_set():
|
||||
try:
|
||||
iq_queue.put((samples, arrival), timeout=.02)
|
||||
break
|
||||
except Full:
|
||||
continue
|
||||
input_statistics["backpressure_seconds"] += time.perf_counter() - started
|
||||
except BaseException as error:
|
||||
fail("SOURCE_EXCEPTION", error)
|
||||
finally:
|
||||
source_done.set()
|
||||
|
||||
def consume_output():
|
||||
try:
|
||||
while not receiver_done.is_set() or not output_queue.empty():
|
||||
try:
|
||||
packet, receiver_timestamp = output_queue.get(timeout=.02)
|
||||
except Empty:
|
||||
continue
|
||||
started = time.perf_counter()
|
||||
assembly_before = sink.assembly_seconds
|
||||
item = sink.offer(packet, time.perf_counter() - epoch)
|
||||
fragment_rows.append({"start_sample": packet.start_sample,
|
||||
"seconds": sink.assembly_seconds - assembly_before})
|
||||
output_statistics["service_seconds"] += time.perf_counter() - started
|
||||
if item is not None:
|
||||
ready = time.perf_counter() - epoch
|
||||
jpeg = item.pop("jpeg")
|
||||
number = item["image_id"]
|
||||
# Эти сведения не передаются назад в детектор/PHY/assembler.
|
||||
started = time.perf_counter()
|
||||
capture_time = capture_times.get(number)
|
||||
age = ready - capture_time if capture_time is not None else None
|
||||
fresh = paced and age is not None and 0 <= age <= .2
|
||||
output_statistics["freshness_seconds"] += time.perf_counter() - started
|
||||
completed.append({**item, "decode_complete_timestamp": ready,
|
||||
"receiver_packet_timestamp": receiver_timestamp,
|
||||
"capture_timestamp": capture_time, "image_age_seconds": age if paced else None,
|
||||
"fresh": fresh, "sha256": hashlib.sha256(jpeg).hexdigest(),
|
||||
"sha_match": number in encoded and jpeg == encoded[number]})
|
||||
except BaseException as error:
|
||||
fail("OUTPUT_EXCEPTION", error)
|
||||
|
||||
producer = Thread(target=produce, name="lab053_iq_source")
|
||||
consumer = Thread(target=consume_output, name="lab053_jpeg_output")
|
||||
producer.start()
|
||||
consumer.start()
|
||||
|
||||
def emit(packets):
|
||||
for packet in packets:
|
||||
if not output_queue.offer((packet, time.perf_counter() - epoch)):
|
||||
fail("OUTPUT_QUEUE_OVERFLOW")
|
||||
return
|
||||
|
||||
try:
|
||||
while not stop.is_set() and (not source_done.is_set() or not iq_queue.empty()):
|
||||
waiting = time.perf_counter()
|
||||
try:
|
||||
samples, arrival = iq_queue.get(timeout=.02)
|
||||
except Empty:
|
||||
receiver_wait += time.perf_counter() - waiting
|
||||
continue
|
||||
receiver_wait += time.perf_counter() - waiting
|
||||
started = time.perf_counter()
|
||||
block_start = started - epoch
|
||||
if processing_delay:
|
||||
time.sleep(processing_delay)
|
||||
packets = receiver.push_iq_block(samples)
|
||||
cost = time.perf_counter() - started
|
||||
receiver_service += cost
|
||||
emit(packets)
|
||||
if collect_packets:
|
||||
packet_rows.extend(receiver.last_packet_rows)
|
||||
block_rows.append({"samples_received": receiver.total_samples, "arrival_time": arrival,
|
||||
"worker_start": block_start, "worker_end": time.perf_counter() - epoch,
|
||||
"processing_seconds": cost, "iq_queue_size": iq_queue.qsize(),
|
||||
"candidate_queue_peak": receiver.candidate_queue_peak,
|
||||
"lag_seconds": time.perf_counter() - epoch - arrival if paced else None,
|
||||
"packets": len(packets)})
|
||||
if len(block_rows) % 128 == 0:
|
||||
rss_rows.append({"iq_seconds": receiver.total_samples / 8000000,
|
||||
"iq_queue": iq_queue.qsize(), "candidate_jobs": receiver.pending_jobs,
|
||||
"retained_iq_bytes": receiver.buffer.nbytes, **process_memory()})
|
||||
if not stop.is_set():
|
||||
started = time.perf_counter()
|
||||
emit(receiver.finish())
|
||||
receiver_service += time.perf_counter() - started
|
||||
if collect_packets:
|
||||
packet_rows.extend(receiver.last_packet_rows)
|
||||
except BaseException as error:
|
||||
fail("RECEIVER_EXCEPTION", error)
|
||||
finally:
|
||||
stop.set()
|
||||
producer.join()
|
||||
receiver.close()
|
||||
receiver_done.set()
|
||||
consumer.join()
|
||||
for queue in (iq_queue, output_queue):
|
||||
while True:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
wall = time.perf_counter() - epoch
|
||||
process_cpu = time.process_time() - process_started
|
||||
iq_seconds = source_bytes / 8 / 8000000 if source_bytes is not None else None
|
||||
horizon = frame_count / 5
|
||||
ages = [row["image_age_seconds"] for row in completed if row["image_age_seconds"] is not None]
|
||||
fresh = [row for row in completed if row["fresh"] and row["decode_complete_timestamp"] <= horizon]
|
||||
valid = [row for row in packet_rows if row["accepted"]]
|
||||
# В paced-опыте wall включает ожидание прихода IQ и не является service time.
|
||||
service_sum = receiver_service + input_statistics["read_seconds"] + output_statistics["service_seconds"] + output_statistics["freshness_seconds"]
|
||||
result = {"mode": "PACED_WALL_CLOCK" if paced else "SATURATED_BOUNDED_REPLAY",
|
||||
"source": str(source), "workers": workers, "startup": startup,
|
||||
"input_fps": 5, "input_frames": frame_count, "observation_seconds": horizon,
|
||||
"iq_seconds": iq_seconds, "consumed_iq_seconds": receiver.total_samples / 8000000,
|
||||
"source_consumed_completely": source_bytes is not None and receiver.total_samples * 8 == source_bytes and not errors,
|
||||
"full_wall_seconds": wall, "process_cpu_seconds": process_cpu,
|
||||
"processing_seconds": service_sum if paced else wall,
|
||||
"processing_per_rf_second": (service_sum if paced else wall) / iq_seconds if iq_seconds else None,
|
||||
"service_sum_seconds": service_sum, "receiver_service_seconds": receiver_service,
|
||||
"receiver_wait_seconds": receiver_wait, "input_statistics": input_statistics,
|
||||
"output_statistics": output_statistics, "assembly_seconds": sink.assembly_seconds,
|
||||
"jpeg_decode_seconds": sink.jpeg_decode_seconds,
|
||||
"coarse_seconds": receiver.coarse_seconds, "cheap_seconds": receiver.cheap_seconds,
|
||||
"shared_front_end_seconds": receiver.decimator.seconds,
|
||||
"coarse_detection_exclusive_seconds": receiver.coarse_seconds - receiver.decimator.seconds,
|
||||
"iq_input_preparation_seconds": receiver.input_preparation_seconds,
|
||||
"cluster_seconds": receiver.cluster_seconds, "true_strict_seconds": receiver.true_strict_seconds,
|
||||
"false_strict_seconds": receiver.false_strict_seconds,
|
||||
"rx_physical_packets": receiver.packet_count, "candidate_count": receiver.candidate_count,
|
||||
"received_images": len(completed), "object_jpeg_integrity": len(completed) == frame_count and all(row["sha_match"] for row in completed),
|
||||
"fresh_displayed": len(fresh) if paced else None, "displayed": len(fresh) if paced else None,
|
||||
"fresh_display_fps": len(fresh) / horizon if paced else None,
|
||||
"image_age_min_seconds": min(ages) if ages else None,
|
||||
"image_age_median_seconds": float(np.median(ages)) if ages else None,
|
||||
"image_age_max_seconds": max(ages) if ages else None, "max_video_age_seconds": .2,
|
||||
"iq_queue_capacity": iq_capacity, "iq_queue_peak": iq_queue.peak, "iq_overflow": iq_queue.overflows,
|
||||
"candidate_queue_capacity": candidate_capacity, "candidate_queue_peak": receiver.candidate_queue_peak,
|
||||
"candidate_overflow": receiver.candidate_overflow,
|
||||
"output_queue_capacity": output_capacity, "output_queue_peak": output_queue.peak,
|
||||
"output_overflow": output_queue.overflows,
|
||||
"speculative_checks": receiver.speculative_checks,
|
||||
"discarded_speculative_checks": receiver.discarded_speculative_checks,
|
||||
"discarded_speculative_seconds": receiver.discarded_speculative_seconds,
|
||||
"retained_iq_peak_bytes": receiver.peak_buffer_samples * 8,
|
||||
"allocated_iq_ring_bytes": 2 * receiver.ring_capacity * 8,
|
||||
"iq_queue_limit_bytes": iq_capacity * receiver.block_samples * 8,
|
||||
"working_arrays_engineering_estimate_bytes": 2 * receiver.ring_capacity * 8 + (iq_capacity + 2) * receiver.block_samples * 8 + workers * 10 * receiver.lookahead * 8,
|
||||
"process_memory": process_memory(), "rss_samples": rss_rows,
|
||||
"detector": dict(receiver.detection_statistics), "rejected_reasons": dict(receiver.rejected_reasons),
|
||||
"retained_after_close_bytes": receiver.buffer.nbytes + receiver.ring.nbytes + receiver.decimator.values.nbytes,
|
||||
"pending_jobs_after_close": receiver.pending_jobs, "errors": errors,
|
||||
"valid_packet_total": distribution([row["seconds"] for row in valid]),
|
||||
"valid_packet_thread_cpu": distribution([row["thread_cpu_seconds"] for row in valid]),
|
||||
"fragment_processing": distribution([row["seconds"] for row in fragment_rows]),
|
||||
"valid_packet_stages": {name: distribution([row["exclusive_stages"].get(name, 0) for row in valid])
|
||||
for name in sorted({name for row in valid for name in row["stages"]})},
|
||||
"images": completed, "blocks": block_rows, "packet_rows": packet_rows,
|
||||
"camera_display_latency": "UNKNOWN", "independent_live_capture_timestamp": False,
|
||||
"freshness_evaluation": "post-JPEG source timestamp; does not feed RX verdict", "rf_tx": 0}
|
||||
return result
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
queue = BoundedQueue(1)
|
||||
assert queue.offer(1) and not queue.offer(2)
|
||||
assert queue.overflows == 1 and queue.peak == 1 and queue.get_nowait() == 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--workers", type=int, default=1)
|
||||
parser.add_argument("--unpaced", action="store_true")
|
||||
parser.add_argument("--name")
|
||||
parser.add_argument("--source", type=Path, default=SOURCE)
|
||||
parser.add_argument("--frames", type=int, default=30)
|
||||
parser.add_argument("--processing-delay", type=float, default=0)
|
||||
parser.add_argument("--numpy-viterbi", action="store_true")
|
||||
arguments = parser.parse_args()
|
||||
result = run_pipeline(workers=arguments.workers, paced=not arguments.unpaced,
|
||||
source=arguments.source, frame_count=arguments.frames,
|
||||
processing_delay=arguments.processing_delay, collect_packets=arguments.frames <= 30,
|
||||
numpy_viterbi=arguments.numpy_viterbi)
|
||||
name = arguments.name or f"{'throughput' if arguments.unpaced else 'paced'}_workers_{arguments.workers}.json"
|
||||
write_result(name, result)
|
||||
print(json.dumps({key: result[key] for key in ("mode", "workers", "processing_seconds", "processing_per_rf_second",
|
||||
"full_wall_seconds", "rx_physical_packets", "received_images", "object_jpeg_integrity", "fresh_display_fps",
|
||||
"image_age_min_seconds", "image_age_median_seconds", "image_age_max_seconds", "iq_queue_peak",
|
||||
"candidate_queue_peak", "iq_overflow", "candidate_overflow", "errors")}), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
333
experiments/lab053_receiver_profile.py
Normal file
333
experiments/lab053_receiver_profile.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Lab053: новый профиль Lab052 и точный файловый источник для конвейера."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
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 experiments import lab052_candidate_detection as detection
|
||||
from experiments import lab052_detector_study as study
|
||||
from experiments.lab051_receiver_profile import StageProfiler
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / "data/processed/lab053"
|
||||
SOURCE = ROOT / "data/raw/lab053/source.c64"
|
||||
|
||||
|
||||
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 distribution(values) -> dict:
|
||||
values = np.asarray(values, dtype=np.float64)
|
||||
if not values.size:
|
||||
return {"count": 0, "sum": 0.0, "mean": None, "median": None, "p95": None, "max": None}
|
||||
return {"count": len(values), "sum": float(values.sum()), "mean": float(values.mean()),
|
||||
"median": float(np.median(values)), "p95": float(np.quantile(values, .95)),
|
||||
"max": float(values.max())}
|
||||
|
||||
|
||||
class DetailedProfiler(StageProfiler):
|
||||
"""Вложенные времена не складываются повторно; только отдельный замер."""
|
||||
|
||||
@contextmanager
|
||||
def measure(self, *, optimized=True):
|
||||
with super().measure(optimized=optimized):
|
||||
patches = []
|
||||
|
||||
def wrap(function, name):
|
||||
def measured(*args, **kwargs):
|
||||
scope = self.shared if self.current is None else self.current
|
||||
stage = scope[name]
|
||||
started = time.perf_counter()
|
||||
entry = [0.0]
|
||||
self.stack.append(entry)
|
||||
try:
|
||||
return function(*args, **kwargs)
|
||||
finally:
|
||||
elapsed = time.perf_counter() - started
|
||||
self.stack.pop()
|
||||
if self.stack:
|
||||
self.stack[-1][0] += elapsed
|
||||
stage.calls += 1
|
||||
stage.seconds += elapsed
|
||||
stage.exclusive_seconds += elapsed - entry[0]
|
||||
return measured
|
||||
|
||||
targets = ((detection.CandidateFrontEnd, "coarse_scores", "coarse_detection"),
|
||||
(detection.CandidateFrontEnd, "split_scores", "cheap_lab052"),
|
||||
(detection.previous.DecimationCache, "get", "shared_decimation"),
|
||||
(detection.previous.BlockFrontEnd, "extract", "soft_extraction"))
|
||||
try:
|
||||
for owner, name, label in targets:
|
||||
original = getattr(owner, name)
|
||||
patches.append((owner, name, original))
|
||||
setattr(owner, name, wrap(original, label))
|
||||
yield self
|
||||
finally:
|
||||
for owner, name, original in reversed(patches):
|
||||
setattr(owner, name, original)
|
||||
|
||||
def detailed_summary(self):
|
||||
output = self.summary()
|
||||
for kind in ("valid", "false"):
|
||||
rows = [row for row in self.rows if row["kind"] == kind]
|
||||
names = sorted({name for row in rows for name in row["stages"]})
|
||||
output[kind]["packet_total"] = distribution([row["seconds"] for row in rows])
|
||||
output[kind]["per_packet_stages"] = {
|
||||
name: {field: distribution([row["stages"].get(name, {}).get(field, 0.0) for row in rows])
|
||||
for field in ("seconds", "exclusive_seconds")} for name in names}
|
||||
return output
|
||||
|
||||
|
||||
def baseline_profile() -> None:
|
||||
sequence = study.sequence_fixture()
|
||||
profiler = DetailedProfiler()
|
||||
started = time.perf_counter()
|
||||
with profiler.measure():
|
||||
stream, receiver = study.measured_stream(sequence, detection.StreamingReceiver)
|
||||
full_experiment_seconds = time.perf_counter() - started
|
||||
result = {"stream": stream, "profile": profiler.detailed_summary(), "packets": profiler.rows,
|
||||
"experiment_wall_seconds": full_experiment_seconds,
|
||||
"source_and_diagnostics_seconds": full_experiment_seconds - stream["processing_seconds"],
|
||||
"timing_includes_instrumentation": True, "rf_tx": 0}
|
||||
write_result("baseline_profile.json", result)
|
||||
print(json.dumps({"profile": result["profile"], "processing": stream["processing_seconds"],
|
||||
"rx_packets": receiver.packet_count}), flush=True)
|
||||
|
||||
|
||||
def prepare_source(frame_count=30) -> None:
|
||||
"""Точные блоки прежнего генератора; конечный файл принадлежит источнику.
|
||||
|
||||
RX далее читает ограниченные блоки обычным read, без mmap всего файла.
|
||||
Стоимость синтеза передаваемой формы не объявляется стоимостью live RX.
|
||||
"""
|
||||
source_path = SOURCE if frame_count == 30 else SOURCE.with_name(f"source_{frame_count}.c64")
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sequence = study.sequence_fixture()
|
||||
sequence = [replace(sequence[number % len(sequence)], image_id=number + 1) for number in range(frame_count)]
|
||||
checksum = hashlib.sha256()
|
||||
counts = []
|
||||
started = time.perf_counter()
|
||||
with source_path.open("wb") as output:
|
||||
class Recorder(detection.StreamingReceiver):
|
||||
def push_iq_block(self, samples):
|
||||
data = samples.tobytes()
|
||||
output.write(data)
|
||||
checksum.update(data)
|
||||
counts.append(samples.size)
|
||||
self.total_samples += samples.size
|
||||
return []
|
||||
|
||||
def finish(self):
|
||||
self.closed = True
|
||||
return []
|
||||
|
||||
original = study.performance.fast
|
||||
try:
|
||||
study.performance.fast = SimpleNamespace(StreamingReceiver=Recorder)
|
||||
study.performance.run_stream(sequence)
|
||||
finally:
|
||||
study.performance.fast = original
|
||||
result = {"path": str(source_path.relative_to(ROOT)), "dtype": "complex64", "blocks": len(counts),
|
||||
"input_frames": frame_count, "sample_count": sum(counts), "bytes": source_path.stat().st_size, "sha256": checksum.hexdigest(),
|
||||
"block_samples": 65536, "last_block_samples": counts[-1],
|
||||
"iq_seconds": sum(counts) / 8000000, "preparation_seconds": time.perf_counter() - started,
|
||||
"source_fixture": "data/processed/lab052/source_fixture.json", "rf_tx": 0}
|
||||
write_result("source.json" if frame_count == 30 else f"source_{frame_count}.json", result)
|
||||
print(json.dumps(result), flush=True)
|
||||
|
||||
|
||||
def optimized_stream(workers=1) -> dict:
|
||||
from experiments.lab053_streaming_receiver import StreamingReceiver
|
||||
rows = []
|
||||
result, receiver = study.measured_stream(study.sequence_fixture(), lambda: StreamingReceiver(
|
||||
workers=workers, packet_observer=lambda row, outcome: rows.append(row)))
|
||||
result.update(workers=workers, packet_rows=rows, candidate_queue_peak=receiver.candidate_queue_peak,
|
||||
candidate_overflow=receiver.candidate_overflow,
|
||||
speculative_checks=receiver.speculative_checks,
|
||||
discarded_speculative_checks=receiver.discarded_speculative_checks,
|
||||
discarded_speculative_seconds=receiver.discarded_speculative_seconds)
|
||||
valid = [row for row in rows if row["accepted"]]
|
||||
result["valid_packet_total"] = distribution([row["seconds"] for row in valid])
|
||||
result["valid_packet_stages"] = {name: distribution([row["exclusive_stages"].get(name, 0) for row in valid])
|
||||
for name in sorted({name for row in valid for name in row["stages"]})}
|
||||
write_result(f"initial_workers_{workers}.json", result)
|
||||
print(json.dumps({key: result[key] for key in ("workers", "processing_seconds", "processing_per_rf_second",
|
||||
"rx_physical_packets", "received_images", "object_jpeg_integrity", "fresh_display_fps",
|
||||
"image_age_min_seconds", "image_age_median_seconds", "image_age_max_seconds", "candidate_count",
|
||||
"valid_packet_total", "candidate_queue_peak", "discarded_speculative_checks")}), flush=True)
|
||||
return result
|
||||
|
||||
|
||||
def summarize_results() -> None:
|
||||
"""Выбрать целые медианные проходы и пересчитать критерии из их строк."""
|
||||
def read(name):
|
||||
return json.loads((OUTPUT / name).read_text(encoding="utf-8"))
|
||||
|
||||
selected = {}
|
||||
repeats = {}
|
||||
for mode in ("paced", "throughput"):
|
||||
runs = [read(f"{mode}_1_repeat_{number}.json") for number in (1, 2, 3)]
|
||||
chosen = sorted(range(3), key=lambda number: runs[number]["processing_seconds"])[1]
|
||||
result = dict(runs[chosen])
|
||||
result["selection"] = {"method": "whole median processing-time run", "repeat": chosen + 1,
|
||||
"source": f"{mode}_1_repeat_{chosen + 1}.json"}
|
||||
selected[mode], repeats[mode] = result, runs
|
||||
write_result("stream.json" if mode == "paced" else "throughput.json", result)
|
||||
write_result(f"{mode}_workers_1.json", result)
|
||||
main, throughput = selected["paced"], selected["throughput"]
|
||||
comparison = [{"workers": number,
|
||||
"throughput_processing_per_rf_second": read(f"throughput_workers_{number}.json")["processing_per_rf_second"],
|
||||
**{key: read(f"paced_workers_{number}.json")[key] for key in (
|
||||
"processing_per_rf_second", "fresh_display_fps", "image_age_max_seconds", "candidate_queue_peak",
|
||||
"iq_queue_peak", "speculative_checks", "discarded_speculative_checks")}} for number in (1, 2, 4)]
|
||||
write_result("worker_comparison.json", {"selected_workers": 1, "comparisons": comparison,
|
||||
"reason": "single worker is sufficient; additional workers give no measured throughput or latency benefit"})
|
||||
long = read("long_stream.json")
|
||||
equivalence, sensitivity = read("equivalence.json"), read("sensitivity.json")
|
||||
regression = read("regression20k/result.json")
|
||||
source = read("source.json")
|
||||
overflow_probe = read("overflow_probe.json")
|
||||
with (ROOT / source["path"]).open("rb") as source_handle:
|
||||
source_sha256 = hashlib.file_digest(source_handle, "sha256").hexdigest()
|
||||
cases = repeats["paced"] + repeats["throughput"] + [read(f"{mode}_workers_{worker}.json")
|
||||
for mode in ("paced", "throughput") for worker in (2, 4)]
|
||||
checks = {
|
||||
"all_main_runs_240_packets_30_exact_jpegs": all(row["rx_physical_packets"] == 240 and row["received_images"] == 30
|
||||
and row["object_jpeg_integrity"] for row in cases),
|
||||
"all_main_runs_255_strict_15_false": all(row["candidate_count"] == 255 for row in cases),
|
||||
"all_paced_runs_5_fresh_fps": all(row["fresh_display_fps"] == 5 for row in cases if row["mode"] == "PACED_WALL_CLOCK"),
|
||||
"all_main_age_at_most_200ms": all(row["image_age_max_seconds"] <= .2 for row in cases if row["mode"] == "PACED_WALL_CLOCK"),
|
||||
"all_main_processing_ratio_below_08": all(row["processing_per_rf_second"] <= .8 for row in cases),
|
||||
"all_main_sources_complete_and_no_overflow": all(row["source_consumed_completely"] and not row["errors"] and
|
||||
row["iq_overflow"] == row["candidate_overflow"] == row["output_overflow"] == 0 for row in cases),
|
||||
"all_main_queues_within_caps": all(row["iq_queue_peak"] <= row["iq_queue_capacity"] and
|
||||
row["candidate_queue_peak"] <= row["candidate_queue_capacity"] and row["output_queue_peak"] <= row["output_queue_capacity"] for row in cases),
|
||||
"same_240_phy_crc_timing_all_worker_counts": equivalence["passed"],
|
||||
"sensitivity_37_of_45_identical": sensitivity["same_verdicts"] and sensitivity["optimized_correct"] == 37 and sensitivity["lost_baseline_packets"] == 0,
|
||||
"long_960_packets_120_jpeg_5_fps": long["rx_physical_packets"] == 960 and long["received_images"] == 120 and
|
||||
long["object_jpeg_integrity"] and long["fresh_display_fps"] == 5 and long["image_age_max_seconds"] <= .2,
|
||||
"long_queues_within_caps_no_overflow": long["iq_queue_peak"] <= long["iq_queue_capacity"] and
|
||||
long["candidate_queue_peak"] <= long["candidate_queue_capacity"] and not long["errors"] and
|
||||
long["iq_overflow"] == long["candidate_overflow"] == long["output_overflow"] == 0,
|
||||
"long_complete_source_and_cleared_state": long["consumed_iq_seconds"] == long["iq_seconds"] and
|
||||
long["retained_after_close_bytes"] == long["pending_jobs_after_close"] == 0,
|
||||
"old20k_36_crc_sha": regression["strict"]["crc_pass"] == 36 and regression["object"]["valid_fragments"] == 36 and
|
||||
regression["after_independent_verdict"]["sha_match"],
|
||||
"source_exact_duration": source["sample_count"] == 48355328 and source["iq_seconds"] == 6.044416,
|
||||
"source_file_size": (ROOT / source["path"]).stat().st_size == source["bytes"],
|
||||
"source_file_sha256": source_sha256 == source["sha256"],
|
||||
"forced_overflow_is_reported_and_state_cleared": overflow_probe["iq_overflow"] == 1 and
|
||||
overflow_probe["iq_queue_peak"] == overflow_probe["iq_queue_capacity"] and
|
||||
not overflow_probe["source_consumed_completely"] and
|
||||
any(row["reason"] == "IQ_QUEUE_OVERFLOW" for row in overflow_probe["errors"]) and
|
||||
overflow_probe["retained_after_close_bytes"] == overflow_probe["pending_jobs_after_close"] == 0,
|
||||
"protocol_unchanged": not subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=all", "--", "protocol"], cwd=ROOT).strip(),
|
||||
"lab052_detector_unchanged": not subprocess.check_output(["git", "diff", "--", "experiments/lab052_candidate_detection.py"], cwd=ROOT).strip(),
|
||||
"lab053_index_empty": not subprocess.check_output(["git", "diff", "--cached", "--name-only"], cwd=ROOT).strip(),
|
||||
}
|
||||
fec_crc = sum(main["valid_packet_stages"][name]["sum"] for name in ("viterbi", "protocol_crc"))
|
||||
partition = {"A_iq_read_and_input_preparation": main["input_statistics"]["read_seconds"] + main["iq_input_preparation_seconds"],
|
||||
"B_common_decimation": main["shared_front_end_seconds"], "C_coarse_detection": main["coarse_detection_exclusive_seconds"],
|
||||
"D_cheap_lab052": main["cheap_seconds"], "E_true_packet_without_G": main["true_strict_seconds"] - fec_crc,
|
||||
"F_false_strict": main["false_strict_seconds"], "G_viterbi_crc": fec_crc,
|
||||
"H_fragment_assembly": main["assembly_seconds"], "I_jpeg_decode": main["jpeg_decode_seconds"],
|
||||
"J_freshness": main["output_statistics"]["freshness_seconds"]}
|
||||
partition["other_orchestration_and_instrumentation"] = main["processing_seconds"] - sum(partition.values())
|
||||
rss = long["rss_samples"]
|
||||
late = rss[len(rss)//2:]
|
||||
# К старой оценке временных массивов добавлены ЯВНЫЕ резервы постоянных кэшей.
|
||||
ring_samples = main["allocated_iq_ring_bytes"] // 16
|
||||
maximum_waveform = ring_samples - 2*65536 - 8000
|
||||
rotation_cache = 16 * maximum_waveform * 8
|
||||
detection_rotation_cache = 8 * ((ring_samples + 7)//8) * 8
|
||||
constants_reserve = 1024*1024
|
||||
memory = {"retained_iq_peak_bytes": main["retained_iq_peak_bytes"], "allocated_ring_bytes": main["allocated_iq_ring_bytes"],
|
||||
"iq_queue_peak_bytes": main["iq_queue_peak"] * 65536*8, "iq_queue_limit_bytes": main["iq_queue_limit_bytes"],
|
||||
"temporary_working_buffers_estimate_bytes": main["working_arrays_engineering_estimate_bytes"],
|
||||
"maximum_frame_waveform_samples": maximum_waveform, "rotation_cache_maxsize": 16,
|
||||
"maximum_rotation_cache_bytes": rotation_cache, "maximum_detection_rotation_cache_bytes": detection_rotation_cache,
|
||||
"constant_templates_and_trellis_reserve_bytes": constants_reserve,
|
||||
"working_arrays_and_constant_caches_estimate_bytes": main["working_arrays_engineering_estimate_bytes"] + rotation_cache + detection_rotation_cache + constants_reserve,
|
||||
"estimate_is_not_a_universal_c_allocation_bound": True,
|
||||
"process_memory": main["process_memory"], "long_process_memory": long["process_memory"],
|
||||
"long_late_rss_min_bytes": min(row["rss_bytes"] for row in late), "long_late_rss_max_bytes": max(row["rss_bytes"] for row in late),
|
||||
"long_late_rss_slope_bytes_per_second": float(np.polyfit([row["iq_seconds"] for row in late], [row["rss_bytes"] for row in late], 1)[0]),
|
||||
"receiver_state_bounded": checks["all_main_queues_within_caps"] and checks["long_queues_within_caps_no_overflow"]
|
||||
and checks["long_complete_source_and_cleared_state"], "experiment_diagnostic_history_is_external": True}
|
||||
write_result("memory.json", memory)
|
||||
write_result("acceptance.json", {"checks": checks, "required_checks_pass": all(checks.values()),
|
||||
"real_time_capable": all(checks.values()), "software_5fps_target_pass": all(checks.values()),
|
||||
"hardware_validated": False, "head": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
||||
"source_code_sha256": {str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in sorted((ROOT/"experiments").glob("lab053_*.py"))},
|
||||
"paced_service_partition_seconds": partition, "paced_service_processing_per_rf_second": main["processing_per_rf_second"],
|
||||
"saturated_wall_processing_per_rf_second": throughput["processing_per_rf_second"],
|
||||
"estimated_single_worker_service_for_40_packets_seconds": 40*main["valid_packet_total"]["mean"],
|
||||
"budget_note": "perf_counter service durations; Windows thread_time has 15.625ms accounting quantization", "rf_tx": 0})
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
figure, axes = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
|
||||
for number, row in enumerate(repeats["paced"], 1):
|
||||
axes[0, 0].plot([image["image_id"] for image in row["images"]],
|
||||
[1000*image["image_age_seconds"] for image in row["images"]], label=f"Проход {number}")
|
||||
axes[0, 0].axhline(200, color="red", linestyle="--", label="Предел 200 мс")
|
||||
axes[0, 0].set(xlabel="Номер изображения", ylabel="Возраст JPEG, мс", title="Подача IQ по часам: 5 свежих кадров/с")
|
||||
axes[0, 0].legend(fontsize=8)
|
||||
axes[0, 1].bar(["Lab052", "Lab053\nработа стадий", "Lab053\nбез ожидания"], [1.2192732766011487, main["processing_per_rf_second"], throughput["processing_per_rf_second"]])
|
||||
axes[0, 1].axhline(1, color="red", linestyle="--")
|
||||
axes[0, 1].axhline(.8, color="green", linestyle=":")
|
||||
axes[0, 1].set(ylabel="Время обработки / длительность IQ", title="Пропускная способность")
|
||||
axes[1, 0].plot([row["capture_timestamp"] for row in long["images"]], [1000*row["image_age_seconds"] for row in long["images"]])
|
||||
axes[1, 0].axhline(200, color="red", linestyle="--")
|
||||
axes[1, 0].set(xlabel="Время входной сессии, с", ylabel="Возраст JPEG, мс", title="Длинная сессия: 120 точных JPEG")
|
||||
axes[1, 1].plot([row["iq_seconds"] for row in rss], [row["rss_bytes"]/2**20 for row in rss], marker=".")
|
||||
axes[1, 1].set(xlabel="Принятая длительность IQ, с", ylabel="RSS процесса, МиБ", title="Память в длинной сессии")
|
||||
for axis in axes.flat:
|
||||
axis.grid(alpha=.2)
|
||||
figure.savefig(OUTPUT / "performance_and_age.png", dpi=150)
|
||||
figure.savefig(OUTPUT / "performance_and_age.svg")
|
||||
plt.close(figure)
|
||||
svg = OUTPUT / "performance_and_age.svg"
|
||||
svg.write_text("\n".join(line.rstrip() for line in svg.read_text(encoding="utf-8").splitlines()) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"checks": checks, "memory": memory, "partition": partition}), flush=True)
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
assert distribution([1, 2, 3])["median"] == 2
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--stage", choices=("profile", "source", "optimized", "report"), default="profile")
|
||||
parser.add_argument("--workers", type=int, default=1)
|
||||
parser.add_argument("--frames", type=int, default=30)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.stage == "optimized":
|
||||
optimized_stream(arguments.workers)
|
||||
elif arguments.stage == "source":
|
||||
prepare_source(arguments.frames)
|
||||
elif arguments.stage == "report":
|
||||
summarize_results()
|
||||
else:
|
||||
baseline_profile()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
145
experiments/lab053_regression.py
Normal file
145
experiments/lab053_regression.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Lab053: сравнение независимых решений Lab052/053 после полного приёма."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
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 import lab052_candidate_detection as baseline
|
||||
from experiments.lab053_receiver_profile import SOURCE, write_result
|
||||
from experiments.lab053_streaming_receiver import StreamingReceiver
|
||||
|
||||
|
||||
class VerdictObserver:
|
||||
def __init__(self):
|
||||
self.decisions = []
|
||||
self.frames = {}
|
||||
|
||||
def scores(self, *args):
|
||||
pass
|
||||
|
||||
def outcome(self, base, candidate, outcome):
|
||||
self.decisions.append((base + candidate.sample_offset, outcome.reason,
|
||||
base + outcome.refined_start_sample, outcome.accepted))
|
||||
if outcome.accepted:
|
||||
frame = outcome.frame
|
||||
self.frames[base + outcome.refined_start_sample] = {
|
||||
"end": base + outcome.next_sample, "timing": frame.timing_offset_samples,
|
||||
"cfo_hz": frame.estimated_cfo_hz, "phase_rad": frame.estimated_phase_rad,
|
||||
"polarity": frame.bpsk_polarity, "crc": outcome.crc,
|
||||
"packet_bytes": frame.reception.packet_bytes,
|
||||
"soft": frame.post_preamble_soft.copy()}
|
||||
|
||||
|
||||
def read_all(receiver, source=SOURCE):
|
||||
packets = []
|
||||
try:
|
||||
with source.open("rb") as capture:
|
||||
while raw := capture.read(receiver.block_samples * 8):
|
||||
packets.extend(receiver.push_iq_block(np.frombuffer(raw, np.complex64)))
|
||||
packets.extend(receiver.finish())
|
||||
return packets
|
||||
finally:
|
||||
if hasattr(receiver, "close"):
|
||||
receiver.close()
|
||||
|
||||
|
||||
def equivalence():
|
||||
old = VerdictObserver()
|
||||
read_all(baseline.StreamingReceiver(observer=old))
|
||||
results = []
|
||||
for workers in (1, 2, 4):
|
||||
new = VerdictObserver()
|
||||
receiver = StreamingReceiver(workers=workers, observer=new)
|
||||
read_all(receiver)
|
||||
same_starts = set(old.frames) == set(new.frames)
|
||||
rows = []
|
||||
for start in sorted(set(old.frames) & set(new.frames)):
|
||||
first, second = old.frames[start], new.frames[start]
|
||||
rows.append({"start_sample": start,
|
||||
**{f"same_{key}": first[key] == second[key] for key in ("end", "timing", "polarity", "crc", "packet_bytes")},
|
||||
"cfo_difference_hz": abs(first["cfo_hz"] - second["cfo_hz"]),
|
||||
"phase_difference_rad": abs(first["phase_rad"] - second["phase_rad"]),
|
||||
"maximum_soft_difference": float(np.max(np.abs(first["soft"] - second["soft"]))),
|
||||
"packet_sha256": hashlib.sha256(second["packet_bytes"]).hexdigest()})
|
||||
checks = {"same_240_starts": same_starts and len(rows) == 240,
|
||||
"same_candidate_policy_sequence": old.decisions == new.decisions,
|
||||
"same_phy_timing_crc_packet_bits": all(all(row[f"same_{key}"] for key in
|
||||
("end", "timing", "polarity", "crc", "packet_bytes")) for row in rows),
|
||||
"cfo_within_002_hz": all(row["cfo_difference_hz"] < .002 for row in rows),
|
||||
"phase_within_2e5_rad": all(row["phase_difference_rad"] < 2e-5 for row in rows),
|
||||
"soft_within_1e4": all(row["maximum_soft_difference"] < 1e-4 for row in rows)}
|
||||
result = {"workers": workers, "checks": checks, "passed": all(checks.values()),
|
||||
"strict_candidates": receiver.candidate_count, "accepted": receiver.packet_count,
|
||||
"maximum_cfo_difference_hz": max(row["cfo_difference_hz"] for row in rows),
|
||||
"maximum_phase_difference_rad": max(row["phase_difference_rad"] for row in rows),
|
||||
"maximum_soft_difference": max(row["maximum_soft_difference"] for row in rows), "packets": rows}
|
||||
results.append(result)
|
||||
print(json.dumps({key: value for key, value in result.items() if key != "packets"}), flush=True)
|
||||
write_result("equivalence.json", {"baseline": "committed Lab052", "workers": results,
|
||||
"passed": all(row["passed"] for row in results), "oracle_used_in_receiver": False, "rf_tx": 0})
|
||||
|
||||
|
||||
def sensitivity():
|
||||
physical = baseline.phy.lab045
|
||||
payload = bytes(range(256))
|
||||
plan = physical.build_transmission_plan(payload)
|
||||
raw = baseline.phy.build_parameterized_tx_cs8(plan, baseline.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)))
|
||||
verdicts = []
|
||||
for constructor in (baseline.StreamingReceiver, StreamingReceiver):
|
||||
receiver = constructor()
|
||||
packets = []
|
||||
try:
|
||||
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())
|
||||
finally:
|
||||
if hasattr(receiver, "close"):
|
||||
receiver.close()
|
||||
verdicts.append(len(packets) == 1 and packets[0].payload == payload)
|
||||
rows.append({"ebn0_db": ebn0, "cfo_hz": cfo, "seed": seed, "scale": scale,
|
||||
"baseline_correct": verdicts[0], "optimized_correct": verdicts[1]})
|
||||
result = {"count": len(rows), "baseline_correct": sum(row["baseline_correct"] for row in rows),
|
||||
"optimized_correct": sum(row["optimized_correct"] for row in rows),
|
||||
"lost_baseline_packets": sum(row["baseline_correct"] and not row["optimized_correct"] for row in rows),
|
||||
"same_verdicts": all(row["baseline_correct"] == row["optimized_correct"] for row in rows),
|
||||
"cases": 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 run_functional_tests() -> None:
|
||||
assert VerdictObserver().decisions == []
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--stage", choices=("equivalence", "sensitivity"), default="equivalence")
|
||||
arguments = parser.parse_args()
|
||||
{"equivalence": equivalence, "sensitivity": sensitivity}[arguments.stage]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
287
experiments/lab053_streaming_receiver.py
Normal file
287
experiments/lab053_streaming_receiver.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Lab053: прежняя политика Lab052 с независимыми строгими вычислениями."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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 experiments import lab052_candidate_detection as detection
|
||||
from experiments.lab053_packet_processing import PacketProcessor
|
||||
|
||||
|
||||
class CandidateQueueOverflow(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DetectionWindow(detection.CandidateFrontEnd):
|
||||
"""Те же coarse/split функции Lab052; строгие FFT здесь не создаются."""
|
||||
|
||||
def __init__(self, samples, owned_end, decimator, base):
|
||||
self.samples, self.owned_end = samples, owned_end
|
||||
self.decimator, self.base = decimator, base
|
||||
|
||||
|
||||
class MeasuredDecimation(detection.previous.DecimationCache):
|
||||
def __init__(self, decimation):
|
||||
super().__init__(decimation)
|
||||
self.seconds = 0.0
|
||||
|
||||
def get(self, samples, base):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
return super().get(samples, base)
|
||||
finally:
|
||||
self.seconds += time.perf_counter() - started
|
||||
|
||||
|
||||
class StreamingReceiver(detection.StreamingReceiver):
|
||||
"""Порядок принятия прежний; опережающие результаты применяются по очереди.
|
||||
|
||||
Только представители групп могут вычисляться одновременно. Альтернативы
|
||||
остаются после FAIL, пропуск внутри кадра разрешает только фактический CRC.
|
||||
Неиспользованные опережающие проверки отдельно учтены, их verdict игнорируется.
|
||||
"""
|
||||
|
||||
def __init__(self, *, workers=1, candidate_capacity=4, observer=None, packet_observer=None,
|
||||
processor=None, block_samples=65536):
|
||||
if type(workers) is not int or workers not in (1, 2, 4):
|
||||
raise ValueError("Допустимы 1, 2 или 4 работника")
|
||||
if type(candidate_capacity) is not int or candidate_capacity < workers:
|
||||
raise ValueError("Вместимость кандидатов должна быть не меньше числа работников")
|
||||
super().__init__(block_samples=block_samples, observer=observer)
|
||||
self.processor = processor or PacketProcessor()
|
||||
self.packet_observer = packet_observer
|
||||
self.workers, self.candidate_capacity = workers, candidate_capacity
|
||||
self.executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="lab053_strict") if workers > 1 else None
|
||||
self.candidate_queue_peak = self.candidate_overflow = self.pending_jobs = 0
|
||||
self.speculative_checks = self.discarded_speculative_checks = 0
|
||||
self.speculative_seconds = self.discarded_speculative_seconds = 0.0
|
||||
self.last_packet_rows = []
|
||||
self.ring_capacity = self.buffer_limit_samples
|
||||
self.ring = np.empty(2 * self.ring_capacity, np.complex64)
|
||||
self.decimator = MeasuredDecimation(self.decimation)
|
||||
self.input_preparation_seconds = 0.0
|
||||
|
||||
def submit(self, candidate):
|
||||
if self.closed:
|
||||
raise RuntimeError("Поток уже закрыт")
|
||||
if self.pending_jobs >= self.candidate_capacity:
|
||||
self.candidate_overflow += 1
|
||||
raise CandidateQueueOverflow("Очередь строгих кандидатов заполнена")
|
||||
self.pending_jobs += 1
|
||||
self.candidate_queue_peak = max(self.candidate_queue_peak, self.pending_jobs)
|
||||
try:
|
||||
return self.executor.submit(self.processor.decode, self.buffer, candidate)
|
||||
except BaseException:
|
||||
self.pending_jobs -= 1
|
||||
raise
|
||||
|
||||
def _scan(self, owned_end):
|
||||
self.buffer.flags.writeable = False
|
||||
front = DetectionWindow(self.buffer, owned_end - self.base_sample, self.decimator, self.base_sample)
|
||||
started = time.perf_counter()
|
||||
scores = front.coarse_scores(self.buffer, self.reference)
|
||||
self.coarse_seconds += time.perf_counter() - started
|
||||
peaks = detection.offline._all_local_peak_indexes(scores)
|
||||
offsets = self.base_sample + peaks * self.decimation
|
||||
owned_peaks = peaks[(offsets >= self.scan_sample) & (offsets < owned_end)]
|
||||
threshold = np.float32(detection.phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD)
|
||||
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 len(scores) else np.empty((0, 2))
|
||||
passed = np.all(parts[first_stage] >= detection.part_threshold(self.rule.parts), axis=1)
|
||||
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(detection.peak_groups(indexes, self.rule.cluster_width_samples // self.decimation))
|
||||
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)
|
||||
ordered = [group[np.argsort(scores[group], kind="stable")[::-1]] for group in groups]
|
||||
futures = {}
|
||||
emitted = []
|
||||
|
||||
def candidate_at(index):
|
||||
return detection.offline.CoarseCandidate(int(index) * self.decimation, float(scores[index]))
|
||||
|
||||
try:
|
||||
if self.executor:
|
||||
for group in ordered:
|
||||
if len(futures) == self.candidate_capacity:
|
||||
break
|
||||
index = int(group[0])
|
||||
if self.base_sample + index * self.decimation >= self.accepted_until - self.decimation:
|
||||
futures[index] = self.submit(candidate_at(index))
|
||||
self.speculative_checks += 1
|
||||
attempted_groups = set()
|
||||
for group_number, index in ((number, int(item)) for number, group in enumerate(ordered) for item in group):
|
||||
absolute = self.base_sample + 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 = candidate_at(index)
|
||||
if index in futures:
|
||||
future = futures.pop(index)
|
||||
try:
|
||||
outcome, row = future.result()
|
||||
finally:
|
||||
self.pending_jobs -= 1
|
||||
self.speculative_seconds += row["seconds"]
|
||||
else:
|
||||
if self.pending_jobs >= self.candidate_capacity:
|
||||
# Освободить один уже вычисленный, но пропущенный результат.
|
||||
skipped = next((key for key in futures
|
||||
if self.base_sample + key*self.decimation < self.accepted_until-self.decimation), None)
|
||||
if skipped is None:
|
||||
self.candidate_overflow += 1
|
||||
raise CandidateQueueOverflow("Нет места для альтернативного кандидата")
|
||||
unused, discarded = futures.pop(skipped).result()
|
||||
self.pending_jobs -= 1
|
||||
self.discarded_speculative_checks += 1
|
||||
self.discarded_speculative_seconds += discarded["seconds"]
|
||||
self.candidate_queue_peak = max(self.candidate_queue_peak, self.pending_jobs + 1)
|
||||
if self.executor:
|
||||
future = self.submit(candidate)
|
||||
try:
|
||||
outcome, row = future.result()
|
||||
finally:
|
||||
self.pending_jobs -= 1
|
||||
else:
|
||||
outcome, row = self.processor.decode(self.buffer, candidate, self.candidate_count)
|
||||
self.strict_seconds += row["seconds"]
|
||||
row.update(base_sample=self.base_sample, absolute_refined=self.base_sample + outcome.refined_start_sample)
|
||||
self.last_packet_rows.append(row)
|
||||
if self.packet_observer is not None:
|
||||
self.packet_observer(row, outcome)
|
||||
if self.observer is not None:
|
||||
self.observer.outcome(self.base_sample, candidate, outcome)
|
||||
if outcome.accepted and outcome.crc == detection.phy.lab045.CRC_PASS and outcome.frame is not None:
|
||||
self.true_strict_seconds += row["seconds"]
|
||||
self.accepted_until = self.base_sample + outcome.next_sample
|
||||
self.packet_count += 1
|
||||
emitted.append(detection.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 += row["seconds"]
|
||||
self.rejected_reasons[outcome.reason] += 1
|
||||
finally:
|
||||
active_error = sys.exc_info()[0] is not None
|
||||
cleanup_error = None
|
||||
try:
|
||||
for future in futures.values():
|
||||
try:
|
||||
unused, row = future.result()
|
||||
self.discarded_speculative_checks += 1
|
||||
self.discarded_speculative_seconds += row["seconds"]
|
||||
except BaseException as error:
|
||||
if cleanup_error is None:
|
||||
cleanup_error = error
|
||||
finally:
|
||||
self.pending_jobs = self.candidate_state_count = 0
|
||||
if active_error or cleanup_error is not None:
|
||||
self.close()
|
||||
if not active_error and cleanup_error is not None:
|
||||
raise cleanup_error
|
||||
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.base_sample = retain_start
|
||||
start = self.base_sample % self.ring_capacity
|
||||
self.buffer = self.ring[start:start + self.total_samples - self.base_sample]
|
||||
return emitted
|
||||
|
||||
def push_iq_block(self, samples):
|
||||
preparation_started = time.perf_counter()
|
||||
if self.closed:
|
||||
raise RuntimeError("Поток уже закрыт")
|
||||
samples = np.asarray(samples, dtype=np.complex64)
|
||||
if samples.ndim != 1 or samples.size > self.block_samples or not np.all(np.isfinite(samples)):
|
||||
raise ValueError("Нужен конечный одномерный IQ-блок не больше block_samples")
|
||||
self.last_packet_rows = []
|
||||
started = time.perf_counter()
|
||||
if self.total_samples + samples.size - self.base_sample > self.buffer_limit_samples:
|
||||
raise RuntimeError("Превышен предел буфера IQ")
|
||||
# Две копии только НОВОГО блока дают непрерывный view через стык кольца.
|
||||
# Старое окно не переносится. Все строгие jobs предыдущего push завершены.
|
||||
position = self.total_samples % self.ring_capacity
|
||||
first = min(samples.size, self.ring_capacity - position)
|
||||
self.ring[position:position + first] = samples[:first]
|
||||
self.ring[position + self.ring_capacity:position + self.ring_capacity + first] = samples[:first]
|
||||
remaining = samples.size - first
|
||||
if remaining:
|
||||
self.ring[:remaining] = samples[first:]
|
||||
self.ring[self.ring_capacity:self.ring_capacity + remaining] = samples[first:]
|
||||
self.total_samples += samples.size
|
||||
start = self.base_sample % self.ring_capacity
|
||||
self.buffer = self.ring[start:start + self.total_samples - self.base_sample]
|
||||
self.peak_buffer_samples = max(self.peak_buffer_samples, self.buffer.size)
|
||||
if self.buffer.size > self.buffer_limit_samples:
|
||||
raise RuntimeError("Превышен предел буфера IQ")
|
||||
emitted = []
|
||||
self.input_preparation_seconds += time.perf_counter() - preparation_started
|
||||
while self.total_samples >= self.scan_sample + self.core_samples + self.lookahead:
|
||||
emitted.extend(self._scan(self.scan_sample + self.core_samples))
|
||||
self.processing_seconds += time.perf_counter() - started
|
||||
return emitted
|
||||
|
||||
def finish(self):
|
||||
if self.closed:
|
||||
raise RuntimeError("Поток уже закрыт")
|
||||
self.last_packet_rows = []
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
return self._scan(self.total_samples) if self.scan_sample < self.total_samples else []
|
||||
finally:
|
||||
self.close()
|
||||
self.processing_seconds += time.perf_counter() - started
|
||||
|
||||
def close(self):
|
||||
"""Дождаться всех выполняющихся jobs до освобождения IQ и счётчиков."""
|
||||
if self.executor:
|
||||
self.executor.shutdown(wait=True, cancel_futures=True)
|
||||
self.pending_jobs = self.candidate_state_count = 0
|
||||
self.buffer = np.empty(0, np.complex64)
|
||||
self.ring = np.empty(0, np.complex64)
|
||||
self.decimator.values = np.empty(0, np.complex64)
|
||||
self.closed = True
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
assert StreamingReceiver().finish() == []
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run_functional_tests()
|
||||
print("Lab053 receiver functional checks PASS; RF TX=0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
experiments/lab053_viterbi.py
Normal file
98
experiments/lab053_viterbi.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Lab053: те же переходы soft Viterbi, циклы компилируются только для CPU."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from experiments import lab051_streaming_receiver as previous
|
||||
|
||||
runtime = Path(__file__).resolve().parents[1] / ".lab053_cpu_runtime/packages"
|
||||
if runtime.is_dir():
|
||||
sys.path.insert(0, str(runtime))
|
||||
try:
|
||||
from numba import njit
|
||||
except ImportError:
|
||||
njit = None
|
||||
|
||||
conv = previous.lab045.conv
|
||||
symbols = conv._BRANCH_SYMBOLS
|
||||
CODES = (2 * (symbols[:, :, 0] > 0) + (symbols[:, :, 1] > 0)).astype(np.intp)
|
||||
PREDECESSORS = conv._PREDECESSORS.copy()
|
||||
CODES.flags.writeable = PREDECESSORS.flags.writeable = False
|
||||
|
||||
|
||||
def trellis_decode(metrics, predecessors, codes):
|
||||
"""Порядок сложений исходный; строгое < сохраняет tie в ветвь 0.
|
||||
|
||||
Не объединяем несколько шагов: это могло бы изменить решение при
|
||||
равных округлённых суммах. Старт/финиш 0, 64 состояния, хвост 6 бит.
|
||||
"""
|
||||
step_count = metrics.shape[0]
|
||||
path = np.full(64, 1e18, dtype=np.float64)
|
||||
path[0] = 0.0
|
||||
next_path = np.empty(64, np.float64)
|
||||
choices = np.empty((step_count, 64), np.uint8)
|
||||
for step in range(step_count):
|
||||
for state in range(64):
|
||||
zero = path[predecessors[state, 0]] + metrics[step, codes[state, 0]]
|
||||
one = path[predecessors[state, 1]] + metrics[step, codes[state, 1]]
|
||||
take_one = one < zero
|
||||
choices[step, state] = take_one
|
||||
next_path[state] = one if take_one else zero
|
||||
path, next_path = next_path, path
|
||||
decoded = np.empty(step_count, np.uint8)
|
||||
state = 0
|
||||
for step in range(step_count - 1, -1, -1):
|
||||
decoded[step] = state >> 5
|
||||
state = predecessors[state, choices[step, state]]
|
||||
assert state == 0
|
||||
return decoded[:-6]
|
||||
|
||||
|
||||
compiled_trellis = njit(cache=True, nogil=True, fastmath=False)(trellis_decode) if njit is not None else None
|
||||
|
||||
|
||||
def viterbi_decode_soft(soft_values, information_bit_count):
|
||||
if compiled_trellis is None:
|
||||
return previous.fast_viterbi(soft_values, information_bit_count)
|
||||
if not isinstance(information_bit_count, (int, np.integer)) or information_bit_count <= 0:
|
||||
return previous.ORIGINAL_VITERBI(soft_values, information_bit_count)
|
||||
values = np.asarray(soft_values, dtype=np.float64)
|
||||
if (values.ndim != 1 or values.size != conv.encoded_bit_count(information_bit_count)
|
||||
or not np.all(np.isfinite(values))):
|
||||
return previous.ORIGINAL_VITERBI(soft_values, information_bit_count)
|
||||
received = values.reshape(int(information_bit_count) + 6, 2)
|
||||
negative = (received + 1.0) ** 2
|
||||
positive = (received - 1.0) ** 2
|
||||
metrics = np.column_stack((negative[:, 0] + negative[:, 1], negative[:, 0] + positive[:, 1],
|
||||
positive[:, 0] + negative[:, 1], positive[:, 0] + positive[:, 1]))
|
||||
return compiled_trellis(metrics, PREDECESSORS, CODES)
|
||||
|
||||
|
||||
def warmup() -> dict:
|
||||
started = time.perf_counter()
|
||||
values = np.ones(conv.encoded_bit_count(8), np.float64)
|
||||
assert np.array_equal(viterbi_decode_soft(values, 8), previous.fast_viterbi(values, 8))
|
||||
return {"backend": "NUMBA_CPU" if compiled_trellis is not None else "NUMPY_LAB051",
|
||||
"warmup_seconds": time.perf_counter() - started, "fastmath": False, "gpu_used": False}
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
values = np.zeros(conv.encoded_bit_count(8), np.float64)
|
||||
assert np.array_equal(viterbi_decode_soft(values, 8), previous.fast_viterbi(values, 8))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(warmup())
|
||||
run_functional_tests()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user