"""Lab047: подготовка передачи одного JPEG через транспорт Lab046. Текущий основной режим полностью программный: существующая фотография воспроизводимо преобразуется в небольшой JPEG, проходит фрагментацию, защищённые кадры Lab045, идеальный BPSK-канал и независимую сборку объекта. Аппаратная часть только подготовлена. Большой поток ``tx_stream.cs8`` можно сформировать отдельным режимом без RF. Единственный будущий аппаратный запуск закрыт тремя явными ключами, не содержит повторов и не вызывается из программного режима. Независимый анализ IQ не принимает TX-объект, смещения или ожидаемое число кадров. """ from __future__ import annotations import argparse from dataclasses import asdict, dataclass from datetime import datetime, timezone import hashlib import json import math from pathlib import Path import shutil import sys import time from types import SimpleNamespace from typing import Callable if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np from PIL import Image from experiments import lab044_hackrf_bpsk_packet as lab044 from experiments import lab045_hackrf_bpsk_fec as lab045 from experiments import lab046_bpsk_object_transport as lab046 from experiments import lab046b_continuous_iq as lab046b from experiments import lab046c_offline_recovery as offline SOURCE_IMAGE_PATH = Path("data/raw/lab011_scene.jpg") OUTPUT_DIRECTORY = Path("data/processed/lab047") TX_IMAGE_NAME = "tx_image.jpg" RECOVERED_IMAGE_NAME = "recovered_image.jpg" RESULT_JSON_NAME = "result.json" RESULT_TEXT_NAME = "result.txt" IMAGE_WIDTH = 320 IMAGE_HEIGHT = 240 IMAGE_MODE = "RGB" JPEG_QUALITY = 35 JPEG_SUBSAMPLING = 2 # 4:2:0 в Pillow JPEG_OPTIMIZE = False JPEG_PROGRESSIVE = False MIN_DEMO_JPEG_BYTES = 8 * 1024 MAX_DEMO_JPEG_BYTES = 15 * 1024 OBJECT_ID = 0x4C303437 # "L047" MASTER_SERIAL = lab044.lab043.MASTER_SERIAL SLAVE_SERIAL = lab044.lab043.SLAVE_SERIAL RF_HZ = 1_400_000_000 SAMPLE_RATE_HZ = 8_000_000 SYMBOL_RATE = 20_000 SAMPLES_PER_SYMBOL = 400 CARRIER_OFFSET_HZ = 50_000 TX_VGA_DB = 10 RX_LNA_DB = 24 RX_VGA_DB = 24 RX_CAPTURE_SECONDS = 15.0 RX_CAPTURE_SAMPLE_COUNT = int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ) RX_CAPTURE_FILE_BYTES = 2 * RX_CAPTURE_SAMPLE_COUNT RX_LEAD_SECONDS = 0.75 RX_WAIT_TIMEOUT_SECONDS = 25.0 CALIBRATION_INTERVAL = offline.SampleInterval( "PRE_TX_BACKGROUND_LAB047", 0, 4_000_000, ) CONTROL_INTERVAL = offline.SampleInterval( "POST_TX_BACKGROUND_LAB047", 96_000_000, RX_CAPTURE_SAMPLE_COUNT, ) BACKGROUND_LOCAL_PEAK_QUANTILE = 0.999 BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER = 4.0 FIXED_HARDWARE_CANDIDATE_THRESHOLD = 0.030964473262429237 REPROCESS_ATTEMPT_NAME = "independent_reprocess_attempt.json" REPROCESS_RESULT_NAME = "independent_reprocess_result.json" POST_VERDICT_DIAGNOSTICS_NAME = "post_verdict_diagnostics.json" CORRECTED_REPROCESS_RESULT_NAME = "independent_reprocess_result_corrected.json" RECEIVER_ORACLE_KEYS = { "tx_frame_offsets", "expected_frame_count", "tx_fragment_indexes", "tx_image", "tx_packets", "tx_coded_bits", } @dataclass(frozen=True) class JpegInformation: path: Path file_bytes: int width: int height: int mode: str format: str sha256: str @dataclass(frozen=True) class TransportAccounting: jpeg_file_bytes: int fragment_count: int rf_frame_count: int samples_per_frame: int tx_sample_count: int tx_file_bytes: int estimated_airtime_seconds: float useful_goodput_bits_per_second: float @dataclass(frozen=True) class SoftwareRoundtrip: recovered_bytes: bytes | None crc_pass: int crc_fail: int valid_fragments: int missing_fragments: tuple[int, ...] object_complete: bool tx_sha256: str recovered_sha256: str | None sha_match: bool byte_errors: int | None bit_errors: int | None @dataclass(frozen=True) class PreparedHardwareTransmission: output_directory: Path image_path: Path stream_path: Path metadata_path: Path image_sha256: str stream_sha256: str frame_count: int frame_samples: int stream_sample_count: int stream_file_bytes: int stream_duration_seconds: float def _sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with Path(path).open("rb") as stream: while block := stream.read(1024 * 1024): digest.update(block) return digest.hexdigest() def validate_jpeg(path: Path) -> JpegInformation: """Полностью проверить JPEG и вернуть его фактические свойства.""" path = Path(path) with Image.open(path) as image: image.verify() with Image.open(path) as image: image.load() information = JpegInformation( path=path, file_bytes=path.stat().st_size, width=image.width, height=image.height, mode=image.mode, format=str(image.format), sha256=_sha256_file(path), ) if information.format != "JPEG": raise ValueError(f"{path} не распознан как JPEG") return information def prepare_jpeg(source_path: Path, destination_path: Path) -> JpegInformation: """Создать JPEG с фиксированными параметрами, не изменяя оригинал.""" source_path = Path(source_path) destination_path = Path(destination_path) if source_path.resolve() == destination_path.resolve(): raise ValueError("Исходное изображение нельзя перезаписывать") with Image.open(source_path) as source: source.load() prepared = source.convert(IMAGE_MODE).resize( (IMAGE_WIDTH, IMAGE_HEIGHT), Image.Resampling.LANCZOS, ) destination_path.parent.mkdir(parents=True, exist_ok=True) prepared.save( destination_path, format="JPEG", quality=JPEG_QUALITY, subsampling=JPEG_SUBSAMPLING, optimize=JPEG_OPTIMIZE, progressive=JPEG_PROGRESSIVE, ) information = validate_jpeg(destination_path) if (information.width, information.height, information.mode) != ( IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_MODE, ): raise RuntimeError("Подготовленный JPEG имеет неверные свойства") return information def transport_accounting(data: bytes) -> TransportAccounting: """Рассчитать объём реального потока по фактическому размеру объекта.""" frames = lab046.build_object_frames(data, OBJECT_ID) frame_samples = frames[0].plan.waveform_sample_count if any(frame.plan.waveform_sample_count != frame_samples for frame in frames): raise RuntimeError("Кадры Lab045 имеют разные длины waveform") sample_count = sum(frame.plan.waveform_sample_count for frame in frames) airtime = sample_count / SAMPLE_RATE_HZ return TransportAccounting( jpeg_file_bytes=len(data), fragment_count=len(frames), rf_frame_count=len(frames), samples_per_frame=frame_samples, tx_sample_count=sample_count, tx_file_bytes=2 * sample_count, estimated_airtime_seconds=airtime, useful_goodput_bits_per_second=len(data) * 8 / airtime, ) def software_roundtrip_jpeg(data: bytes) -> SoftwareRoundtrip: """Пропустить JPEG через Lab046, Lab045 и идеальный BPSK-канал.""" frames = lab046.build_object_frames(data, OBJECT_ID) assembler = lab046.ObjectAssembler() crc_pass = 0 crc_fail = 0 for frame_number, frame in enumerate(frames): soft = lab046.ideal_channel(frame.plan, frame_number) reception = lab045.analyze_post_preamble_soft(soft) if reception.analysis["crc"] != lab045.CRC_PASS: crc_fail += 1 continue crc_pass += 1 fragment = ( lab046.recover_fragment_from_payload(reception.payload_bytes) if reception.payload_bytes is not None else None ) assembler.offer(fragment) recovered = assembler.recovered_object() recovered_sha = _sha256_bytes(recovered) if recovered is not None else None transmitted_sha = _sha256_bytes(data) byte_errors: int | None = None bit_errors: int | None = None if recovered is not None and len(recovered) == len(data): difference = np.bitwise_xor( np.frombuffer(data, dtype=np.uint8), np.frombuffer(recovered, dtype=np.uint8), ) byte_errors = int(np.count_nonzero(difference)) bit_errors = int(np.unpackbits(difference).sum()) return SoftwareRoundtrip( recovered_bytes=recovered, crc_pass=crc_pass, crc_fail=crc_fail, valid_fragments=assembler.counters.accepted, missing_fragments=tuple(assembler.missing_indexes()), object_complete=assembler.object_complete(), tx_sha256=transmitted_sha, recovered_sha256=recovered_sha, sha_match=recovered is not None and recovered_sha == transmitted_sha, byte_errors=byte_errors, bit_errors=bit_errors, ) def hardware_parameters() -> dict: return { "master_tx_serial": MASTER_SERIAL, "slave_rx_serial": SLAVE_SERIAL, "frequency_hz": RF_HZ, "sample_rate_hz": SAMPLE_RATE_HZ, "symbol_rate": SYMBOL_RATE, "samples_per_symbol": SAMPLES_PER_SYMBOL, "digital_carrier_hz": CARRIER_OFFSET_HZ, "tx_vga_db": TX_VGA_DB, "tx_rf_amp": False, "tx_bias_tee": False, "rx_lna_db": RX_LNA_DB, "rx_vga_db": RX_VGA_DB, "rx_rf_amp": False, "rx_bias_tee": False, "master_clkout_to_slave_clkin": True, "slave_clkout": False, "expected_reg29": lab044.lab043.REG29_EXTERNAL_CLOCK, } def receiver_oracle_declaration() -> dict[str, bool]: return {key: False for key in sorted(RECEIVER_ORACLE_KEYS)} def _validate_fixed_settings() -> None: actual_phy = ( lab045.SAMPLE_RATE_HZ, lab045.SYMBOL_RATE, lab045.SAMPLES_PER_SYMBOL, lab045.CARRIER_OFFSET_HZ, lab045.TX_VGA_DB, lab045.RX_LNA_DB, lab045.RX_VGA_DB, ) expected_phy = ( SAMPLE_RATE_HZ, SYMBOL_RATE, SAMPLES_PER_SYMBOL, CARRIER_OFFSET_HZ, TX_VGA_DB, RX_LNA_DB, RX_VGA_DB, ) if actual_phy != expected_phy: raise RuntimeError(f"Параметры Lab045 {actual_phy} не совпали с {expected_phy}") if lab046b.IQ_PREAMBLE_THRESHOLD != 0.65: raise RuntimeError("Исторический строгий порог 0.65 был изменён") if RX_CAPTURE_SAMPLE_COUNT != int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ): raise RuntimeError("Расчёт длительности RX не совпал") if CONTROL_INTERVAL.end_sample != RX_CAPTURE_SAMPLE_COUNT: raise RuntimeError("Контрольный фон не заканчивается вместе с RX") def run_software_preparation( source_path: Path = SOURCE_IMAGE_PATH, output_directory: Path = OUTPUT_DIRECTORY, ) -> dict: """Создать малые демонстрационные артефакты Lab047 без RF.""" _validate_fixed_settings() output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) tx_path = output_directory / TX_IMAGE_NAME recovered_path = output_directory / RECOVERED_IMAGE_NAME jpeg = prepare_jpeg(source_path, tx_path) if not MIN_DEMO_JPEG_BYTES <= jpeg.file_bytes <= MAX_DEMO_JPEG_BYTES: raise RuntimeError( f"Фиксированная подготовка дала {jpeg.file_bytes} байт вне 8-15 KiB" ) transmitted = tx_path.read_bytes() accounting = transport_accounting(transmitted) roundtrip = software_roundtrip_jpeg(transmitted) if not roundtrip.object_complete or roundtrip.recovered_bytes is None: raise RuntimeError("Программная сборка JPEG не завершилась") recovered_path.write_bytes(roundtrip.recovered_bytes) recovered_jpeg = validate_jpeg(recovered_path) image_decode_pass = ( recovered_jpeg.width == jpeg.width and recovered_jpeg.height == jpeg.height and recovered_jpeg.mode == jpeg.mode and recovered_jpeg.format == jpeg.format ) if not ( roundtrip.sha_match and roundtrip.byte_errors == 0 and roundtrip.bit_errors == 0 and image_decode_pass ): raise RuntimeError("Программный сквозной проход JPEG не прошёл") result = { "stage": "LAB047_SOFTWARE_PREPARATION", "source": str(Path(source_path)), "jpeg": { **asdict(jpeg), "path": str(jpeg.path), "quality": JPEG_QUALITY, "subsampling": "4:2:0", "optimize": JPEG_OPTIMIZE, "progressive": JPEG_PROGRESSIVE, }, "transport": asdict(accounting), "software_roundtrip": { key: value for key, value in asdict(roundtrip).items() if key != "recovered_bytes" }, "software_image_decode": "PASS" if image_decode_pass else "FAIL", "recovered_jpeg": { **asdict(recovered_jpeg), "path": str(recovered_jpeg.path), }, "planned_rx": { "duration_seconds": RX_CAPTURE_SECONDS, "sample_count": RX_CAPTURE_SAMPLE_COUNT, "file_bytes": RX_CAPTURE_FILE_BYTES, "lead_seconds": RX_LEAD_SECONDS, }, "hardware": hardware_parameters(), "candidate_rule": { "method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD", "quantile": BACKGROUND_LOCAL_PEAK_QUANTILE, "mad_multiplier": BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER, "fixed_before_full_decode": True, }, "receiver_oracles": receiver_oracle_declaration(), "rf_tx": 0, } result_path = output_directory / RESULT_JSON_NAME result_path.write_text( json.dumps(result, ensure_ascii=False, indent=2, default=str), encoding="utf-8", ) text_path = output_directory / RESULT_TEXT_NAME text_path.write_text( "\n".join( ( "LAB047 SOFTWARE PREPARATION = PASS", f"JPEG FILE = {tx_path}", f"JPEG SIZE = {jpeg.file_bytes}", f"IMAGE DIMENSIONS = {jpeg.width} x {jpeg.height}", f"JPEG SHA-256 = {jpeg.sha256}", f"FRAGMENTS = {accounting.fragment_count}", f"RF FRAMES = {accounting.rf_frame_count}", f"ESTIMATED RF AIRTIME = {accounting.estimated_airtime_seconds:.6f} s", "SOFTWARE ROUNDTRIP = PASS", "SOFTWARE SHA MATCH = YES", "SOFTWARE JPEG DECODE = PASS", "RF TX = 0", ) ) + "\n", encoding="utf-8", ) return result def default_hardware_directory() -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return Path.home() / "Documents" / "SDR_Rover_Lab047_raw" / timestamp def prepare_hardware_transmission( output_directory: Path, tx_image_path: Path = OUTPUT_DIRECTORY / TX_IMAGE_NAME, ) -> PreparedHardwareTransmission: """Создать будущий TX-поток без RF и без удержания всего потока в памяти.""" _validate_fixed_settings() output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=False) image_path = output_directory / TX_IMAGE_NAME stream_path = output_directory / "tx_stream.cs8" metadata_path = output_directory / "tx_metadata.json" shutil.copyfile(tx_image_path, image_path) image = validate_jpeg(image_path) if not MIN_DEMO_JPEG_BYTES <= image.file_bytes <= MAX_DEMO_JPEG_BYTES: raise RuntimeError("Аппаратный JPEG находится вне зафиксированного диапазона 8-15 KiB") if (image.width, image.height, image.mode) != (IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_MODE): raise RuntimeError("Аппаратный JPEG имеет незапланированные свойства") data = image_path.read_bytes() frames = lab046.build_object_frames(data, OBJECT_ID) frame_samples = frames[0].plan.waveform_sample_count stream_sample_count = sum(frame.plan.waveform_sample_count for frame in frames) stream_duration_seconds = stream_sample_count / SAMPLE_RATE_HZ control_start_seconds = CONTROL_INTERVAL.start_sample / SAMPLE_RATE_HZ if RX_LEAD_SECONDS + stream_duration_seconds >= control_start_seconds: raise RuntimeError("TX не оставляет защитного интервала до контрольного фона") stream_digest_builder = hashlib.sha256() stream_file_bytes = 0 with stream_path.open("xb") as stream: for frame in frames: waveform = lab045.build_tx_cs8(frame.plan) if len(waveform) != 2 * frame.plan.waveform_sample_count: raise RuntimeError("Форма Lab045 не прошла учёт cs8") stream.write(waveform) stream_digest_builder.update(waveform) stream_file_bytes += len(waveform) if stream_file_bytes != 2 * stream_sample_count: raise RuntimeError("Размер TX-потока не совпал с числом отсчётов") stream_sha = stream_digest_builder.hexdigest() if _sha256_file(stream_path) != stream_sha: raise RuntimeError("Контрольная сумма TX-потока не воспроизвелась") metadata = { "created_utc": datetime.now(timezone.utc).isoformat(), "stage": "LAB047_PREPARATION_ONLY", "rf_tx_count": 0, "jpeg": { **asdict(image), "path": str(image_path), "object_id": OBJECT_ID, }, "fragmentation": { "fragment_data_bytes": lab046.FRAGMENT_DATA_SIZE, "fragment_count": len(frames), "last_fragment_data_bytes": ( frames[-1].encoded_payload_length - lab046.fragments.FRAGMENT_HEADER_SIZE ), }, "tx_stream": { "path": str(stream_path), "format": "cs8 interleaved signed I/Q", "frame_count": len(frames), "samples_per_frame": frame_samples, "sample_count": stream_sample_count, "file_bytes": stream_file_bytes, "duration_seconds": stream_duration_seconds, "sha256": stream_sha, "inter_frame_samples_added": 0, }, "planned_rx": { "duration_seconds": RX_CAPTURE_SECONDS, "sample_count": RX_CAPTURE_SAMPLE_COUNT, "file_bytes": RX_CAPTURE_FILE_BYTES, "lead_seconds": RX_LEAD_SECONDS, }, "hardware": hardware_parameters(), "receiver_oracles": receiver_oracle_declaration(), } metadata_path.write_text( json.dumps(metadata, ensure_ascii=False, indent=2, default=str), encoding="utf-8", ) return PreparedHardwareTransmission( output_directory=output_directory, image_path=image_path, stream_path=stream_path, metadata_path=metadata_path, image_sha256=image.sha256, stream_sha256=stream_sha, frame_count=len(frames), frame_samples=frame_samples, stream_sample_count=stream_sample_count, stream_file_bytes=stream_file_bytes, stream_duration_seconds=stream_duration_seconds, ) def _load_and_validate_prepared( output_directory: Path, ) -> PreparedHardwareTransmission: output_directory = Path(output_directory) metadata_path = output_directory / "tx_metadata.json" metadata = json.loads(metadata_path.read_text(encoding="utf-8")) image_path = output_directory / TX_IMAGE_NAME stream_path = output_directory / "tx_stream.cs8" if metadata.get("stage") != "LAB047_PREPARATION_ONLY": raise RuntimeError("Неверный этап в метаданных Lab047") if metadata.get("rf_tx_count") != 0: raise RuntimeError("Метаданные уже объявляют RF TX") if metadata.get("hardware") != hardware_parameters(): raise RuntimeError("Аппаратные параметры изменены") if metadata.get("receiver_oracles") != receiver_oracle_declaration(): raise RuntimeError("В метаданных разрешён оракул приёмника") image = validate_jpeg(image_path) if not MIN_DEMO_JPEG_BYTES <= image.file_bytes <= MAX_DEMO_JPEG_BYTES: raise RuntimeError("Подготовленный аппаратный JPEG вне диапазона 8-15 KiB") if (image.width, image.height, image.mode) != (IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_MODE): raise RuntimeError("Подготовленный аппаратный JPEG имеет неверные свойства") data = image_path.read_bytes() frames = lab046.build_object_frames(data, OBJECT_ID) stream = metadata["tx_stream"] expected_samples = sum(frame.plan.waveform_sample_count for frame in frames) expected_bytes = 2 * expected_samples expected_duration = expected_samples / SAMPLE_RATE_HZ if RX_LEAD_SECONDS + expected_duration >= CONTROL_INTERVAL.start_sample / SAMPLE_RATE_HZ: raise RuntimeError("Подготовленный TX перекрывает контрольный фон") exact = ( int(metadata["jpeg"]["object_id"]) == OBJECT_ID and metadata["jpeg"]["sha256"] == image.sha256 and int(metadata["fragmentation"]["fragment_count"]) == len(frames) and int(stream["frame_count"]) == len(frames) and int(stream["samples_per_frame"]) == frames[0].plan.waveform_sample_count and int(stream["sample_count"]) == expected_samples and int(stream["file_bytes"]) == expected_bytes and int(stream["inter_frame_samples_added"]) == 0 and stream_path.stat().st_size == expected_bytes and _sha256_file(stream_path) == stream["sha256"] ) if not exact: raise RuntimeError("Подготовленные артефакты Lab047 не прошли точную проверку") return PreparedHardwareTransmission( output_directory=output_directory, image_path=image_path, stream_path=stream_path, metadata_path=metadata_path, image_sha256=image.sha256, stream_sha256=str(stream["sha256"]), frame_count=len(frames), frame_samples=frames[0].plan.waveform_sample_count, stream_sample_count=expected_samples, stream_file_bytes=expected_bytes, stream_duration_seconds=expected_duration, ) def run_hardware_capture_once( output_directory: Path, *, authorization: bool = False, runner: lab044.lab043.CommandRunner | None = None, process_starter: lab044.lab043.ProcessStarter | None = None, sleep_function: lab044.lab043.SleepFunction = time.sleep, process_checker: Callable[[], list[str]] = lab045._running_hackrf_transfer_processes, ) -> dict: """Выполнить ровно один будущий TX и RX без автоматического повтора.""" if not authorization: raise PermissionError("Нет отдельного разрешения на один JPEG RF TX") _validate_fixed_settings() runner = runner or lab044.lab043._default_command_runner process_starter = process_starter or lab044.lab043._default_process_starter output_directory = Path(output_directory) prepared = _load_and_validate_prepared(output_directory) attempt_path = output_directory / "hardware_attempt.json" with attempt_path.open("x", encoding="utf-8") as stream: json.dump( { "started_utc": datetime.now(timezone.utc).isoformat(), "authorized_once": True, "automatic_retry": False, }, stream, ensure_ascii=False, indent=2, ) guard = lab044.lab043.TransmitGuard() rx_process: lab044.lab043.RunningCommand | None = None cleanup: dict = {} result: dict | None = None try: if process_checker(): raise RuntimeError("До Lab047 уже запущен hackrf_transfer") inventory = lab045._verify_device_inventory(runner) preflight = lab044.lab043.common_clock_preflight(runner, guard) transfer_tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe") rx_path = output_directory / "rx_stream.cs8" rx_stdout_path = output_directory / "rx_stdout.txt" rx_stderr_path = output_directory / "rx_stderr.txt" tx_stdout_path = output_directory / "tx_stdout.txt" tx_stderr_path = output_directory / "tx_stderr.txt" rx_command = [ transfer_tool, "-d", SLAVE_SERIAL, "-r", str(rx_path), "-f", str(RF_HZ), "-s", str(SAMPLE_RATE_HZ), "-n", str(RX_CAPTURE_SAMPLE_COUNT), "-l", str(RX_LNA_DB), "-g", str(RX_VGA_DB), "-a", "0", "-p", "0", "-B", ] rx_process = process_starter(rx_command, rx_stdout_path, rx_stderr_path) sleep_function(RX_LEAD_SECONDS) if rx_process.poll() is not None: rx_process.close_logs() detail = rx_stderr_path.read_text(encoding="utf-8", errors="replace") raise RuntimeError(f"RX завершился до TX: {detail.strip()}") tx_command = [ transfer_tool, "-d", MASTER_SERIAL, "-t", str(prepared.stream_path), "-f", str(RF_HZ), "-s", str(SAMPLE_RATE_HZ), "-n", str(prepared.stream_sample_count), "-x", str(TX_VGA_DB), "-a", "0", "-p", "0", "-B", ] guard.register_transmit() tx_result = runner(tx_command) tx_stdout_path.write_text(tx_result.stdout or "", encoding="utf-8") tx_stderr_path.write_text(tx_result.stderr or "", encoding="utf-8") if tx_result.returncode != 0: raise RuntimeError(f"Единственный TX завершился с кодом {tx_result.returncode}") if rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) != 0: raise RuntimeError("RX завершился с ошибкой") if rx_path.stat().st_size != RX_CAPTURE_FILE_BYTES: raise RuntimeError("RX-файл имеет неверный размер") rx_log = rx_stdout_path.read_text(encoding="utf-8", errors="replace") + rx_stderr_path.read_text( encoding="utf-8", errors="replace" ) tx_log = (tx_result.stdout or "") + (tx_result.stderr or "") if not lab044.lab043._transfer_log_is_clean(rx_log): raise RuntimeError("RX-журнал содержит overrun/underrun") if not lab044.lab043._transfer_log_is_clean(tx_log): raise RuntimeError("TX-журнал содержит overrun/underrun") result = { "stage": "LAB047_CAPTURE_COMPLETE_ANALYSIS_NOT_STARTED", "rf_tx_count": guard.transmit_calls, "automatic_retry": False, "inventory": inventory, "preflight": preflight, "rx_path": str(rx_path), "rx_sha256": _sha256_file(rx_path), "rx_samples": RX_CAPTURE_SAMPLE_COUNT, "rx_duration_seconds": RX_CAPTURE_SECONDS, "tx_frames": prepared.frame_count, "tx_duration_seconds": prepared.stream_duration_seconds, } finally: if rx_process is not None: try: rx_process.terminate() except Exception as error: cleanup.setdefault("rx_process_errors", []).append(str(error)) cleanup["clock_output_errors"] = lab044.lab043._disable_clock_outputs(runner) try: cleanup["lingering_hackrf_transfer"] = process_checker() except Exception as error: cleanup["process_check_error"] = str(error) if result is None: raise RuntimeError("Lab047 завершилась до результата; повтор запрещён") result["cleanup"] = cleanup result["cleanup_passed"] = bool( not cleanup.get("clock_output_errors") and cleanup.get("lingering_hackrf_transfer") == [] and "process_check_error" not in cleanup ) (output_directory / "capture_result.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8", ) return result def calibrate_dynamic_candidate_rule( capture_path: Path, interval: offline.SampleInterval = CALIBRATION_INTERVAL, ) -> tuple[offline.CandidateRule, offline.BackgroundStatistics]: """Вычислить Q99.9 + 4 MAD только по фону до передачи.""" scores, peaks, elapsed = offline._background_peak_scores(capture_path, interval) threshold = offline.candidate_threshold_from_local_peaks( peaks, quantile=BACKGROUND_LOCAL_PEAK_QUANTILE, mad_multiplier=BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER, ) rule = offline.CandidateRule( method="pre-TX local-peak Q99.9 + 4 * local-peak MAD", local_peak_quantile=BACKGROUND_LOCAL_PEAK_QUANTILE, threshold=threshold, ) statistics = offline._background_statistics( interval, scores, peaks, threshold, elapsed, ) return rule, statistics def run_independent_capture_recovery( capture_path: Path, output_directory: Path, ) -> offline.IndependentRecoveryResult: """Один полный RX-only проход без сведений передатчика.""" capture_path = Path(capture_path) output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) result_path = output_directory / "independent_result.json" attempt_path = output_directory / "independent_attempt.json" if result_path.exists() or attempt_path.exists(): raise FileExistsError("Независимый проход уже запускался; второй проход запрещён") offline._validate_capture(capture_path) with attempt_path.open("x", encoding="utf-8") as stream: json.dump( { "started_utc": datetime.now(timezone.utc).isoformat(), "capture_path": str(capture_path), "candidate_rule": "pre-TX local-peak Q99.9 + 4 * local-peak MAD", "automatic_retry": False, "receiver_oracles": receiver_oracle_declaration(), }, stream, ensure_ascii=False, indent=2, ) started = time.perf_counter() rule, calibration = calibrate_dynamic_candidate_rule(capture_path) control = offline.evaluate_control_background( capture_path, rule, interval=CONTROL_INTERVAL, ) independent = offline._run_with_fixed_candidate_rule( capture_path, rule, calibration, control, full_started=started, ) result_path.write_text( json.dumps(offline._independent_summary(independent), ensure_ascii=False, indent=2), encoding="utf-8", ) if independent.object_verdict.object_complete: recovered = independent.object_verdict.recovered_object if recovered is None: raise RuntimeError("OBJECT COMPLETE без восстановленных байтов") recovered_path = output_directory / RECOVERED_IMAGE_NAME recovered_path.write_bytes(recovered) validate_jpeg(recovered_path) return independent def _background_statistics_from_json(value: dict) -> offline.BackgroundStatistics: interval = value["interval"] return offline.BackgroundStatistics( interval=offline.SampleInterval( name=str(interval["name"]), start_sample=int(interval["start_sample"]), end_sample=int(interval["end_sample"]), ), correlation_score_count=int(value["correlation_score_count"]), local_peak_count=int(value["local_peak_count"]), correlation_median=float(value["correlation_median"]), correlation_mad=float(value["correlation_mad"]), local_peak_quantiles={ str(key): float(number) for key, number in value["local_peak_quantiles"].items() }, candidate_count=int(value["candidate_count"]), candidates_per_second=float(value["candidates_per_second"]), elapsed_seconds=float(value["elapsed_seconds"]), local_peak_median=float(value["local_peak_median"]), local_peak_mad=float(value["local_peak_mad"]), ) def load_fixed_hardware_candidate_rule( capture_path: Path, original_result_path: Path, ) -> tuple[ offline.CandidateRule, offline.BackgroundStatistics, offline.BackgroundStatistics, ]: """Загрузить уже зафиксированный порог без повторной калибровки.""" capture_path = Path(capture_path) original = json.loads(Path(original_result_path).read_text(encoding="utf-8")) if Path(original["capture_path"]).resolve() != capture_path.resolve(): raise RuntimeError("Первоначальный результат относится к другому RX capture") if original.get("completed") is not False: raise RuntimeError("Ожидался первоначальный незавершённый результат") coarse = original.get("coarse", {}) if ( coarse.get("termination_reason") != "MAX_CANDIDATES_REACHED" or coarse.get("max_candidates_reached") is not True ): raise RuntimeError("Первоначальная остановка не подтверждает MAX_CANDIDATES_REACHED") rule_value = original["rule"] rule = offline.CandidateRule( method=str(rule_value["method"]), local_peak_quantile=float(rule_value["local_peak_quantile"]), threshold=float(rule_value["threshold"]), ) calibration = _background_statistics_from_json(original["calibration"]) control = _background_statistics_from_json(original["control"]) reproduced = ( calibration.local_peak_quantiles["0.999"] + BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER * calibration.local_peak_mad ) if not math.isclose( rule.threshold, FIXED_HARDWARE_CANDIDATE_THRESHOLD, rel_tol=0.0, abs_tol=1e-15, ): raise RuntimeError("Зафиксированный аппаратный threshold был изменён") if not math.isclose(reproduced, rule.threshold, rel_tol=0.0, abs_tol=1e-15): raise RuntimeError("Q99.9 + 4 MAD не воспроизводит зафиксированный threshold") if calibration.interval != CALIBRATION_INTERVAL or control.interval != CONTROL_INTERVAL: raise RuntimeError("Фоновые интервалы аппаратного результата были изменены") if control.candidate_count != 0: raise RuntimeError("Первоначальный контрольный фон содержал кандидаты") return rule, calibration, control def run_saved_capture_streaming_recovery( capture_path: Path, original_result_path: Path, output_directory: Path, ) -> offline.IndependentRecoveryResult: """Ровно один потоковый повторный анализ сохранённого RX без TX-оракулов.""" capture_path = Path(capture_path) output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) attempt_path = output_directory / REPROCESS_ATTEMPT_NAME result_path = output_directory / REPROCESS_RESULT_NAME if attempt_path.exists() or result_path.exists(): raise FileExistsError("Повторная автономная обработка уже запускалась") offline._validate_capture(capture_path) rule, calibration, control = load_fixed_hardware_candidate_rule( capture_path, original_result_path, ) with attempt_path.open("x", encoding="utf-8") as stream: json.dump( { "started_utc": datetime.now(timezone.utc).isoformat(), "capture_path": str(capture_path), "original_result_path": str(original_result_path), "threshold": rule.threshold, "threshold_changed": False, "streaming_candidate_processing": True, "max_candidates_processed": offline.MAX_CANDIDATES, "automatic_retry": False, "receiver_oracles": receiver_oracle_declaration(), "rf_tx": 0, }, stream, ensure_ascii=False, indent=2, ) started = time.perf_counter() independent = offline._run_with_fixed_candidate_rule( capture_path, rule, calibration, control, full_started=started, ) result_path.write_text( json.dumps(offline._independent_summary(independent), ensure_ascii=False, indent=2), encoding="utf-8", ) if independent.object_verdict.object_complete: recovered = independent.object_verdict.recovered_object if recovered is None: raise RuntimeError("OBJECT COMPLETE без восстановленных байтов") recovered_path = output_directory / RECOVERED_IMAGE_NAME if recovered_path.exists(): raise FileExistsError("Восстановленный JPEG уже существует") recovered_path.write_bytes(recovered) validate_jpeg(recovered_path) return independent def diagnose_hardware_jpeg_after_independent_verdict( independent: offline.IndependentRecoveryResult, tx_image_path: Path, recovered_image_path: Path, ) -> dict: """Сопоставить RX с TX только после полного независимого вердикта.""" if not independent.completed: raise RuntimeError("Незавершённый независимый verdict не открывает TX reference") transmitted = Path(tx_image_path).read_bytes() tx_frames = { frame.fragment_index: frame for frame in lab046.build_object_frames(transmitted, OBJECT_ID) } matched_indexes: set[int] = set() duplicate_valid_detections = 0 false_crc_pass = 0 total_pre_fec_errors = 0 total_post_fec_errors = 0 for frame in independent.strict.accepted_frames: payload = frame.reception.payload_bytes fragment = ( lab046.recover_fragment_from_payload(payload) if payload is not None else None ) if fragment is None or fragment.image_id != OBJECT_ID: false_crc_pass += 1 continue expected = tx_frames.get(fragment.fragment_index) if expected is None or frame.reception.packet_bytes != expected.plan.packet: false_crc_pass += 1 continue if fragment.fragment_index in matched_indexes: duplicate_valid_detections += 1 continue matched_indexes.add(fragment.fragment_index) comparison = lab045.diagnostic_pre_and_post_fec( frame.reception, frame.post_preamble_soft, expected.plan, ) pre_fec = comparison["pre_fec_coded"]["bit_errors"] post_fec = comparison["post_fec_packet"]["bit_errors"] if pre_fec is not None: total_pre_fec_errors += int(pre_fec) if post_fec is not None: total_post_fec_errors += int(post_fec) recovered = independent.object_verdict.recovered_object recovered_sha: str | None = None byte_errors: int | None = None bit_errors: int | None = None jpeg_decode = "NOT_AVAILABLE" width: int | None = None height: int | None = None if recovered is not None: recovered_sha = _sha256_bytes(recovered) if len(recovered) == len(transmitted): difference = np.bitwise_xor( np.frombuffer(transmitted, dtype=np.uint8), np.frombuffer(recovered, dtype=np.uint8), ) byte_errors = int(np.count_nonzero(difference)) bit_errors = int(np.unpackbits(difference).sum()) recovered_jpeg = validate_jpeg(recovered_image_path) jpeg_decode = "PASS" width = recovered_jpeg.width height = recovered_jpeg.height return { "stage": "AFTER_COMPLETED_INDEPENDENT_VERDICT_ONLY", "expected_tx_frames": len(tx_frames), "matched_valid_frames": len(matched_indexes), "missed_tx_frames": sorted(set(tx_frames) - matched_indexes), "false_crc_pass": false_crc_pass, "duplicate_valid_detections": duplicate_valid_detections, "total_pre_fec_coded_bit_errors": total_pre_fec_errors, "total_post_fec_protocol_bit_errors": total_post_fec_errors, "jpeg_size": len(recovered) if recovered is not None else None, "recovered_sha256": recovered_sha, "tx_sha256": _sha256_bytes(transmitted), "sha_match": recovered is not None and recovered_sha == _sha256_bytes(transmitted), "byte_errors": byte_errors, "bit_errors": bit_errors, "jpeg_decode": jpeg_decode, "width": width, "height": height, } def _expected_streaming_block_count(sample_count: int) -> int: reference_samples = lab044._preamble_reference_samples().size overlap = offline._aligned_overlap(reference_samples) step = offline.DETECTION_CHUNK_SAMPLES - overlap blocks = 0 for window_start in range(0, sample_count, step): blocks += 1 if min(sample_count, window_start + offline.DETECTION_CHUNK_SAMPLES) == sample_count: break return blocks def correct_saved_streaming_completion( original_reprocess_result_path: Path, corrected_result_path: Path, capture_path: Path, ) -> dict: """Исправить только ошибочную границу покрытия без нового RX-прохода.""" original_path = Path(original_reprocess_result_path) corrected_path = Path(corrected_result_path) capture_path = Path(capture_path) value = json.loads(original_path.read_text(encoding="utf-8")) _, sample_count = offline._validate_capture(capture_path) saved_capture_path = Path(value.get("capture_path", "")) try: same_capture = saved_capture_path.is_file() and saved_capture_path.samefile(capture_path) except OSError: same_capture = False coarse = value["coarse"] strict = value["strict"] safe_to_correct = bool( value.get("completed") is False and same_capture and int(value["capture_samples"]) == sample_count and float(value["rule"]["threshold"]) == FIXED_HARDWARE_CANDIDATE_THRESHOLD and coarse.get("termination_reason") == "END_OF_CAPTURE" and coarse.get("max_candidates_reached") is False and int(coarse.get("blocks_processed", -1)) == _expected_streaming_block_count(sample_count) and strict.get("termination_reason") == "END_OF_CAPTURE" and strict.get("max_search_iterations_reached") is False and strict.get("max_valid_frames_reached") is False ) if not safe_to_correct: raise RuntimeError("Результат не доказывает полное покрытие без повторного прохода") original_sha = _sha256_file(original_path) original_processed_until = int(coarse["processed_until_sample"]) coarse["processed_until_sample"] = sample_count value["completed"] = True value["completion_metadata_correction"] = { "reason": ( "last valid correlation start was mistaken for the end of owned IQ; " "all expected score blocks were exhausted" ), "original_result_path": str(original_path), "original_result_sha256": original_sha, "original_completed": False, "corrected_completed": True, "original_processed_until_sample": original_processed_until, "corrected_processed_until_sample": sample_count, "full_decoder_rerun": False, "threshold_changed": False, } with corrected_path.open("x", encoding="utf-8") as stream: json.dump(value, stream, ensure_ascii=False, indent=2) return value def diagnose_saved_reprocess_after_corrected_verdict( corrected_result_path: Path, capture_path: Path, recovered_image_path: Path, tx_image_path: Path, ) -> dict: """Повторно извлечь только принятые RX-кадры и затем сравнить с TX.""" verdict = json.loads(Path(corrected_result_path).read_text(encoding="utf-8")) if ( verdict.get("completed") is not True or verdict.get("object_verdict", {}).get("object_complete") is not True or verdict.get("completion_metadata_correction", {}).get("full_decoder_rerun") is not False or verdict.get("completion_metadata_correction", {}).get("threshold_changed") is not False or float(verdict.get("rule", {}).get("threshold", math.nan)) != FIXED_HARDWARE_CANDIDATE_THRESHOLD ): raise RuntimeError("Исправленный полный verdict не подтверждён") accepted_rows = [ row for row in verdict["strict"]["outcomes"] if row.get("accepted") and row.get("crc") == lab045.CRC_PASS ] if len(accepted_rows) != int(verdict["strict"]["counters"]["valid_frames"]): raise RuntimeError("Сохранённые принятые outcomes неполны") raw, sample_count = offline._validate_capture(capture_path) diagnostic_frames: list[lab046b.IqFrameReception] = [] diagnostic_started = time.perf_counter() for number, row in enumerate(accepted_rows, start=1): candidate = offline.CoarseCandidate( sample_offset=int(row["candidate_offset"]), score=float("nan"), ) outcome = offline.decode_candidate_from_memmap( raw, sample_count, candidate, number, ) if not ( outcome.accepted and outcome.crc == lab045.CRC_PASS and outcome.frame is not None ): raise RuntimeError("Диагностическое повторное извлечение принятого кадра не совпало") diagnostic_frames.append(outcome.frame) diagnostic_decode_seconds = time.perf_counter() - diagnostic_started recovered = Path(recovered_image_path).read_bytes() independent_view = SimpleNamespace( completed=True, strict=SimpleNamespace(accepted_frames=tuple(diagnostic_frames)), object_verdict=SimpleNamespace(recovered_object=recovered), ) diagnostics = diagnose_hardware_jpeg_after_independent_verdict( independent_view, tx_image_path, recovered_image_path, ) diagnostics["accepted_candidates_diagnostically_redecoded"] = len(diagnostic_frames) diagnostics["diagnostic_redecode_seconds"] = diagnostic_decode_seconds diagnostics["full_candidate_search_repeated"] = False return diagnostics def diagnose_jpeg_after_independent_verdict( independent_result_path: Path, recovered_image_path: Path, tx_image_path: Path, ) -> dict: """Сравнить JPEG с TX только после сохранённого независимого вердикта.""" verdict = json.loads(Path(independent_result_path).read_text(encoding="utf-8")) object_verdict = verdict.get("object_verdict", {}) if not verdict.get("completed") or not object_verdict.get("object_complete"): raise RuntimeError("Независимый полный вердикт не разрешает открывать TX reference") recovered_path = Path(recovered_image_path) tx_path = Path(tx_image_path) recovered = recovered_path.read_bytes() transmitted = tx_path.read_bytes() byte_errors: int | None = None bit_errors: int | None = None if len(recovered) == len(transmitted): difference = np.bitwise_xor( np.frombuffer(transmitted, dtype=np.uint8), np.frombuffer(recovered, dtype=np.uint8), ) byte_errors = int(np.count_nonzero(difference)) bit_errors = int(np.unpackbits(difference).sum()) recovered_jpeg = validate_jpeg(recovered_path) tx_jpeg = validate_jpeg(tx_path) return { "stage": "AFTER_INDEPENDENT_VERDICT_ONLY", "tx_bytes": len(transmitted), "recovered_bytes": len(recovered), "tx_sha256": _sha256_bytes(transmitted), "recovered_sha256": _sha256_bytes(recovered), "sha_match": _sha256_bytes(transmitted) == _sha256_bytes(recovered), "byte_errors": byte_errors, "bit_errors": bit_errors, "image_decode": "PASS", "image_dimensions_match": ( tx_jpeg.width == recovered_jpeg.width and tx_jpeg.height == recovered_jpeg.height ), } def run_functional_tests() -> None: _validate_fixed_settings() accounting = transport_accounting(bytes(245)) assert accounting.fragment_count == 2 assert accounting.rf_frame_count == 2 assert accounting.tx_file_bytes == 2 * accounting.tx_sample_count assert receiver_oracle_declaration() == { key: False for key in sorted(RECEIVER_ORACLE_KEYS) } assert CALIBRATION_INTERVAL.end_sample / SAMPLE_RATE_HZ < RX_LEAD_SECONDS assert CONTROL_INTERVAL.start_sample / SAMPLE_RATE_HZ > ( RX_LEAD_SECONDS + 36 * 2_306_400 / SAMPLE_RATE_HZ ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--prepare-software", action="store_true") parser.add_argument("--prepare-hardware", action="store_true") parser.add_argument("--hardware", action="store_true") parser.add_argument("--analyze-capture", type=Path) parser.add_argument("--reprocess-saved-capture", type=Path) parser.add_argument("--finalize-saved-reprocess", action="store_true") parser.add_argument("--original-independent-result", type=Path) parser.add_argument("--i-understand-hardware", action="store_true") parser.add_argument("--authorize-one-jpeg-rf-tx", action="store_true") parser.add_argument("--confirm-1400mhz-permitted", action="store_true") parser.add_argument("--source", type=Path, default=SOURCE_IMAGE_PATH) parser.add_argument( "--tx-image", type=Path, default=OUTPUT_DIRECTORY / TX_IMAGE_NAME, help="подготовленный JPEG для формирования будущего cs8", ) parser.add_argument("--output-directory", type=Path) arguments = parser.parse_args() selected_modes = sum( ( arguments.prepare_software, arguments.prepare_hardware, arguments.hardware, arguments.analyze_capture is not None, arguments.reprocess_saved_capture is not None, arguments.finalize_saved_reprocess, ) ) if selected_modes > 1: parser.error("Выберите только один режим") if arguments.hardware: authorized = bool( arguments.i_understand_hardware and arguments.authorize_one_jpeg_rf_tx and arguments.confirm_1400mhz_permitted ) if not authorized: print("Аппаратный режим заблокирован без трёх явных ключей; RF TX = 0") return 2 if arguments.output_directory is None: parser.error("--hardware требует --output-directory") print(json.dumps( run_hardware_capture_once(arguments.output_directory, authorization=True), ensure_ascii=False, indent=2, )) return 0 if arguments.prepare_hardware: output_directory = arguments.output_directory or default_hardware_directory() prepared = prepare_hardware_transmission(output_directory, arguments.tx_image) print(json.dumps({ "output_directory": str(prepared.output_directory), "image_sha256": prepared.image_sha256, "frame_count": prepared.frame_count, "stream_sample_count": prepared.stream_sample_count, "stream_file_bytes": prepared.stream_file_bytes, "stream_duration_seconds": prepared.stream_duration_seconds, "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 0 if arguments.analyze_capture is not None: if arguments.output_directory is None: parser.error("--analyze-capture требует --output-directory") independent = run_independent_capture_recovery( arguments.analyze_capture, arguments.output_directory, ) print(json.dumps(offline._independent_summary(independent), ensure_ascii=False, indent=2)) return 0 if arguments.reprocess_saved_capture is not None: if arguments.output_directory is None: parser.error("--reprocess-saved-capture требует --output-directory") original_result_path = ( arguments.original_independent_result or arguments.output_directory / "independent_result.json" ) independent = run_saved_capture_streaming_recovery( arguments.reprocess_saved_capture, original_result_path, arguments.output_directory, ) diagnostics = None if independent.completed: diagnostics = diagnose_hardware_jpeg_after_independent_verdict( independent, arguments.output_directory / TX_IMAGE_NAME, arguments.output_directory / RECOVERED_IMAGE_NAME, ) diagnostic_path = arguments.output_directory / POST_VERDICT_DIAGNOSTICS_NAME with diagnostic_path.open("x", encoding="utf-8") as stream: json.dump(diagnostics, stream, ensure_ascii=False, indent=2) print(json.dumps({ "independent": { "completed": independent.completed, "threshold": independent.rule.threshold, "capture_samples": independent.capture_samples, "total_local_peaks": independent.coarse.total_local_peaks, "first_stage_candidates": independent.coarse.candidate_count, "candidates_skipped_as_same_local_event": ( independent.coarse.candidates_skipped_as_same_local_event ), "exact_duplicate_candidates_skipped": ( independent.coarse.exact_duplicate_candidates_skipped ), "processed_until_sample": independent.coarse.processed_until_sample, "termination_reason": independent.coarse.termination_reason, "strict_counters": asdict(independent.strict.counters), "object_verdict": { key: value for key, value in asdict(independent.object_verdict).items() if key != "recovered_object" }, "full_elapsed_seconds": independent.full_elapsed_seconds, "estimated_maximum_temporary_bytes": ( independent.coarse.estimated_maximum_temporary_bytes ), }, "post_verdict_diagnostics": diagnostics, "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 0 if arguments.finalize_saved_reprocess: if arguments.output_directory is None: parser.error("--finalize-saved-reprocess требует --output-directory") output_directory = arguments.output_directory capture_path = output_directory / "rx_stream.cs8" corrected_path = output_directory / CORRECTED_REPROCESS_RESULT_NAME corrected = correct_saved_streaming_completion( output_directory / REPROCESS_RESULT_NAME, corrected_path, capture_path, ) diagnostics = diagnose_saved_reprocess_after_corrected_verdict( corrected_path, capture_path, output_directory / RECOVERED_IMAGE_NAME, output_directory / TX_IMAGE_NAME, ) diagnostic_path = output_directory / POST_VERDICT_DIAGNOSTICS_NAME with diagnostic_path.open("x", encoding="utf-8") as stream: json.dump(diagnostics, stream, ensure_ascii=False, indent=2) print(json.dumps({ "corrected_completed": corrected["completed"], "completion_metadata_correction": corrected["completion_metadata_correction"], "post_verdict_diagnostics": diagnostics, "rf_tx": 0, }, ensure_ascii=False, indent=2)) return 0 result = run_software_preparation( arguments.source, arguments.output_directory or OUTPUT_DIRECTORY, ) print(json.dumps(result, ensure_ascii=False, indent=2, default=str)) print("RF TX = 0") return 0 if __name__ == "__main__": raise SystemExit(main())