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