Lab046C: recover short 1400 MHz capture offline
This commit is contained in:
@@ -35,6 +35,13 @@ DEFAULT_CAPTURE_PATH = Path(
|
||||
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
|
||||
@@ -43,6 +50,9 @@ 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 = 10_000
|
||||
MAX_SEARCH_ITERATIONS = 10_000
|
||||
@@ -80,6 +90,20 @@ CONTROL_INTERVAL = SampleInterval(
|
||||
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:
|
||||
@@ -99,6 +123,8 @@ class BackgroundStatistics:
|
||||
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)
|
||||
@@ -222,6 +248,21 @@ class PostVerdictDiagnostics:
|
||||
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]
|
||||
|
||||
|
||||
@@ -356,6 +397,8 @@ def _background_statistics(
|
||||
) -> 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)),
|
||||
@@ -374,9 +417,30 @@ def _background_statistics(
|
||||
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,
|
||||
@@ -393,6 +457,58 @@ def calibrate_candidate_rule(
|
||||
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,
|
||||
@@ -709,7 +825,11 @@ def strict_process_candidates(
|
||||
break
|
||||
outcome = decoder(raw, sample_count, candidate, len(outcomes) + 1)
|
||||
outcomes.append(outcome)
|
||||
if outcome.accepted and outcome.frame is not None:
|
||||
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)
|
||||
@@ -782,14 +902,18 @@ def assemble_independent_object(
|
||||
)
|
||||
|
||||
|
||||
def run_independent_recovery(capture_path: Path) -> IndependentRecoveryResult:
|
||||
"""Один полный RX-only прогон; API не принимает ни одного TX oracle."""
|
||||
def _run_with_fixed_candidate_rule(
|
||||
capture_path: Path,
|
||||
rule: CandidateRule,
|
||||
calibration: BackgroundStatistics,
|
||||
control: BackgroundStatistics,
|
||||
*,
|
||||
full_started: float,
|
||||
) -> IndependentRecoveryResult:
|
||||
"""Обработать весь RX после окончательной фиксации правила кандидатов."""
|
||||
|
||||
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(
|
||||
@@ -827,6 +951,43 @@ def run_independent_recovery(capture_path: Path) -> IndependentRecoveryResult:
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -919,6 +1080,115 @@ def diagnose_after_independent_verdict(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -998,11 +1268,45 @@ def write_results(
|
||||
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:
|
||||
@@ -1014,14 +1318,67 @@ def main() -> int:
|
||||
default=DEFAULT_REFERENCE_DIRECTORY,
|
||||
)
|
||||
parser.add_argument("--output-directory", type=Path, default=DEFAULT_OUTPUT_DIRECTORY)
|
||||
parser.add_argument("--run-original-capture", action="store_true")
|
||||
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:
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user