"""Lab046C: автономное восстановление объекта из сохранённого слабого IQ. Модуль не содержит аппаратных команд. Первый каскад калибруется только по заведомо фоновому участку RX, второй каскад принимает кадр исключительно после PHY header, soft Viterbi, protocol parser и CRC PASS. TX reference доступен только отдельной функции диагностики после завершённого независимого verdict. """ from __future__ import annotations import argparse from dataclasses import asdict, dataclass, replace import hashlib import json import math from pathlib import Path import sys import time from typing import Callable import numpy as np if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from experiments import lab046_bpsk_object_transport as lab046 from experiments import lab046b_continuous_iq as lab046b from experiments import lab046c_hackrf_bpsk_object as lab046c DEFAULT_CAPTURE_PATH = Path( r"C:\Users\user\Documents\SDR_Rover_Lab046_raw" r"\20260901_145339_bpsk_object\rx_stream.cs8" ) DEFAULT_REFERENCE_DIRECTORY = DEFAULT_CAPTURE_PATH.parent DEFAULT_OUTPUT_DIRECTORY = Path("data/processed/lab046c_offline_recovery") CAPTURE_1400MHZ_PATH = Path( r"C:\Users\user\Documents\SDR_Rover_RF_Check" r"\20260901_172749_1400MHz\rx_1400mhz.cs8" ) REFERENCE_1400MHZ_DIRECTORY = CAPTURE_1400MHZ_PATH.parent OUTPUT_1400MHZ_DIRECTORY = Path("data/processed/lab046c_1400mhz_offline") # По плану аппаратного опыта RX стартовал за 0.75 s до TX, а после TX оставалось # около 3.65 s. Более узкие интервалы оставляют защитный запас с обеих сторон. CALIBRATION_START_SAMPLE = 0 CALIBRATION_END_SAMPLE = 4_000_000 # 0.0 .. 0.5 s CONTROL_START_SAMPLE = 168_000_000 # 21.0 .. 24.0 s CONTROL_END_SAMPLE = 192_000_000 BACKGROUND_LOCAL_PEAK_QUANTILE = 0.999 BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE = 0.999 BACKGROUND_1400MHZ_MAD_MULTIPLIER = 4.0 FIXED_1400MHZ_CANDIDATE_THRESHOLD = 0.03361873421818018 DETECTION_CHUNK_SAMPLES = 16_000_000 # В потоковом проходе это предел реально выполненных строгих проверок, а не # размер промежуточного списка. Он не зависит от ожидаемого числа кадров. MAX_CANDIDATES = 100_000 MAX_STORED_COARSE_CANDIDATES = 10_000 MAX_STORED_OUTCOMES = 4_096 MAX_STORED_CURSOR_POSITIONS = MAX_STORED_OUTCOMES + 1 COARSE_CANDIDATE_MEMORY_BYTES = 108 MAX_SEARCH_ITERATIONS = MAX_CANDIDATES MAX_VALID_FRAMES = 256 REFINEMENT_MARGIN_SAMPLES = 4_000 # resample_poly/fftconvolve одновременно создают несколько крупных временных # массивов. Измеренный tracemalloc synthetic peak был 9.61 размера complex64 # блока; коэффициент 12 сохраняет явный консервативный запас. COARSE_TEMPORARY_MEMORY_FACTOR = 12 @dataclass(frozen=True) class SampleInterval: name: str start_sample: int end_sample: int @property def sample_count(self) -> int: return self.end_sample - self.start_sample @property def duration_seconds(self) -> float: return self.sample_count / lab046c.SAMPLE_RATE_HZ CALIBRATION_INTERVAL = SampleInterval( "CALIBRATION_BACKGROUND", CALIBRATION_START_SAMPLE, CALIBRATION_END_SAMPLE, ) CONTROL_INTERVAL = SampleInterval( "CONTROL_BACKGROUND", CONTROL_START_SAMPLE, CONTROL_END_SAMPLE, ) # RX-процесс начался раньше TX. Передатчик работал в интервале # 0.602438700 ... 1.489154700 s относительно начала RX. Границы ниже # оставляют более 100 ms защитного запаса с обеих сторон. CALIBRATION_1400MHZ_INTERVAL = SampleInterval( "PRE_TX_BACKGROUND_1400MHZ", 0, 4_000_000, ) CONTROL_1400MHZ_INTERVAL = SampleInterval( "POST_TX_BACKGROUND_1400MHZ", 12_800_000, 19_200_000, ) @dataclass(frozen=True) class CandidateRule: method: str local_peak_quantile: float threshold: float @dataclass(frozen=True) class BackgroundStatistics: interval: SampleInterval correlation_score_count: int local_peak_count: int correlation_median: float correlation_mad: float local_peak_quantiles: dict[str, float] candidate_count: int candidates_per_second: float elapsed_seconds: float local_peak_median: float = float("nan") local_peak_mad: float = float("nan") @dataclass(frozen=True) class CoarseCandidate: sample_offset: int score: float @dataclass(frozen=True) class CoarseSearchResult: sample_count: int total_local_peaks: int candidates: tuple[CoarseCandidate, ...] maximum_window_samples: int estimated_maximum_temporary_bytes: int elapsed_seconds: float termination_reason: str max_candidates_reached: bool candidate_count: int = 0 candidates_skipped_as_same_local_event: int = 0 exact_duplicate_candidates_skipped: int = 0 blocks_processed: int = 0 processed_until_sample: int = 0 candidate_memory_bytes_peak: int = 0 @dataclass(frozen=True) class CandidateOutcome: candidate_offset: int refined_start_sample: int next_sample: int refinement_pass: bool timing_pass: bool carrier_pass: bool phy_header_attempted: bool phy_header_pass: bool fec_attempted: bool fec_pass: bool protocol_parse_pass: bool crc: str accepted: bool reason: str frame: lab046b.IqFrameReception | None maximum_local_window_samples: int elapsed_seconds: float @dataclass(frozen=True) class StrictCounters: candidates_processed: int rejected_during_refinement: int refinement_pass: int timing_pass: int carrier_pass: int phy_header_pass: int phy_header_fail: int fec_attempts: int fec_pass: int fec_fail: int protocol_parse_pass: int crc_pass: int crc_fail: int valid_frames: int @dataclass(frozen=True) class StrictSearchResult: counters: StrictCounters accepted_frames: tuple[lab046b.IqFrameReception, ...] outcomes: tuple[CandidateOutcome, ...] cursor_history: tuple[int, ...] elapsed_seconds: float average_candidate_seconds: float maximum_local_window_samples: int maximum_local_window_bytes: int termination_reason: str max_search_iterations_reached: bool max_valid_frames_reached: bool outcomes_truncated: bool = False cursor_history_truncated: bool = False @dataclass(frozen=True) class ObjectVerdict: valid_fragments: int duplicates: int conflicting_duplicates: int rejected_fragments: int object_id: int | None declared_fragment_count: int | None received_unique_fragments: int missing_fragments: tuple[int, ...] object_complete: bool recovered_object: bytes | None @dataclass(frozen=True) class IndependentRecoveryResult: completed: bool capture_path: Path capture_samples: int capture_duration_seconds: float rule: CandidateRule calibration: BackgroundStatistics control: BackgroundStatistics coarse: CoarseSearchResult strict: StrictSearchResult object_verdict: ObjectVerdict full_elapsed_seconds: float processing_to_capture_ratio: float @dataclass(frozen=True) class PostVerdictDiagnostics: stage: str expected_tx_frames: int independent_valid_frames_matching_tx: int missed_tx_frames: tuple[int, ...] false_crc_pass: int sample_offset_errors: tuple[int, ...] total_pre_fec_coded_bit_errors: int total_post_fec_protocol_bit_errors: int object_size: int | None recovered_sha256: str | None tx_sha256: str sha_match: bool object_byte_errors: int | None object_bit_errors: int | None offline_recovery: str @dataclass(frozen=True) class ShortCapturePostVerdictDiagnostics: stage: str expected_tx_frames: int matched_valid_frames: int missed_tx_frames: tuple[int, ...] false_crc_pass: int sample_offset_errors: tuple[int, ...] total_pre_fec_coded_bit_errors: int | None total_post_fec_protocol_bit_errors: int | None total_payload_byte_errors: int | None total_payload_bit_errors: int | None rf_path_assessment: str ScoreFunction = Callable[[np.ndarray, np.ndarray], np.ndarray] def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _amplitude_db(ratio: float) -> float: return 20.0 * math.log10(max(float(ratio), 1e-300)) def _validate_capture(capture_path: Path) -> tuple[np.memmap, int]: path = Path(capture_path) file_bytes = path.stat().st_size if file_bytes <= 0 or file_bytes % 2: raise ValueError("RX capture должен иметь положительный чётный размер") raw = np.memmap(path, dtype=np.int8, mode="r") return raw, file_bytes // 2 def _correlation_scores( samples: np.ndarray, reference_decimated: np.ndarray, ) -> np.ndarray: rx_decimated = lab046b._decimate_then_downconvert(samples) if rx_decimated.size < reference_decimated.size: return np.empty(0, dtype=np.float32) scores = np.asarray( lab046c.lab044.lab043._normalized_sliding_correlation( rx_decimated, reference_decimated, ), dtype=np.float32, ) scores[~np.isfinite(scores)] = 0.0 np.clip(scores, 0.0, 1.0, out=scores) return scores def _all_local_peak_indexes(scores: np.ndarray) -> np.ndarray: values = np.asarray(scores, dtype=np.float32) if values.ndim != 1 or values.size == 0: return np.empty(0, dtype=np.int64) left = np.empty_like(values) right = np.empty_like(values) left[0] = -np.inf left[1:] = values[:-1] right[-1] = -np.inf right[:-1] = values[1:] return np.flatnonzero((values >= left) & (values > right)) def _aligned_overlap(reference_samples: int) -> int: decimation = lab046c.lab044.lab043.DETECTION_DECIMATION required = reference_samples + decimation return ((required + decimation - 1) // decimation) * decimation def _score_blocks( raw: np.memmap, interval: SampleInterval, *, chunk_samples: int = DETECTION_CHUNK_SAMPLES, score_function: ScoreFunction = _correlation_scores, ): reference = lab046c.lab044._preamble_reference_samples() reference_decimated = lab046b._decimate_then_downconvert(reference) decimation = lab046c.lab044.lab043.DETECTION_DECIMATION overlap = _aligned_overlap(reference.size) chunk_samples = (chunk_samples // decimation) * decimation if chunk_samples <= overlap: raise ValueError("chunk_samples должен быть больше PRBS7 reference с overlap") if not 0 <= interval.start_sample < interval.end_sample <= raw.size // 2: raise ValueError("Фоновый интервал выходит за границы capture") step = chunk_samples - overlap for window_start in range(interval.start_sample, interval.end_sample, step): window_end = min(interval.end_sample, window_start + chunk_samples) ownership_end = ( interval.end_sample if window_end == interval.end_sample else window_start + step ) samples = lab046c._cs8_window(raw, window_start, window_end) scores = score_function(samples, reference_decimated) owned_count = min( scores.size, (ownership_end - window_start + decimation - 1) // decimation, ) local_indexes = _all_local_peak_indexes(scores) local_indexes = local_indexes[local_indexes < owned_count] yield ( window_start, scores[:owned_count], local_indexes, int(samples.size), int( COARSE_TEMPORARY_MEMORY_FACTOR * samples.nbytes + reference_decimated.nbytes + scores.nbytes + math.ceil(samples.size / decimation) * np.dtype(np.complex64).itemsize ), ) if window_end == interval.end_sample: break def _background_peak_scores( capture_path: Path, interval: SampleInterval, ) -> tuple[np.ndarray, np.ndarray, float]: raw, _ = _validate_capture(capture_path) started = time.perf_counter() score_blocks: list[np.ndarray] = [] peak_blocks: list[np.ndarray] = [] for _, scores, indexes, _, _ in _score_blocks(raw, interval): score_blocks.append(np.asarray(scores, dtype=np.float32).copy()) peak_blocks.append(np.asarray(scores[indexes], dtype=np.float32).copy()) all_scores = np.concatenate(score_blocks) if score_blocks else np.empty(0, np.float32) all_peaks = np.concatenate(peak_blocks) if peak_blocks else np.empty(0, np.float32) if all_scores.size == 0 or all_peaks.size == 0: raise RuntimeError("Фоновый интервал не дал корреляционной статистики") return all_scores, all_peaks, time.perf_counter() - started def _background_statistics( interval: SampleInterval, scores: np.ndarray, peaks: np.ndarray, threshold: float, elapsed_seconds: float, ) -> BackgroundStatistics: median = float(np.median(scores)) mad = float(np.median(np.abs(scores - median))) local_peak_median = float(np.median(peaks)) local_peak_mad = float(np.median(np.abs(peaks - local_peak_median))) quantiles = { "0.99": float(np.quantile(peaks, 0.99)), "0.999": float(np.quantile(peaks, 0.999)), "0.9995": float(np.quantile(peaks, 0.9995)), "0.9999": float(np.quantile(peaks, 0.9999)), "1.0": float(np.max(peaks)), } candidate_count = int(np.count_nonzero(peaks >= threshold)) return BackgroundStatistics( interval=interval, correlation_score_count=int(scores.size), local_peak_count=int(peaks.size), correlation_median=median, correlation_mad=mad, local_peak_quantiles=quantiles, candidate_count=candidate_count, candidates_per_second=candidate_count / interval.duration_seconds, elapsed_seconds=elapsed_seconds, local_peak_median=local_peak_median, local_peak_mad=local_peak_mad, ) def candidate_threshold_from_local_peaks( peaks: np.ndarray, *, quantile: float, mad_multiplier: float, ) -> float: """Получить порог как высокий квантиль плюс запас в единицах MAD.""" values = np.asarray(peaks, dtype=np.float32) if values.ndim != 1 or values.size == 0: raise ValueError("Нужен непустой одномерный массив локальных максимумов") if not 0.0 <= quantile <= 1.0 or mad_multiplier < 0.0: raise ValueError("Параметры статистического порога вне допустимого диапазона") median = float(np.median(values)) mad = float(np.median(np.abs(values - median))) quantile_value = float(np.quantile(values, quantile)) return quantile_value + mad_multiplier * mad def calibrate_candidate_rule( capture_path: Path, interval: SampleInterval = CALIBRATION_INTERVAL, ) -> tuple[CandidateRule, BackgroundStatistics]: """Зафиксировать threshold только по локальным максимумам calibration background.""" scores, peaks, elapsed = _background_peak_scores(capture_path, interval) threshold = float(np.quantile(peaks, BACKGROUND_LOCAL_PEAK_QUANTILE)) rule = CandidateRule( method="calibration local-peak quantile", local_peak_quantile=BACKGROUND_LOCAL_PEAK_QUANTILE, threshold=threshold, ) return rule, _background_statistics(interval, scores, peaks, threshold, elapsed) def calibrate_1400mhz_candidate_rule( capture_path: Path, ) -> tuple[CandidateRule, BackgroundStatistics]: """Воспроизвести заранее зафиксированный порог только по фону до TX.""" scores, peaks, elapsed = _background_peak_scores( capture_path, CALIBRATION_1400MHZ_INTERVAL, ) reproduced = candidate_threshold_from_local_peaks( peaks, quantile=BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE, mad_multiplier=BACKGROUND_1400MHZ_MAD_MULTIPLIER, ) if not math.isclose( reproduced, FIXED_1400MHZ_CANDIDATE_THRESHOLD, rel_tol=0.0, abs_tol=1e-12, ): raise RuntimeError( "Фоновая статистика не воспроизвела заранее зафиксированный " f"порог: {reproduced!r}" ) rule = CandidateRule( method="pre-TX local-peak Q99.9 + 4 * local-peak MAD", local_peak_quantile=BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE, threshold=FIXED_1400MHZ_CANDIDATE_THRESHOLD, ) statistics = _background_statistics( CALIBRATION_1400MHZ_INTERVAL, scores, peaks, rule.threshold, elapsed, ) return rule, statistics def evaluate_1400mhz_control_background( capture_path: Path, rule: CandidateRule, ) -> BackgroundStatistics: """Проверить зафиксированный порог на независимом фоне после TX.""" return evaluate_control_background( capture_path, rule, interval=CONTROL_1400MHZ_INTERVAL, ) def evaluate_control_background( capture_path: Path, rule: CandidateRule, interval: SampleInterval = CONTROL_INTERVAL, ) -> BackgroundStatistics: """Проверить уже зафиксированное правило, не изменяя threshold.""" scores, peaks, elapsed = _background_peak_scores(capture_path, interval) return _background_statistics(interval, scores, peaks, rule.threshold, elapsed) def coarse_search_capture( capture_path: Path, rule: CandidateRule, *, max_candidates: int = MAX_STORED_COARSE_CANDIDATES, chunk_samples: int = DETECTION_CHUNK_SAMPLES, ) -> CoarseSearchResult: """Найти first-stage candidates во всём capture без TX offsets/count.""" if max_candidates < 0: raise ValueError("max_candidates не может быть отрицательным") raw, sample_count = _validate_capture(capture_path) whole = SampleInterval("FULL_CAPTURE", 0, sample_count) started = time.perf_counter() candidates: list[CoarseCandidate] = [] total_local_peaks = 0 maximum_window = 0 maximum_temporary = 0 blocks_processed = 0 processed_until_sample = 0 termination = "END_OF_CAPTURE" max_reached = False decimation = lab046c.lab044.lab043.DETECTION_DECIMATION for window_start, scores, indexes, window_samples, temporary_bytes in _score_blocks( raw, whole, chunk_samples=chunk_samples, ): blocks_processed += 1 processed_until_sample = min( sample_count, window_start + int(scores.size) * decimation, ) total_local_peaks += int(indexes.size) maximum_window = max(maximum_window, window_samples) maximum_temporary = max(maximum_temporary, temporary_bytes) selected = indexes[scores[indexes] >= np.float32(rule.threshold)] for index in selected: if len(candidates) >= max_candidates: termination = "MAX_CANDIDATES_REACHED" max_reached = True break candidates.append(CoarseCandidate( sample_offset=window_start + int(index) * decimation, score=float(scores[index]), )) if max_reached: break candidates.sort(key=lambda row: row.sample_offset) return CoarseSearchResult( sample_count=sample_count, total_local_peaks=total_local_peaks, candidates=tuple(candidates), maximum_window_samples=maximum_window, estimated_maximum_temporary_bytes=maximum_temporary, elapsed_seconds=time.perf_counter() - started, termination_reason=termination, max_candidates_reached=max_reached, candidate_count=len(candidates), blocks_processed=blocks_processed, processed_until_sample=processed_until_sample, candidate_memory_bytes_peak=len(candidates) * COARSE_CANDIDATE_MEMORY_BYTES, ) def _failed_outcome( candidate: CoarseCandidate, *, refined: int, next_sample: int, refinement_pass: bool, timing_pass: bool, carrier_pass: bool, phy_header_attempted: bool, phy_header_pass: bool, reason: str, maximum_window: int, started: float, ) -> CandidateOutcome: return CandidateOutcome( candidate_offset=candidate.sample_offset, refined_start_sample=refined, next_sample=next_sample, refinement_pass=refinement_pass, timing_pass=timing_pass, carrier_pass=carrier_pass, phy_header_attempted=phy_header_attempted, phy_header_pass=phy_header_pass, fec_attempted=False, fec_pass=False, protocol_parse_pass=False, crc=lab046c.lab045.CRC_NOT_REACHED, accepted=False, reason=reason, frame=None, maximum_local_window_samples=maximum_window, elapsed_seconds=time.perf_counter() - started, ) def decode_candidate_from_memmap( raw: np.memmap, sample_count: int, candidate: CoarseCandidate, detection_number: int, ) -> CandidateOutcome: """Строго проверить один candidate локальными IQ-окнами.""" started = time.perf_counter() reference = lab046c.lab044._preamble_reference_samples() probe_symbols = lab046c.lab045.PREAMBLE_BIT_COUNT + lab046c.lab045.PHY_HEADER_BIT_COUNT probe_waveform = ( (probe_symbols + 2 * lab046c.lab045.GUARD_SYMBOL_COUNT) * lab046c.lab045.SAMPLES_PER_SYMBOL + lab046c.lab045.RRC_SPAN_SYMBOLS * lab046c.lab045.SAMPLES_PER_SYMBOL ) probe_start = max(0, candidate.sample_offset - REFINEMENT_MARGIN_SAMPLES) probe_end = min( sample_count, candidate.sample_offset + probe_waveform + REFINEMENT_MARGIN_SAMPLES, ) probe_iq = lab046c._cs8_window(raw, probe_start, probe_end) maximum_window = int(probe_iq.size) local_coarse = candidate.sample_offset - probe_start try: local_refined = lab046c.lab044.lab043._refine_frame_start( probe_iq, reference, local_coarse, ) except (ValueError, IndexError): return _failed_outcome( candidate, refined=candidate.sample_offset, next_sample=candidate.sample_offset + 1, refinement_pass=False, timing_pass=False, carrier_pass=False, phy_header_attempted=False, phy_header_pass=False, reason="REFINEMENT_FAILED", maximum_window=maximum_window, started=started, ) refined_global = probe_start + int(local_refined) probe = lab046b._extract_soft_window(probe_iq, int(local_refined), probe_symbols) if not probe.passed: return _failed_outcome( candidate, refined=refined_global, next_sample=candidate.sample_offset + 1, refinement_pass=True, timing_pass=False, carrier_pass=False, phy_header_attempted=False, phy_header_pass=False, reason=probe.reason or "PROBE_FAILED", maximum_window=maximum_window, started=started, ) header_soft = probe.normalized_real[ lab046c.lab045.PREAMBLE_BIT_COUNT : probe_symbols ] header = lab046c.lab045.decode_phy_header_soft(header_soft) if not header["passed"]: return _failed_outcome( candidate, refined=refined_global, next_sample=candidate.sample_offset + 1, refinement_pass=True, timing_pass=True, carrier_pass=True, phy_header_attempted=True, phy_header_pass=False, reason=f"PHY_HEADER_{header['reason']}", maximum_window=maximum_window, started=started, ) packet_bytes = int(header["packet_bytes"]) coded_length = lab046c.lab045.conv.encoded_bit_count(packet_bytes * 8) frame_symbols = ( lab046c.lab045.PREAMBLE_BIT_COUNT + lab046c.lab045.PHY_HEADER_BIT_COUNT + coded_length ) waveform_samples = ( (frame_symbols + 2 * lab046c.lab045.GUARD_SYMBOL_COUNT) * lab046c.lab045.SAMPLES_PER_SYMBOL + lab046c.lab045.RRC_SPAN_SYMBOLS * lab046c.lab045.SAMPLES_PER_SYMBOL ) full_end = refined_global + waveform_samples if refined_global < 0 or full_end > sample_count: return _failed_outcome( candidate, refined=refined_global, next_sample=candidate.sample_offset + 1, refinement_pass=True, timing_pass=True, carrier_pass=True, phy_header_attempted=True, phy_header_pass=True, reason="TRUNCATED_FULL_IQ_WINDOW", maximum_window=maximum_window, started=started, ) full_iq = lab046c._cs8_window(raw, refined_global, full_end) maximum_window = max(maximum_window, int(full_iq.size)) full = lab046b._extract_soft_window(full_iq, 0, frame_symbols) if not full.passed: return _failed_outcome( candidate, refined=refined_global, next_sample=candidate.sample_offset + 1, refinement_pass=True, timing_pass=True, carrier_pass=True, phy_header_attempted=True, phy_header_pass=True, reason=full.reason or "FULL_EXTRACTION_FAILED", maximum_window=maximum_window, started=started, ) post_preamble = full.normalized_real[lab046c.lab045.PREAMBLE_BIT_COUNT:] reception = lab046c.lab045.analyze_post_preamble_soft(post_preamble) crc = str(reception.analysis["crc"]) fec_pass = bool(reception.analysis["fec_decode_pass"]) protocol_pass = bool(reception.analysis["protocol_header_pass"]) accepted = crc == lab046c.lab045.CRC_PASS preamble_hard = ( full.normalized_real[: lab046c.lab045.PREAMBLE_BIT_COUNT] >= 0.0 ).astype(np.uint8) preamble_errors = int(np.count_nonzero( preamble_hard != lab046c.lab044.preamble_bits() )) frame = lab046b.IqFrameReception( detection_number=detection_number, coarse_start_sample=candidate.sample_offset, refined_start_sample=refined_global, frame_end_sample=full_end, preamble_score=candidate.score, over_median_db=float("nan"), over_second_db=float("nan"), timing_offset_samples=full.timing_offset_samples, symbol_sample_phase=full.symbol_sample_phase, nominal_sample=full.nominal_sample, estimated_cfo_hz=float(full.carrier["cfo_hz"]), estimated_phase_rad=float(full.carrier["phase_rad"]), bpsk_polarity=str(full.carrier["polarity"]), preamble_errors=preamble_errors, snr_db=float(full.carrier["snr_db"]), post_preamble_soft=post_preamble, reception=reception, ) return CandidateOutcome( candidate_offset=candidate.sample_offset, refined_start_sample=refined_global, next_sample=full_end if accepted else candidate.sample_offset + 1, refinement_pass=True, timing_pass=True, carrier_pass=True, phy_header_attempted=True, phy_header_pass=True, fec_attempted=True, fec_pass=fec_pass, protocol_parse_pass=protocol_pass, crc=crc, accepted=accepted, reason="CRC_PASS" if accepted else f"CRC_{crc}", frame=frame, maximum_local_window_samples=maximum_window, elapsed_seconds=time.perf_counter() - started, ) CandidateDecoder = Callable[ [np.memmap, int, CoarseCandidate, int], CandidateOutcome, ] def strict_process_candidates( capture_path: Path, candidates: tuple[CoarseCandidate, ...], *, max_search_iterations: int = MAX_SEARCH_ITERATIONS, max_valid_frames: int = MAX_VALID_FRAMES, decoder: CandidateDecoder = decode_candidate_from_memmap, ) -> StrictSearchResult: """Проверить кандидатов по offset; cursor всегда движется только вперёд.""" if max_search_iterations < 0 or max_valid_frames < 0: raise ValueError("Safety limits не могут быть отрицательными") raw, sample_count = _validate_capture(capture_path) started = time.perf_counter() cursor = 0 cursor_history = [0] outcomes: list[CandidateOutcome] = [] accepted_frames: list[lab046b.IqFrameReception] = [] termination = "END_OF_CANDIDATES" max_iterations_reached = False max_frames_reached = False for candidate in sorted(candidates, key=lambda row: row.sample_offset): if candidate.sample_offset < cursor: continue if len(outcomes) >= max_search_iterations: termination = "MAX_SEARCH_ITERATIONS_REACHED" max_iterations_reached = True break if len(accepted_frames) >= max_valid_frames: termination = "MAX_VALID_FRAMES_REACHED" max_frames_reached = True break outcome = decoder(raw, sample_count, candidate, len(outcomes) + 1) outcomes.append(outcome) if ( outcome.accepted and outcome.frame is not None and outcome.crc == lab046c.lab045.CRC_PASS ): accepted_frames.append(outcome.frame) cursor = max(cursor + 1, int(outcome.next_sample)) cursor_history.append(cursor) counters = StrictCounters( candidates_processed=len(outcomes), rejected_during_refinement=sum(not row.refinement_pass for row in outcomes), refinement_pass=sum(row.refinement_pass for row in outcomes), timing_pass=sum(row.timing_pass for row in outcomes), carrier_pass=sum(row.carrier_pass for row in outcomes), phy_header_pass=sum(row.phy_header_pass for row in outcomes), phy_header_fail=sum( row.phy_header_attempted and not row.phy_header_pass for row in outcomes ), fec_attempts=sum(row.fec_attempted for row in outcomes), fec_pass=sum(row.fec_pass for row in outcomes), fec_fail=sum(row.fec_attempted and not row.fec_pass for row in outcomes), protocol_parse_pass=sum(row.protocol_parse_pass for row in outcomes), crc_pass=sum(row.crc == lab046c.lab045.CRC_PASS for row in outcomes), crc_fail=sum(row.crc == lab046c.lab045.CRC_FAIL for row in outcomes), valid_frames=len(accepted_frames), ) elapsed = time.perf_counter() - started maximum_window = max( (row.maximum_local_window_samples for row in outcomes), default=0, ) return StrictSearchResult( counters=counters, accepted_frames=tuple(accepted_frames), outcomes=tuple(outcomes), cursor_history=tuple(cursor_history), elapsed_seconds=elapsed, average_candidate_seconds=elapsed / len(outcomes) if outcomes else 0.0, maximum_local_window_samples=maximum_window, maximum_local_window_bytes=maximum_window * np.dtype(np.complex64).itemsize, termination_reason=termination, max_search_iterations_reached=max_iterations_reached, max_valid_frames_reached=max_frames_reached, ) def stream_process_capture( capture_path: Path, rule: CandidateRule, *, max_candidates: int = MAX_CANDIDATES, max_valid_frames: int = MAX_VALID_FRAMES, chunk_samples: int = DETECTION_CHUNK_SAMPLES, decoder: CandidateDecoder = decode_candidate_from_memmap, ) -> tuple[CoarseSearchResult, StrictSearchResult]: """Последовательно находить и сразу строго проверять кандидатов. ``max_candidates`` ограничивает число вызовов строгого декодера за весь capture. Кандидаты всего файла не накапливаются. После CRC PASS курсор владеет областью до конца принятого кадра; после отказа пропускается только текущая позиция. Точный повтор sample offset подавляется без радиуса, который мог бы скрыть соседний настоящий кадр. """ if max_candidates < 0 or max_valid_frames < 0: raise ValueError("Safety limits не могут быть отрицательными") raw, sample_count = _validate_capture(capture_path) whole = SampleInterval("FULL_CAPTURE", 0, sample_count) started = time.perf_counter() decimation = lab046c.lab044.lab043.DETECTION_DECIMATION total_local_peaks = 0 first_stage_candidates = 0 candidates_skipped_as_same_local_event = 0 exact_duplicate_candidates_skipped = 0 blocks_processed = 0 processed_until_sample = 0 maximum_window = 0 maximum_temporary = 0 candidate_memory_peak = 0 last_candidate_offset: int | None = None cursor = 0 cursor_history = [0] cursor_history_truncated = False stored_outcomes: list[CandidateOutcome] = [] outcomes_truncated = False accepted_frames: list[lab046b.IqFrameReception] = [] strict_elapsed = 0.0 maximum_strict_window = 0 counters = { "candidates_processed": 0, "rejected_during_refinement": 0, "refinement_pass": 0, "timing_pass": 0, "carrier_pass": 0, "phy_header_pass": 0, "phy_header_fail": 0, "fec_attempts": 0, "fec_pass": 0, "fec_fail": 0, "protocol_parse_pass": 0, "crc_pass": 0, "crc_fail": 0, "valid_frames": 0, } termination = "END_OF_CAPTURE" max_candidates_reached = False max_valid_frames_reached = False stop = False for window_start, scores, indexes, window_samples, temporary_bytes in _score_blocks( raw, whole, chunk_samples=chunk_samples, ): blocks_processed += 1 processed_until_sample = min( sample_count, window_start + int(scores.size) * decimation, ) total_local_peaks += int(indexes.size) maximum_window = max(maximum_window, window_samples) selected = indexes[scores[indexes] >= np.float32(rule.threshold)] maximum_temporary = max( maximum_temporary, temporary_bytes + int(selected.nbytes) + COARSE_CANDIDATE_MEMORY_BYTES, ) candidate_memory_peak = max( candidate_memory_peak, int(selected.nbytes) + COARSE_CANDIDATE_MEMORY_BYTES, ) for index in selected: sample_offset = window_start + int(index) * decimation if last_candidate_offset is not None: if sample_offset < last_candidate_offset: raise RuntimeError("Кандидаты потокового поиска нарушили порядок offset") if sample_offset == last_candidate_offset: exact_duplicate_candidates_skipped += 1 continue last_candidate_offset = sample_offset first_stage_candidates += 1 if sample_offset < cursor: candidates_skipped_as_same_local_event += 1 continue if counters["candidates_processed"] >= max_candidates: termination = "MAX_CANDIDATES_REACHED" max_candidates_reached = True stop = True break if len(accepted_frames) >= max_valid_frames: termination = "MAX_VALID_FRAMES_REACHED" max_valid_frames_reached = True stop = True break candidate = CoarseCandidate( sample_offset=sample_offset, score=float(scores[index]), ) outcome = decoder( raw, sample_count, candidate, counters["candidates_processed"] + 1, ) counters["candidates_processed"] += 1 counters["rejected_during_refinement"] += int(not outcome.refinement_pass) counters["refinement_pass"] += int(outcome.refinement_pass) counters["timing_pass"] += int(outcome.timing_pass) counters["carrier_pass"] += int(outcome.carrier_pass) counters["phy_header_pass"] += int(outcome.phy_header_pass) counters["phy_header_fail"] += int( outcome.phy_header_attempted and not outcome.phy_header_pass ) counters["fec_attempts"] += int(outcome.fec_attempted) counters["fec_pass"] += int(outcome.fec_pass) counters["fec_fail"] += int(outcome.fec_attempted and not outcome.fec_pass) counters["protocol_parse_pass"] += int(outcome.protocol_parse_pass) counters["crc_pass"] += int(outcome.crc == lab046c.lab045.CRC_PASS) counters["crc_fail"] += int(outcome.crc == lab046c.lab045.CRC_FAIL) strict_elapsed += outcome.elapsed_seconds maximum_strict_window = max( maximum_strict_window, outcome.maximum_local_window_samples, ) accepted = bool( outcome.accepted and outcome.frame is not None and outcome.crc == lab046c.lab045.CRC_PASS ) if accepted: accepted_frames.append(outcome.frame) counters["valid_frames"] += 1 if len(stored_outcomes) < MAX_STORED_OUTCOMES or accepted: stored_outcomes.append(outcome) else: outcomes_truncated = True next_cursor = int(outcome.next_sample) if accepted else sample_offset + 1 cursor = max(cursor + 1, next_cursor) if len(cursor_history) < MAX_STORED_CURSOR_POSITIONS: cursor_history.append(cursor) else: cursor_history_truncated = True if stop: break # Если генератор блоков исчерпан без защитной остановки, обработан весь # интервал. Последний допустимый старт корреляции закономерно раньше конца # IQ на длину опорной последовательности и не является границей покрытия. if not stop: processed_until_sample = sample_count total_elapsed = time.perf_counter() - started coarse = CoarseSearchResult( sample_count=sample_count, total_local_peaks=total_local_peaks, candidates=(), maximum_window_samples=maximum_window, estimated_maximum_temporary_bytes=maximum_temporary, elapsed_seconds=max(0.0, total_elapsed - strict_elapsed), termination_reason=termination, max_candidates_reached=max_candidates_reached, candidate_count=first_stage_candidates, candidates_skipped_as_same_local_event=candidates_skipped_as_same_local_event, exact_duplicate_candidates_skipped=exact_duplicate_candidates_skipped, blocks_processed=blocks_processed, processed_until_sample=processed_until_sample, candidate_memory_bytes_peak=candidate_memory_peak, ) strict = StrictSearchResult( counters=StrictCounters(**counters), accepted_frames=tuple(accepted_frames), outcomes=tuple(stored_outcomes), cursor_history=tuple(cursor_history), elapsed_seconds=strict_elapsed, average_candidate_seconds=( strict_elapsed / counters["candidates_processed"] if counters["candidates_processed"] else 0.0 ), maximum_local_window_samples=maximum_strict_window, maximum_local_window_bytes=( maximum_strict_window * np.dtype(np.complex64).itemsize ), termination_reason=termination, max_search_iterations_reached=max_candidates_reached, max_valid_frames_reached=max_valid_frames_reached, outcomes_truncated=outcomes_truncated, cursor_history_truncated=cursor_history_truncated, ) return coarse, strict def assemble_independent_object( frames: tuple[lab046b.IqFrameReception, ...], ) -> ObjectVerdict: """Собрать объект только по payload независимо принятых CRC PASS frames.""" assembler = lab046.ObjectAssembler() object_id: int | None = None declared_count: int | None = None for frame in frames: payload = frame.reception.payload_bytes fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None if fragment is not None and object_id is None: object_id = int(fragment.image_id) declared_count = int(fragment.fragment_count) assembler.offer(fragment) recovered = assembler.recovered_object() return ObjectVerdict( valid_fragments=assembler.counters.accepted, duplicates=assembler.counters.duplicates, conflicting_duplicates=assembler.counters.conflicting, rejected_fragments=assembler.counters.rejected, object_id=object_id, declared_fragment_count=declared_count, received_unique_fragments=assembler.counters.accepted, missing_fragments=tuple(assembler.missing_indexes()), object_complete=assembler.object_complete(), recovered_object=recovered, ) def _run_with_fixed_candidate_rule( capture_path: Path, rule: CandidateRule, calibration: BackgroundStatistics, control: BackgroundStatistics, *, full_started: float, ) -> IndependentRecoveryResult: """Обработать весь RX после окончательной фиксации правила кандидатов.""" capture_path = Path(capture_path) _, sample_count = _validate_capture(capture_path) coarse, strict = stream_process_capture(capture_path, rule) verdict = assemble_independent_object(strict.accepted_frames) elapsed = time.perf_counter() - full_started completed = bool( not coarse.max_candidates_reached and not strict.max_search_iterations_reached and not strict.max_valid_frames_reached and strict.termination_reason == "END_OF_CAPTURE" and coarse.processed_until_sample == sample_count ) return IndependentRecoveryResult( completed=completed, capture_path=capture_path, capture_samples=sample_count, capture_duration_seconds=sample_count / lab046c.SAMPLE_RATE_HZ, rule=rule, calibration=calibration, control=control, coarse=coarse, strict=strict, object_verdict=verdict, full_elapsed_seconds=elapsed, processing_to_capture_ratio=( elapsed / (sample_count / lab046c.SAMPLE_RATE_HZ) ), ) def run_independent_recovery(capture_path: Path) -> IndependentRecoveryResult: """Сохранённый 24-секундный RX-only прогон без TX-оракулов.""" full_started = time.perf_counter() rule, calibration = calibrate_candidate_rule(capture_path) control = evaluate_control_background(capture_path, rule) return _run_with_fixed_candidate_rule( capture_path, rule, calibration, control, full_started=full_started, ) def run_1400mhz_independent_recovery( capture_path: Path, ) -> IndependentRecoveryResult: """Один полный проход записи 1400 МГц без сведений от передатчика.""" full_started = time.perf_counter() rule, calibration = calibrate_1400mhz_candidate_rule(capture_path) control = evaluate_1400mhz_control_background(capture_path, rule) if control.candidate_count != 0: raise RuntimeError( "Независимый контрольный фон создал кандидаты; " "полный проход остановлен без изменения порога" ) return _run_with_fixed_candidate_rule( capture_path, rule, calibration, control, full_started=full_started, ) def diagnose_after_independent_verdict( independent: IndependentRecoveryResult, reference_directory: Path, ) -> PostVerdictDiagnostics: """Открыть TX reference только после неизменяемого independent verdict.""" if not independent.completed: raise RuntimeError("Независимый verdict ещё не завершён") reference_directory = Path(reference_directory) tx_object = (reference_directory / "tx_object.bin").read_bytes() metadata = json.loads( (reference_directory / "tx_metadata.json").read_text(encoding="utf-8") ) tx_frames = lab046.build_object_frames(tx_object, lab046c.OBJECT_ID) by_packet = {frame.plan.packet: (index, frame) for index, frame in enumerate(tx_frames)} matched_indexes: list[int] = [] starts_by_index: list[tuple[int, int]] = [] false_crc_pass = 0 pre_fec_errors = 0 post_fec_errors = 0 for received in independent.strict.accepted_frames: packet = received.reception.packet_bytes matched = by_packet.get(packet) if packet is not None else None if matched is None: false_crc_pass += 1 continue index, frame = matched matched_indexes.append(index) starts_by_index.append((index, received.refined_start_sample)) comparison = lab046c.lab045.diagnostic_pre_and_post_fec( received.reception, received.post_preamble_soft, frame.plan, ) pre_fec_errors += int(comparison["pre_fec_coded"]["bit_errors"] or 0) post_fec_errors += int(comparison["post_fec_packet"]["bit_errors"] or 0) expected_indexes = set(range(len(tx_frames))) missed = tuple(sorted(expected_indexes - set(matched_indexes))) sample_errors: tuple[int, ...] = () if starts_by_index: frame_samples = tx_frames[0].plan.waveform_sample_count base_estimates = [start - index * frame_samples for index, start in starts_by_index] base = int(round(float(np.median(base_estimates)))) sample_errors = tuple( start - (base + index * frame_samples) for index, start in starts_by_index ) recovered = independent.object_verdict.recovered_object recovered_sha = _sha256(recovered) if recovered is not None else None tx_sha = str(metadata["object"]["sha256"]) if recovered is not None and len(recovered) == len(tx_object): received_array = np.frombuffer(recovered, dtype=np.uint8) tx_array = np.frombuffer(tx_object, dtype=np.uint8) xor = np.bitwise_xor(received_array, tx_array) byte_errors = int(np.count_nonzero(xor)) bit_errors = int(np.unpackbits(xor).sum()) else: byte_errors = None bit_errors = None sha_match = recovered_sha == tx_sha if recovered_sha is not None else False if ( independent.object_verdict.object_complete and false_crc_pass == 0 and len(recovered or b"") == lab046c.OBJECT_SIZE_BYTES and sha_match and byte_errors == 0 ): offline_recovery = "PASS" elif independent.strict.counters.valid_frames: offline_recovery = "PARTIAL" else: offline_recovery = "FAIL" return PostVerdictDiagnostics( stage="AFTER_INDEPENDENT_OBJECT_VERDICT", expected_tx_frames=len(tx_frames), independent_valid_frames_matching_tx=len(matched_indexes), missed_tx_frames=missed, false_crc_pass=false_crc_pass, sample_offset_errors=sample_errors, total_pre_fec_coded_bit_errors=pre_fec_errors, total_post_fec_protocol_bit_errors=post_fec_errors, object_size=len(recovered) if recovered is not None else None, recovered_sha256=recovered_sha, tx_sha256=tx_sha, sha_match=sha_match, object_byte_errors=byte_errors, object_bit_errors=bit_errors, offline_recovery=offline_recovery, ) def _numbered_reference_files( reference_directory: Path, prefix: str, ) -> dict[int, Path]: files: dict[int, Path] = {} for path in Path(reference_directory).glob(f"{prefix}_*.bin"): try: index = int(path.stem.rsplit("_", 1)[1]) except (IndexError, ValueError) as error: raise RuntimeError(f"Некорректное имя опорного файла: {path.name}") from error if index in files: raise RuntimeError(f"Повтор индекса опорного файла: {index}") files[index] = path return files def diagnose_1400mhz_after_independent_verdict( independent: IndependentRecoveryResult, reference_directory: Path, ) -> ShortCapturePostVerdictDiagnostics: """Сравнить CRC PASS кадры с TX только после независимого решения.""" if not independent.completed: raise RuntimeError("Независимый результат ещё не завершён") reference_directory = Path(reference_directory) payload_paths = _numbered_reference_files(reference_directory, "tx_payload") packet_paths = _numbered_reference_files(reference_directory, "tx_packet") if not payload_paths or payload_paths.keys() != packet_paths.keys(): raise RuntimeError("Наборы TX payload и packet отсутствуют или не совпадают") plans: dict[int, lab046c.lab045.TransmissionPlan] = {} index_by_packet: dict[bytes, int] = {} for index in sorted(payload_paths): payload = payload_paths[index].read_bytes() packet = packet_paths[index].read_bytes() plan = lab046c.lab045.build_transmission_plan(payload) if plan.packet != packet: raise RuntimeError(f"TX packet {index} не соответствует TX payload") if packet in index_by_packet: raise RuntimeError("Переданные packets не уникальны") plans[index] = plan index_by_packet[packet] = index matched: list[tuple[int, lab046b.IqFrameReception]] = [] false_crc_pass = 0 pre_fec_errors = 0 post_fec_errors = 0 payload_byte_errors = 0 payload_bit_errors = 0 for received in independent.strict.accepted_frames: packet = received.reception.packet_bytes index = index_by_packet.get(packet) if packet is not None else None if index is None: false_crc_pass += 1 continue matched.append((index, received)) comparison = lab046c.lab045.diagnostic_pre_and_post_fec( received.reception, received.post_preamble_soft, plans[index], ) pre_fec_errors += int(comparison["pre_fec_coded"]["bit_errors"] or 0) post_fec_errors += int(comparison["post_fec_packet"]["bit_errors"] or 0) payload_byte_errors += int(comparison["post_fec_payload"]["byte_errors"] or 0) payload_bit_errors += int(comparison["post_fec_payload"]["bit_errors"] or 0) matched_indexes = {index for index, _ in matched} expected_indexes = set(plans) missed = tuple(sorted(expected_indexes - matched_indexes)) sample_errors: tuple[int, ...] = () if matched: first_plan = plans[min(plans)] frame_samples = first_plan.waveform_sample_count base_estimates = [ frame.refined_start_sample - index * frame_samples for index, frame in matched ] base = int(round(float(np.median(base_estimates)))) sample_errors = tuple( frame.refined_start_sample - (base + index * frame_samples) for index, frame in sorted( matched, key=lambda row: (row[0], row[1].refined_start_sample), ) ) if false_crc_pass: assessment = "INSUFFICIENT" elif len(matched_indexes) == len(expected_indexes) and not missed: assessment = "HEALTHY" elif matched_indexes: assessment = "PARTIAL" else: assessment = "INSUFFICIENT" return ShortCapturePostVerdictDiagnostics( stage="AFTER_INDEPENDENT_VERDICT_ONLY", expected_tx_frames=len(plans), matched_valid_frames=len(matched_indexes), missed_tx_frames=missed, false_crc_pass=false_crc_pass, sample_offset_errors=sample_errors, total_pre_fec_coded_bit_errors=pre_fec_errors if matched else None, total_post_fec_protocol_bit_errors=post_fec_errors if matched else None, total_payload_byte_errors=payload_byte_errors if matched else None, total_payload_bit_errors=payload_bit_errors if matched else None, rf_path_assessment=assessment, ) def _independent_summary(result: IndependentRecoveryResult) -> dict: return { "completed": result.completed, "capture_path": str(result.capture_path), "capture_samples": result.capture_samples, "capture_duration_seconds": result.capture_duration_seconds, "rule": asdict(result.rule), "calibration": asdict(result.calibration), "control": asdict(result.control), "coarse": { **asdict(result.coarse), "candidates": [asdict(row) for row in result.coarse.candidates], }, "strict": { "counters": asdict(result.strict.counters), "cursor_history": result.strict.cursor_history, "elapsed_seconds": result.strict.elapsed_seconds, "average_candidate_seconds": result.strict.average_candidate_seconds, "maximum_local_window_samples": result.strict.maximum_local_window_samples, "maximum_local_window_bytes": result.strict.maximum_local_window_bytes, "termination_reason": result.strict.termination_reason, "max_search_iterations_reached": result.strict.max_search_iterations_reached, "max_valid_frames_reached": result.strict.max_valid_frames_reached, "outcomes_truncated": result.strict.outcomes_truncated, "cursor_history_truncated": result.strict.cursor_history_truncated, "outcomes": [ { key: value for key, value in asdict(row).items() if key != "frame" } for row in result.strict.outcomes ], }, "object_verdict": { key: value for key, value in asdict(result.object_verdict).items() if key != "recovered_object" }, "full_elapsed_seconds": result.full_elapsed_seconds, "processing_to_capture_ratio": result.processing_to_capture_ratio, "receiver_oracles": { "tx_stream": False, "tx_offsets": False, "expected_frame_count": False, "tx_object": False, "fragment_order": False, "tx_packets": False, "tx_coded_bits": False, }, "rf_tx": 0, } def write_results( output_directory: Path, independent: IndependentRecoveryResult, diagnostics: PostVerdictDiagnostics, ) -> tuple[Path, Path, Path | None]: output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) independent_path = output_directory / "independent_result.json" diagnostic_path = output_directory / "post_verdict_diagnostics.json" independent_path.write_text( json.dumps(_independent_summary(independent), ensure_ascii=False, indent=2), encoding="utf-8", ) diagnostic_path.write_text( json.dumps(asdict(diagnostics), ensure_ascii=False, indent=2), encoding="utf-8", ) recovered_path: Path | None = None if independent.object_verdict.object_complete: recovered = independent.object_verdict.recovered_object if recovered is None: raise RuntimeError("OBJECT COMPLETE без recovered bytes") recovered_path = output_directory / "recovered_object.bin" recovered_path.write_bytes(recovered) return independent_path, diagnostic_path, recovered_path def write_1400mhz_independent_result( output_directory: Path, independent: IndependentRecoveryResult, ) -> Path: """Зафиксировать независимый результат до открытия TX reference.""" output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) independent_path = output_directory / "independent_result.json" independent_path.write_text( json.dumps(_independent_summary(independent), ensure_ascii=False, indent=2), encoding="utf-8", ) return independent_path def write_1400mhz_post_verdict_diagnostics( output_directory: Path, diagnostics: ShortCapturePostVerdictDiagnostics, ) -> Path: """Сохранить диагностику, выполненную после независимого результата.""" output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) diagnostic_path = output_directory / "post_verdict_diagnostics.json" diagnostic_path.write_text( json.dumps(asdict(diagnostics), ensure_ascii=False, indent=2), encoding="utf-8", ) return diagnostic_path def run_functional_tests() -> None: assert lab046b.IQ_PREAMBLE_THRESHOLD == 0.65 assert BACKGROUND_LOCAL_PEAK_QUANTILE == 0.999 assert FIXED_1400MHZ_CANDIDATE_THRESHOLD == 0.03361873421818018 assert MAX_VALID_FRAMES > 68 assert CALIBRATION_INTERVAL.end_sample < CONTROL_INTERVAL.start_sample assert CALIBRATION_1400MHZ_INTERVAL.end_sample < CONTROL_1400MHZ_INTERVAL.start_sample def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--capture", type=Path, default=DEFAULT_CAPTURE_PATH) parser.add_argument( "--reference-directory", type=Path, default=DEFAULT_REFERENCE_DIRECTORY, ) parser.add_argument("--output-directory", type=Path, default=DEFAULT_OUTPUT_DIRECTORY) mode = parser.add_mutually_exclusive_group() mode.add_argument("--run-original-capture", action="store_true") mode.add_argument("--run-1400mhz-capture", action="store_true") arguments = parser.parse_args() if not arguments.run_original_capture and not arguments.run_1400mhz_capture: run_functional_tests() print("LAB046C OFFLINE SOFTWARE PREFLIGHT = PASS") print("RF TX = 0") return 0 if arguments.run_1400mhz_capture: capture = ( arguments.capture if arguments.capture != DEFAULT_CAPTURE_PATH else CAPTURE_1400MHZ_PATH ) reference_directory = ( arguments.reference_directory if arguments.reference_directory != DEFAULT_REFERENCE_DIRECTORY else REFERENCE_1400MHZ_DIRECTORY ) output_directory = ( arguments.output_directory if arguments.output_directory != DEFAULT_OUTPUT_DIRECTORY else OUTPUT_1400MHZ_DIRECTORY ) independent = run_1400mhz_independent_recovery(capture) if not independent.completed: print(json.dumps({ "independent": _independent_summary(independent), "post_verdict": None, "reason": "INDEPENDENT_SCAN_INCOMPLETE_FAIL_CLOSED", "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 2 independent_path = write_1400mhz_independent_result( output_directory, independent, ) diagnostics = diagnose_1400mhz_after_independent_verdict( independent, reference_directory, ) diagnostic_path = write_1400mhz_post_verdict_diagnostics( output_directory, diagnostics, ) print(json.dumps({ "completed": independent.completed, "rule": asdict(independent.rule), "calibration": asdict(independent.calibration), "control": asdict(independent.control), "total_local_peaks": independent.coarse.total_local_peaks, "first_stage_candidates": len(independent.coarse.candidates), "strict_counters": asdict(independent.strict.counters), "post_verdict": asdict(diagnostics), "written_paths": [str(independent_path), str(diagnostic_path)], "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 3 if diagnostics.false_crc_pass else 0 independent = run_independent_recovery(arguments.capture) if not independent.completed: print(json.dumps({ "independent": _independent_summary(independent), "post_verdict": None, "reason": "INDEPENDENT_SCAN_INCOMPLETE_FAIL_CLOSED", "original_hardware_result": "C", "original_hardware_result_changed": False, "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 2 # Следующая строка исполняется только после возвращённого completed verdict. diagnostics = diagnose_after_independent_verdict( independent, arguments.reference_directory, ) paths = write_results( arguments.output_directory, independent, diagnostics, ) print(json.dumps({ "independent": _independent_summary(independent), "post_verdict": asdict(diagnostics), "written_paths": [str(path) if path is not None else None for path in paths], "original_hardware_result": "C", "original_hardware_result_changed": False, "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())