1059 lines
38 KiB
Python
1059 lines
38 KiB
Python
"""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())
|