Files
SDR-Rover/experiments/lab046c_offline_recovery.py
2026-09-08 14:15:36 +03:00

1647 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
*,
read_iq_window: Callable | None = None,
) -> CandidateOutcome:
"""Строго проверить один candidate локальными IQ-окнами."""
started = time.perf_counter()
window_reader = lab046c._cs8_window if read_iq_window is None else read_iq_window
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 = window_reader(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 = window_reader(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())