diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 7b72bec..c3f6a4f 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -959,3 +959,68 @@ notices. Финальный read-only verifier независимо сверил менять задним числом и второй улучшенный pass автоматически не запускать. Повторный RF TX, gain ladder, JPEG, video, QPSK, erasure FEC и ARQ без новой отдельной постановки запрещены. + +# Запись 031 + +## Дата + +2 сентября 2026 года + +## Тема + +Lab046C: независимая повторная обработка короткой записи 1400 МГц с отдельным +статистическим допуском кандидатов, без нового RF. + +## Выполнено + +Использована сохранённая запись +`20260901_172749_1400MHz/rx_1400mhz.cs8`: 19 200 000 комплексных отсчётов cs8, +2,4 с при 8 МГц, SHA-256 +`06ab0867865bca462a21c42c9f3bc7e519e336108f7b5e98e7b283f842387a99`. +HackRF не запускался, `RF TX = 0`. Исторический порог +`IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, FEC и PHY не изменялись. Исходный +аппаратный `CONTROL RESULT = C` сохранён без переписывания. + +До полного независимого декодирования зафиксировано отдельное правило первого +каскада только по фону до передачи `0,00 ... 0,50 с`: +`pre-TX local-peak Q99.9 + 4 * local-peak MAD`. Q99.9 равен +`0.022788675501942635`, MAD равен `0.0027075146790593863`, итоговый порог равен +`0.03361873421818018`. На независимом контрольном фоне после передачи +`1,60 ... 2,40 с` максимум равен `0.02711654081940651`, кандидатов 0. Порог +после просмотра результата не менялся. + +Перед полным проходом независимая проверка выявила небезопасную при отказе ошибку общей точки +приёма: противоречивый результат `accepted=True, CRC_FAIL` мог увеличить +`VALID_FRAMES`. Добавлена обязательная повторная проверка `CRC PASS`; сценарий +после исправления даёт `VALID_FRAMES = 0`. Независимая повторная проверка +признала исправление чистым. + +Выполнен ровно один полный проход всей записи без смещений TX, ожидаемого числа +кадров, сигнала TX, пакетов, полезной нагрузки и кодированных битов. Получено +29 205 локальных максимумов и 1 279 кандидатов первого каскада. Строгий каскад +обработал 28 кандидатов: PHY-заголовок PASS/FAIL `3/25`, FEC attempts/PASS/FAIL +`3/3/0`, CRC PASS/FAIL `3/0`, `VALID FRAMES = 3`. Поиск завершился по концу +записи и списка кандидатов; ограничения безопасности не сработали. + +Независимый результат записан до открытия опорных данных передатчика. Только +после этого три действительных кадра сопоставлены с тремя переданными: +пропущено 0, `FALSE CRC PASS = 0`, ошибки положения `0/-1/+2` отсчёта. Ошибки +до FEC составили `17 + 19 + 29 = 65` бит, после FEC 0 бит, в полезной нагрузке +0 байт и 0 бит. Независимая проверка готовых JSON и трёх пар полезной +нагрузки/пакета +расхождений не выявила. `1400 MHz OFFLINE REPROCESSING = PASS`, +`RF PATH ASSESSMENT = HEALTHY`. + +Итоговый программный шлюз перед полным проходом: `python tools/quick_gate.py` +код 0 и 512 проверок; `python -m pytest tests/ -q` = 512 passed; +`git diff --check` код 0 с информационными уведомлениями LF/CRLF. Git add, +commit и push не выполнялись. + +Подробный отчёт и машинно-читаемые результаты сохранены в +`docs/lab046c_1400mhz_offline_reprocessing.md` и +`data/processed/lab046c_1400mhz_offline/`. + +## Следующий этап + +STOP. Новый RF, повтор Lab046C, изменение порога, JPEG, QPSK и изменение FEC +без отдельной постановки запрещены. diff --git a/docs/lab046c_1400mhz_offline_reprocessing.md b/docs/lab046c_1400mhz_offline_reprocessing.md new file mode 100644 index 0000000..85625ca --- /dev/null +++ b/docs/lab046c_1400mhz_offline_reprocessing.md @@ -0,0 +1,101 @@ +# Повторная обработка записи Lab046C на 1400 МГц + +## Границы работы + +Использована только сохранённая запись: + +`C:\Users\user\Documents\SDR_Rover_RF_Check\20260901_172749_1400MHz\rx_1400mhz.cs8` + +- размер: 38 400 000 байт; +- число комплексных отсчётов cs8: 19 200 000; +- частота дискретизации: 8 МГц; +- длительность: 2,4 с; +- SHA-256: `06ab0867865bca462a21c42c9f3bc7e519e336108f7b5e98e7b283f842387a99`; +- число запусков передатчика в этой работе: 0. + +HackRF не запускался. Порог `IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, FEC и PHY +не изменялись. Исходный аппаратный `CONTROL RESULT = C` не переписывается. + +## Правило первого каскада + +Правило зафиксировано до полного независимого декодирования. Использован только +фон до передачи, интервал `0,00 ... 0,50 с`: + +```text +candidate_threshold = +pre-TX local-peak Q99.9 + 4 * pre-TX local-peak MAD +``` + +Получено: + +- локальных максимумов: 5 538; +- медиана локальных максимумов: `0.0094285998493433`; +- MAD локальных максимумов: `0.0027075146790593863`; +- Q99: `0.019724003970623016`; +- Q99.9: `0.022788675501942635`; +- Q99.99: `0.023935016244649887`; +- максимум: `0.024017350748181343`; +- зафиксированный порог: `0.03361873421818018`. + +Фон после передачи, интервал `1,60 ... 2,40 с`, использован только как +независимая контрольная выборка: + +- локальных максимумов: 9 285; +- медиана локальных максимумов: `0.009831136092543602`; +- MAD локальных максимумов: `0.002742284908890724`; +- Q99.9: `0.023004598915576935`; +- Q99.99: `0.026167524978518486`; +- максимум: `0.02711654081940651`; +- кандидатов при зафиксированном пороге: 0. + +После просмотра результата порог не менялся. + +## Независимый результат + +Выполнен ровно один полный проход по всем 19 200 000 отсчётам. Приёмник не +получал смещения переданных кадров, их ожидаемое число, сигнал передатчика, +пакеты, полезную нагрузку или кодированные биты. + +- локальных максимумов во всей записи: 29 205; +- кандидатов первого каскада: 1 279; +- обработано строгим каскадом: 28; +- PHY-заголовок прошли: 3; +- PHY-заголовок не прошли: 25; +- попыток FEC: 3; +- FEC прошли: 3; +- FEC не прошли: 0; +- CRC прошли: 3; +- CRC не прошли: 0; +- действительных кадров: 3. + +Поиск дошёл до конца записи и до конца списка кандидатов. Ограничения числа +кандидатов, итераций и действительных кадров не сработали. Все 25 отклонённых +кандидатов получили `PHY_HEADER_MAGIC_MISMATCH`. Только `CRC PASS` добавлял кадр +в независимый результат. + +## Диагностика после независимого решения + +Независимый JSON был записан до открытия опорных файлов передатчика. После +этого выполнено сопоставление: + +- ожидаемых переданных кадров: 3; +- совпавших действительных кадров: 3; +- пропущенных кадров: 0; +- ложных `CRC PASS`: 0; +- ошибки положения начала: `0`, `-1`, `+2` отсчёта; +- ошибки кодированных битов до FEC: `17 + 19 + 29 = 65`; +- ошибки пакета после FEC: 0 бит; +- ошибки полезной нагрузки: 0 байт, 0 бит. + +Три полезные нагрузки по 256 байт формируют соответствующие переданные пакеты +по 268 байт. Добавления пропущенных кадров в независимый результат не было. + +## Вывод + +Повторная обработка записи на 1400 МГц получила `VALID FRAMES = 3` и +`CRC PASS = 3` без ложных срабатываний CRC. Это подтверждает исправность +физического радиочастотного тракта для данного короткого опыта. Оценка: +`RF PATH ASSESSMENT = HEALTHY`. + +Результат не является разрешением на новый RF, повтор Lab046C, JPEG, QPSK или +изменение FEC. `RF TX = 0`. diff --git a/experiments/lab046c_offline_recovery.py b/experiments/lab046c_offline_recovery.py index 25807bc..ac20644 100644 --- a/experiments/lab046c_offline_recovery.py +++ b/experiments/lab046c_offline_recovery.py @@ -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({ diff --git a/tests/test_lab046c_offline_recovery.py b/tests/test_lab046c_offline_recovery.py index 597b909..c1ddc72 100644 --- a/tests/test_lab046c_offline_recovery.py +++ b/tests/test_lab046c_offline_recovery.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect from pathlib import Path import struct +from types import SimpleNamespace import zlib import numpy as np @@ -128,6 +129,39 @@ def test_calibration_uses_only_fixed_local_peak_quantile(monkeypatch) -> None: assert statistics.candidate_count == int(np.count_nonzero(peaks >= rule.threshold)) +def test_1400mhz_threshold_formula_is_reproducible() -> None: + peaks = np.asarray([0.01, 0.02, 0.021, 0.022, 0.024], dtype=np.float32) + first = offline.candidate_threshold_from_local_peaks( + peaks, + quantile=offline.BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE, + mad_multiplier=offline.BACKGROUND_1400MHZ_MAD_MULTIPLIER, + ) + second = offline.candidate_threshold_from_local_peaks( + peaks.copy(), + quantile=offline.BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE, + mad_multiplier=offline.BACKGROUND_1400MHZ_MAD_MULTIPLIER, + ) + assert first == second + + +def test_1400mhz_control_background_creates_no_candidates(monkeypatch) -> None: + scores = np.asarray([0.001, 0.01, 0.02], dtype=np.float32) + peaks = np.asarray([0.02, 0.02711654081940651], dtype=np.float32) + monkeypatch.setattr( + offline, + "_background_peak_scores", + lambda path, interval: (scores, peaks, 0.1), + ) + rule = offline.CandidateRule( + "fixed before independent decoding", + 0.999, + offline.FIXED_1400MHZ_CANDIDATE_THRESHOLD, + ) + statistics = offline.evaluate_1400mhz_control_background(Path("unused"), rule) + assert statistics.candidate_count == 0 + assert max(peaks) < rule.threshold + + def test_control_background_cannot_change_fixed_rule(monkeypatch) -> None: peaks = np.asarray([0.01, 0.02, 0.20], dtype=np.float32) monkeypatch.setattr( @@ -214,6 +248,67 @@ def test_noise_candidates_never_become_valid_without_crc( assert result.counters.crc_pass == 0 +def test_inconsistent_accepted_flag_cannot_bypass_crc(tmp_path: Path) -> None: + capture = _capture_file(tmp_path / "inconsistent_accept.cs8") + candidate = offline.CoarseCandidate(10, 0.04) + + def inconsistent(raw, sample_count, row, number): + del raw, sample_count, number + return _candidate_outcome( + row, + accepted=True, + next_sample=20, + crc=offline.lab046c.lab045.CRC_FAIL, + ) + + result = offline.strict_process_candidates( + capture, + (candidate,), + decoder=inconsistent, + ) + assert result.counters.crc_pass == 0 + assert result.counters.crc_fail == 1 + assert result.counters.valid_frames == 0 + assert result.accepted_frames == () + + +def test_weak_peak_only_enters_first_stage_without_crc( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + capture = _capture_file(tmp_path / "weak_candidate.cs8") + + def blocks(raw, interval, chunk_samples): + del raw, interval, chunk_samples + scores = np.asarray([0.0, 0.04, 0.0], dtype=np.float32) + yield 0, scores, np.asarray([1]), 3, 100 + + monkeypatch.setattr(offline, "_score_blocks", blocks) + rule = offline.CandidateRule( + "fixed", + 0.999, + offline.FIXED_1400MHZ_CANDIDATE_THRESHOLD, + ) + coarse = offline.coarse_search_capture(capture, rule) + + def reject(raw, sample_count, candidate, number): + del raw, sample_count, number + return _candidate_outcome( + candidate, + accepted=False, + next_sample=candidate.sample_offset + 1, + ) + + strict = offline.strict_process_candidates( + capture, + coarse.candidates, + decoder=reject, + ) + assert len(coarse.candidates) == 1 + assert strict.counters.valid_frames == 0 + assert strict.counters.crc_pass == 0 + + @pytest.mark.parametrize( ("header", "reason"), [ @@ -404,6 +499,12 @@ def test_receiver_api_has_no_tx_or_expected_count_oracle() -> None: } assert parameters.isdisjoint(forbidden) + short_parameters = set( + inspect.signature(offline.run_1400mhz_independent_recovery).parameters + ) + assert short_parameters == {"capture_path"} + assert short_parameters.isdisjoint(forbidden) + def test_post_verdict_diagnostics_refuses_incomplete_verdict(tmp_path: Path) -> None: result = object.__new__(offline.IndependentRecoveryResult) @@ -412,6 +513,38 @@ def test_post_verdict_diagnostics_refuses_incomplete_verdict(tmp_path: Path) -> offline.diagnose_after_independent_verdict(result, tmp_path) +def test_1400mhz_missing_frames_report_error_counts_as_unavailable( + tmp_path: Path, +) -> None: + payload = bytes(256) + plan = offline.lab046c.lab045.build_transmission_plan(payload) + (tmp_path / "tx_payload_0.bin").write_bytes(payload) + (tmp_path / "tx_packet_0.bin").write_bytes(plan.packet) + independent = object.__new__(offline.IndependentRecoveryResult) + object.__setattr__(independent, "completed", True) + object.__setattr__( + independent, + "strict", + SimpleNamespace(accepted_frames=()), + ) + diagnostics = offline.diagnose_1400mhz_after_independent_verdict( + independent, + tmp_path, + ) + assert diagnostics.matched_valid_frames == 0 + assert diagnostics.missed_tx_frames == (0,) + assert diagnostics.total_pre_fec_coded_bit_errors is None + assert diagnostics.total_post_fec_protocol_bit_errors is None + assert diagnostics.total_payload_bit_errors is None + + +def test_1400mhz_main_persists_independent_result_before_tx_reference() -> None: + source = inspect.getsource(offline.main) + write_position = source.index("write_1400mhz_independent_result") + diagnostic_position = source.index("diagnose_1400mhz_after_independent_verdict") + assert write_position < diagnostic_position + + def test_safety_limited_independent_run_does_not_unlock_tx_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,