"""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") # По плану аппаратного опыта 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 DETECTION_CHUNK_SAMPLES = 16_000_000 MAX_CANDIDATES = 10_000 MAX_SEARCH_ITERATIONS = 10_000 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, ) @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 @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 @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 @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 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))) 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, ) 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 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_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 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, ): 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, ) 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: 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 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_independent_recovery(capture_path: Path) -> IndependentRecoveryResult: """Один полный RX-only прогон; API не принимает ни одного TX oracle.""" full_started = time.perf_counter() capture_path = Path(capture_path) _, sample_count = _validate_capture(capture_path) rule, calibration = calibrate_candidate_rule(capture_path) control = evaluate_control_background(capture_path, rule) coarse = coarse_search_capture(capture_path, rule) if coarse.max_candidates_reached: strict = strict_process_candidates( capture_path, (), max_search_iterations=0, max_valid_frames=0, ) verdict = assemble_independent_object(()) else: strict = strict_process_candidates(capture_path, coarse.candidates) 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_CANDIDATES" ) 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 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 _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": [ { 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 run_functional_tests() -> None: assert lab046b.IQ_PREAMBLE_THRESHOLD == 0.65 assert BACKGROUND_LOCAL_PEAK_QUANTILE == 0.999 assert MAX_VALID_FRAMES > 68 assert CALIBRATION_INTERVAL.end_sample < CONTROL_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) parser.add_argument("--run-original-capture", action="store_true") arguments = parser.parse_args() if not arguments.run_original_capture: run_functional_tests() print("LAB046C OFFLINE SOFTWARE PREFLIGHT = PASS") print("RF TX = 0") return 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())