Files
SDR-Rover/experiments/lab046c_offline_recovery.py
2026-09-01 17:09:27 +03:00

1059 lines
38 KiB
Python
Raw 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")
# По плану аппаратного опыта 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())