"""Lab046C: подготовка одного непрерывного аппаратного BPSK-сеанса. Модуль связывает уже проверенные части Lab043--Lab046B. Подготовка создаёт новый случайный объект 16 KiB и последовательно записывает 68 Lab045 waveform в один cs8-файл. Приёмник читает будущий cs8 capture окнами через memmap и не получает TX offsets, ожидаемый порядок фрагментов или переданный объект. Аппаратный режим закрыт двумя явными ключами. ``--preflight-only`` выполняет только read-only inventory и разрешённые короткие RX общего clock-gate. Сам RF-сеанс этим модулем в Lab046C preparation не запускается: RF TX = 0. """ from __future__ import annotations import argparse import hashlib import json import secrets import subprocess import sys import time from dataclasses import dataclass, replace from datetime import datetime, timezone from pathlib import Path from typing import Callable if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np 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 OBJECT_SIZE_BYTES = 16_384 OBJECT_ID = lab046.DEFAULT_OBJECT_ID MASTER_SERIAL = lab044.lab043.MASTER_SERIAL SLAVE_SERIAL = lab044.lab043.SLAVE_SERIAL RF_HZ = 1_080_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 = 24.0 RX_CAPTURE_SAMPLE_COUNT = 192_000_000 RX_CAPTURE_FILE_BYTES = 384_000_000 RX_LEAD_SECONDS = 0.75 RX_WAIT_TIMEOUT_SECONDS = 35.0 DETECTION_CHUNK_SAMPLES = 16_000_000 REFINEMENT_MARGIN_SAMPLES = 4_000 MAX_FRAMES = 256 MAX_SEARCH_ITERATIONS = 2_048 RECEIVER_ORACLE_KEYS = { "tx_start_offset", "frame_start_offsets", "tx_waveform_lengths", "expected_fragment_count", "tx_packets_or_coded_bits", "tx_object_before_completion_verdict", } def _receiver_oracle_declaration_is_safe(value: object) -> bool: if not isinstance(value, dict): return False return ( set(value) == RECEIVER_ORACLE_KEYS and all(flag is False for flag in value.values()) ) def _validate_fixed_phy() -> None: actual = ( lab045.RF_HZ, 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 = ( RF_HZ, SAMPLE_RATE_HZ, SYMBOL_RATE, SAMPLES_PER_SYMBOL, CARRIER_OFFSET_HZ, TX_VGA_DB, RX_LNA_DB, RX_VGA_DB, ) if actual != expected: raise RuntimeError(f"Фактические PHY-параметры {actual} не совпали с {expected}") if RX_CAPTURE_SAMPLE_COUNT != int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ): raise RuntimeError("RX sample accounting не совпал") if RX_CAPTURE_FILE_BYTES != 2 * RX_CAPTURE_SAMPLE_COUNT: raise RuntimeError("RX cs8 byte accounting не совпал") 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 default_output_directory() -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return ( Path.home() / "Documents" / "SDR_Rover_Lab046_raw" / f"{timestamp}_bpsk_object" ) @dataclass(frozen=True) class PreparedTransmission: output_directory: Path object_path: Path stream_path: Path metadata_path: Path object_sha256: str stream_sha256: str fragment_count: int frame_count: int frame_samples: int stream_sample_count: int stream_file_bytes: int stream_duration_seconds: float ObjectFactory = Callable[[int], bytes] def prepare_transmission( output_directory: Path, *, object_factory: ObjectFactory = secrets.token_bytes, ) -> PreparedTransmission: """Создать новый объект и один cs8 TX stream без RF и полных RAM-копий.""" _validate_fixed_phy() output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=False) object_path = output_directory / "tx_object.bin" stream_path = output_directory / "tx_stream.cs8" metadata_path = output_directory / "tx_metadata.json" data = bytes(object_factory(OBJECT_SIZE_BYTES)) if len(data) != OBJECT_SIZE_BYTES: raise ValueError("object_factory вернул неверное число байт") object_path.write_bytes(data) object_digest = _sha256_file(object_path) frames = lab046.build_object_frames(data, OBJECT_ID) if not frames: raise RuntimeError("Фрагментация не создала кадров") fragment_count = frames[0].fragment_count frame_samples = frames[0].plan.waveform_sample_count if len(frames) != fragment_count: raise RuntimeError("Число FramePlan не совпало с fragment_count") if any(frame.fragment_count != fragment_count for frame in frames): raise RuntimeError("Фрагменты содержат разные fragment_count") if any(frame.plan.waveform_sample_count != frame_samples for frame in frames): raise RuntimeError("Lab046C ожидает одинаковую длину полных Lab045 waveform") 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) expected_bytes = 2 * frame.plan.waveform_sample_count if len(waveform) != expected_bytes: raise RuntimeError("Lab045 waveform не прошёл cs8 accounting") stream.write(waveform) stream_digest_builder.update(waveform) stream_file_bytes += len(waveform) stream_sample_count = sum(frame.plan.waveform_sample_count for frame in frames) expected_stream_bytes = 2 * stream_sample_count if stream_file_bytes != expected_stream_bytes or stream_path.stat().st_size != expected_stream_bytes: raise RuntimeError("TX stream file size не совпал с frame accounting") stream_digest = stream_digest_builder.hexdigest() if _sha256_file(stream_path) != stream_digest: raise RuntimeError("TX stream SHA-256 не совпал при повторном чтении") metadata = { "created_utc": datetime.now(timezone.utc).isoformat(), "stage": "LAB046C_PREPARATION_ONLY", "rf_tx_count": 0, "object": { "path": str(object_path), "bytes": len(data), "sha256": object_digest, "source": "cryptographic_random_bytes", "deterministic_seed": None, "object_id": OBJECT_ID, }, "fragmentation": { "fragment_data_bytes": lab046.FRAGMENT_DATA_SIZE, "fragment_count": fragment_count, "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_sample_count / SAMPLE_RATE_HZ, "sha256": stream_digest, "inter_frame_samples_added": 0, }, "planned_rx": { "duration_seconds": RX_CAPTURE_SECONDS, "sample_count": RX_CAPTURE_SAMPLE_COUNT, "file_bytes": RX_CAPTURE_FILE_BYTES, "format": "cs8 interleaved signed I/Q", "justification": ( "0.75 s before TX plus about 3.65 s after the 19.6044 s TX stream" ), }, "hardware": hardware_parameters(), "receiver_oracles": { key: False for key in sorted(RECEIVER_ORACLE_KEYS) }, "recovered_object": None, } metadata_path.write_text( json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8", ) return PreparedTransmission( output_directory, object_path, stream_path, metadata_path, object_digest, stream_digest, fragment_count, len(frames), frame_samples, stream_sample_count, stream_file_bytes, stream_sample_count / SAMPLE_RATE_HZ, ) 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, "clock_initial_settle_seconds": lab044.lab043.CLOCK_INITIAL_SETTLE_SECONDS, "clock_poll_interval_seconds": lab044.lab043.CLOCK_POLL_INTERVAL_SECONDS, "clock_timeout_seconds": lab044.lab043.CLOCK_TIMEOUT_SECONDS, "expected_reg29": lab044.lab043.REG29_EXTERNAL_CLOCK, } def maximum_phy_waveform_samples() -> int: coded_bits = lab045.conv.encoded_bit_count(lab045.MAX_PACKET_BYTES * 8) symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT + coded_bits return ( (symbols + 2 * lab045.GUARD_SYMBOL_COUNT) * lab045.SAMPLES_PER_SYMBOL + lab045.RRC_SPAN_SYMBOLS * lab045.SAMPLES_PER_SYMBOL ) def _cs8_window(raw: np.memmap, start_sample: int, end_sample: int) -> np.ndarray: """Преобразовать только одно окно interleaved int8 в complex64.""" if start_sample < 0 or end_sample < start_sample: raise ValueError("Некорректные границы cs8 window") byte_start = 2 * start_sample byte_end = 2 * end_sample codes = raw[byte_start:byte_end] if codes.size != 2 * (end_sample - start_sample): raise ValueError("Усечённое cs8 window") samples = np.empty(end_sample - start_sample, dtype=np.complex64) samples.real = np.asarray(codes[0::2], dtype=np.float32) / np.float32(128.0) samples.imag = np.asarray(codes[1::2], dtype=np.float32) / np.float32(128.0) return samples @dataclass(frozen=True) class WindowedIqReceiveResult: iq_sample_count: int iq_file_bytes: int iq_storage_dtype: str processing_dtype: str maximum_window_samples: int maximum_window_bytes: int preamble_candidates: int processed_candidates: int rejected_candidates: int phy_header_pass_count: int fec_pass_count: int crc_pass_count: int crc_fail_count: int accepted_frames: tuple[lab046b.IqFrameReception, ...] rejected_reasons: tuple[str, ...] cursor_history: tuple[int, ...] search_iterations: int termination_reason: str def scan_cs8_capture( capture_path: Path, *, preamble_threshold: float = lab046b.IQ_PREAMBLE_THRESHOLD, max_frames: int = MAX_FRAMES, max_search_iterations: int = MAX_SEARCH_ITERATIONS, chunk_samples: int = DETECTION_CHUNK_SAMPLES, ) -> WindowedIqReceiveResult: """Оконно найти кадры в одном cs8 capture без каких-либо TX-границ.""" capture_path = Path(capture_path) file_bytes = capture_path.stat().st_size if file_bytes <= 0 or file_bytes % 2: raise ValueError("cs8 capture должен иметь положительный чётный размер") if max_frames < 0 or max_search_iterations < 0: raise ValueError("safety limits не могут быть отрицательными") overlap = maximum_phy_waveform_samples() + REFINEMENT_MARGIN_SAMPLES decimation = lab044.lab043.DETECTION_DECIMATION overlap = ((overlap + decimation - 1) // decimation) * decimation chunk_samples = (chunk_samples // decimation) * decimation if chunk_samples <= overlap: raise ValueError("chunk_samples должен быть больше полного PHY window") sample_count = file_bytes // 2 raw = np.memmap(capture_path, dtype=np.int8, mode="r") step = chunk_samples - overlap accepted_frames: list[lab046b.IqFrameReception] = [] rejected_reasons: list[str] = [] cursor = 0 cursor_history = [0] candidate_count = 0 iterations = 0 phy_header_pass = 0 fec_pass = 0 crc_pass = 0 crc_fail = 0 maximum_window = 0 termination = "END_OF_STREAM" for window_start in range(0, sample_count, step): if len(accepted_frames) >= max_frames: termination = "MAX_FRAMES_REACHED" break if iterations >= max_search_iterations: termination = "MAX_SEARCH_ITERATIONS_REACHED" break window_end = min(sample_count, window_start + chunk_samples) ownership_end = sample_count if window_end == sample_count else window_start + step samples = _cs8_window(raw, window_start, window_end) maximum_window = max(maximum_window, samples.size) candidates, _ = lab046b._detect_iq_preamble_candidates(samples, preamble_threshold) for candidate in candidates: global_coarse = window_start + candidate.coarse_start_sample if global_coarse < window_start or global_coarse >= ownership_end: continue candidate_count += 1 if global_coarse < cursor: continue if len(accepted_frames) >= max_frames: termination = "MAX_FRAMES_REACHED" break if iterations >= max_search_iterations: termination = "MAX_SEARCH_ITERATIONS_REACHED" break iterations += 1 decoded = lab046b._decode_iq_candidate(samples, candidate, iterations) phy_header_pass += int(decoded.phy_header_pass) fec_pass += int(decoded.fec_pass) crc_pass += int(decoded.crc == lab045.CRC_PASS) crc_fail += int(decoded.crc == lab045.CRC_FAIL) global_next = window_start + int(decoded.next_sample) if decoded.accepted and decoded.frame is not None: frame = replace( decoded.frame, detection_number=iterations, coarse_start_sample=window_start + decoded.frame.coarse_start_sample, refined_start_sample=window_start + decoded.frame.refined_start_sample, frame_end_sample=window_start + decoded.frame.frame_end_sample, ) accepted_frames.append(frame) else: rejected_reasons.append(decoded.reason) cursor = max(cursor + 1, global_next) cursor_history.append(cursor) if termination != "END_OF_STREAM": break if window_end == sample_count: break return WindowedIqReceiveResult( iq_sample_count=sample_count, iq_file_bytes=file_bytes, iq_storage_dtype="interleaved int8 I/Q", processing_dtype="complex64", maximum_window_samples=maximum_window, maximum_window_bytes=maximum_window * np.dtype(np.complex64).itemsize, preamble_candidates=candidate_count, processed_candidates=iterations, rejected_candidates=len(rejected_reasons), phy_header_pass_count=phy_header_pass, fec_pass_count=fec_pass, crc_pass_count=crc_pass, crc_fail_count=crc_fail, accepted_frames=tuple(accepted_frames), rejected_reasons=tuple(rejected_reasons), cursor_history=tuple(cursor_history), search_iterations=iterations, termination_reason=termination, ) @dataclass(frozen=True) class ObjectReceiveResult: iq: WindowedIqReceiveResult valid_fragments: int duplicates: int conflicting: int rejected_fragments: int missing_fragments: tuple[int, ...] object_complete: bool recovered_object: bytes | None def receive_object_from_cs8( capture_path: Path, *, preamble_threshold: float = lab046b.IQ_PREAMBLE_THRESHOLD, max_frames: int = MAX_FRAMES, max_search_iterations: int = MAX_SEARCH_ITERATIONS, chunk_samples: int = DETECTION_CHUNK_SAMPLES, ) -> ObjectReceiveResult: """Независимо принять объект; TX object/count/offsets в API отсутствуют.""" iq = scan_cs8_capture( capture_path, preamble_threshold=preamble_threshold, max_frames=max_frames, max_search_iterations=max_search_iterations, chunk_samples=chunk_samples, ) assembler = lab046.ObjectAssembler() for frame in iq.accepted_frames: payload = frame.reception.payload_bytes fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None assembler.offer(fragment) recovered = assembler.recovered_object() return ObjectReceiveResult( iq=iq, valid_fragments=assembler.counters.accepted, duplicates=assembler.counters.duplicates, conflicting=assembler.counters.conflicting, rejected_fragments=assembler.counters.rejected, missing_fragments=tuple(assembler.missing_indexes()), object_complete=assembler.object_complete(), recovered_object=recovered, ) def run_common_clock_preflight_only( runner: lab044.lab043.CommandRunner | None = None, *, process_checker: lab045.ProcessChecker = lab045._running_hackrf_transfer_processes, ) -> dict: """Проверить inventory/common clock без RF TX и гарантированно выключить CLKOUT.""" runner = runner or lab044.lab043._default_command_runner guard = lab044.lab043.TransmitGuard() cleanup: dict = {} failure: Exception | None = None inventory: dict | None = None preflight: dict | None = None try: running_before = process_checker() if running_before: raise RuntimeError(f"До preflight уже запущен hackrf_transfer: {running_before}") inventory = lab045._verify_device_inventory(runner) preflight = lab044.lab043.common_clock_preflight(runner, guard) except Exception as error: failure = error finally: clock_errors = lab044.lab043._disable_clock_outputs(runner) cleanup["clock_outputs_off"] = not clock_errors cleanup["clock_output_errors"] = clock_errors try: lingering = process_checker() except Exception as error: cleanup["lingering_hackrf_transfer"] = None cleanup["process_check_error"] = str(error) else: cleanup["lingering_hackrf_transfer"] = lingering cleanup["no_lingering_hackrf_transfer"] = not lingering cleanup_failure = None if not cleanup.get("clock_outputs_off", False): cleanup_failure = "CLKOUT cleanup не подтверждён" if cleanup.get("no_lingering_hackrf_transfer") is not True: cleanup_failure = "после preflight остался или не проверен hackrf_transfer" if failure is not None: if cleanup_failure is not None: raise RuntimeError(f"{failure}; {cleanup_failure}") from failure raise failure if cleanup_failure is not None: raise RuntimeError(cleanup_failure) assert inventory is not None and preflight is not None if guard.transmit_calls != 0: raise RuntimeError("Preflight-only нарушил RF TX count = 0") return { "passed": True, "inventory": inventory, "common_clock": preflight, "cleanup": cleanup, "rf_tx_count": 0, } def _load_and_validate_prepared(output_directory: Path) -> PreparedTransmission: metadata_path = output_directory / "tx_metadata.json" metadata = json.loads(metadata_path.read_text(encoding="utf-8")) object_path = output_directory / "tx_object.bin" stream_path = output_directory / "tx_stream.cs8" tx = metadata["tx_stream"] obj = metadata["object"] prepared = PreparedTransmission( output_directory, object_path, stream_path, metadata_path, str(obj["sha256"]), str(tx["sha256"]), int(metadata["fragmentation"]["fragment_count"]), int(tx["frame_count"]), int(tx["samples_per_frame"]), int(tx["sample_count"]), int(tx["file_bytes"]), float(tx["duration_seconds"]), ) if metadata.get("stage") != "LAB046C_PREPARATION_ONLY": raise RuntimeError("tx_metadata.json имеет неверный stage") if metadata.get("rf_tx_count") != 0: raise RuntimeError("Подготовленный metadata уже объявляет RF TX") if int(obj.get("object_id", -1)) != OBJECT_ID: raise RuntimeError("Object ID не совпал с фиксированным Lab046C") if Path(obj.get("path", "")).resolve() != object_path.resolve(): raise RuntimeError("Object path в metadata не совпал с каталогом") if Path(tx.get("path", "")).resolve() != stream_path.resolve(): raise RuntimeError("TX stream path в metadata не совпал с каталогом") if object_path.stat().st_size != OBJECT_SIZE_BYTES: raise RuntimeError("tx_object.bin имеет неверный размер") tx_object = object_path.read_bytes() expected_frames = lab046.build_object_frames(tx_object, OBJECT_ID) expected_frame_count = len(expected_frames) expected_frame_samples = expected_frames[0].plan.waveform_sample_count expected_stream_samples = sum( frame.plan.waveform_sample_count for frame in expected_frames ) expected_stream_bytes = 2 * expected_stream_samples expected_duration = expected_stream_samples / SAMPLE_RATE_HZ fragmentation = metadata.get("fragmentation", {}) exact_accounting = ( prepared.fragment_count == expected_frame_count == 68 and prepared.frame_count == expected_frame_count and prepared.frame_samples == expected_frame_samples == 2_306_400 and prepared.stream_sample_count == expected_stream_samples == 156_835_200 and prepared.stream_file_bytes == expected_stream_bytes == 313_670_400 and abs(prepared.stream_duration_seconds - expected_duration) <= 1e-12 and int(fragmentation.get("fragment_data_bytes", -1)) == lab046.FRAGMENT_DATA_SIZE and int(fragmentation.get("fragment_count", -1)) == expected_frame_count and int(fragmentation.get("last_fragment_data_bytes", -1)) == 36 and int(tx.get("inter_frame_samples_added", -1)) == 0 ) if not exact_accounting: raise RuntimeError("Metadata не прошёл точный Lab046C frame/stream accounting") if metadata.get("hardware") != hardware_parameters(): raise RuntimeError("Hardware parameters в metadata не совпали с фиксированными") oracle_flags = metadata.get("receiver_oracles", {}) if not _receiver_oracle_declaration_is_safe(oracle_flags): raise RuntimeError("Metadata допускает TX oracle в приёмнике") if stream_path.stat().st_size != prepared.stream_file_bytes: raise RuntimeError("tx_stream.cs8 имеет неверный размер") if _sha256_file(object_path) != prepared.object_sha256: raise RuntimeError("tx_object.bin SHA-256 не совпал с metadata") if _sha256_file(stream_path) != prepared.stream_sha256: raise RuntimeError("tx_stream.cs8 SHA-256 не совпал с metadata") return prepared def run_hardware_experiment( output_directory: Path, runner: lab044.lab043.CommandRunner | None = None, process_starter: lab044.lab043.ProcessStarter | None = None, sleep_function: lab044.lab043.SleepFunction = time.sleep, process_checker: lab045.ProcessChecker = lab045._running_hackrf_transfer_processes, receiver: Callable[[Path], ObjectReceiveResult] = receive_object_from_cs8, ) -> dict: """Один будущий RF-сеанс без retry; вызывать только после отдельного разрешения.""" _validate_fixed_phy() 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) guard = lab044.lab043.TransmitGuard() rx_process: lab044.lab043.RunningCommand | None = None cleanup: dict = {} final_result: dict | None = None try: if process_checker(): raise RuntimeError("До Lab046C уже запущен hackrf_transfer") inventory = lab045._verify_device_inventory(runner) preflight = lab044.lab043.common_clock_preflight(runner, guard) 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" transfer_tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe") 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_stream.cs8 имеет неверный размер") 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 log содержит overrun/underrun") if not lab044.lab043._transfer_log_is_clean(tx_log): raise RuntimeError("TX log содержит overrun/underrun") received = receiver(rx_path) recovered = received.recovered_object if recovered is not None: (output_directory / "recovered_object.bin").write_bytes(recovered) # Только после независимого прохода всего RX и verdict assembler-а # читается TX object для диагностического сравнения. tx_object = prepared.object_path.read_bytes() tx_frames = { frame.fragment_index: frame for frame in lab046.build_object_frames(tx_object, OBJECT_ID) } frame_diagnostics: list[dict] = [] total_pre_fec_errors = 0 total_post_fec_errors = 0 for frame in received.iq.accepted_frames: payload = frame.reception.payload_bytes fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None fragment_index = fragment.fragment_index if fragment is not None else None pre_fec_errors = None post_fec_errors = None if fragment_index in tx_frames: comparison = lab045.diagnostic_pre_and_post_fec( frame.reception, frame.post_preamble_soft, tx_frames[fragment_index].plan, ) pre_fec_errors = comparison["pre_fec_coded"]["bit_errors"] post_fec_errors = comparison["post_fec_packet"]["bit_errors"] if pre_fec_errors is not None: total_pre_fec_errors += int(pre_fec_errors) if post_fec_errors is not None: total_post_fec_errors += int(post_fec_errors) frame_diagnostics.append({ "detection_number": frame.detection_number, "sample_offset": frame.refined_start_sample, "preamble_errors": frame.preamble_errors, "peak_over_median_db": frame.over_median_db, "estimated_cfo_hz": frame.estimated_cfo_hz, "estimated_phase_rad": frame.estimated_phase_rad, "snr_db": frame.snr_db, "phy_header_pass": True, "fec_pass": bool(frame.reception.analysis["fec_decode_pass"]), "crc": str(frame.reception.analysis["crc"]), "fragment_index": fragment_index, "pre_fec_coded_bit_errors_after_crc": pre_fec_errors, "post_fec_protocol_bit_errors_after_crc": post_fec_errors, }) byte_errors = None bit_errors = None sha_match = False if recovered is not None and len(recovered) == len(tx_object): tx_values = np.frombuffer(tx_object, dtype=np.uint8) rx_values = np.frombuffer(recovered, dtype=np.uint8) xor = np.bitwise_xor(tx_values, rx_values) byte_errors = int(np.count_nonzero(xor)) bit_errors = int(np.unpackbits(xor).sum()) sha_match = hashlib.sha256(recovered).hexdigest() == prepared.object_sha256 if received.object_complete and recovered is not None and sha_match and byte_errors == 0: classification = "A" elif received.valid_fragments > 0: classification = "B" else: classification = "C" result = { "classification": classification, "rf_tx_count": guard.transmit_calls, "inventory": inventory, "preflight": preflight, "rx_capture_duration_seconds": RX_CAPTURE_SECONDS, "rx_iq_sample_count": received.iq.iq_sample_count, "preamble_candidates": received.iq.preamble_candidates, "rejected_candidates": received.iq.rejected_candidates, "valid_frames": len(received.iq.accepted_frames), "phy_header_pass": received.iq.phy_header_pass_count, "fec_pass": received.iq.fec_pass_count, "crc_pass": received.iq.crc_pass_count, "crc_fail": received.iq.crc_fail_count, "valid_fragments": received.valid_fragments, "duplicates": received.duplicates, "missing_fragments": list(received.missing_fragments), "object_complete": received.object_complete, "total_pre_fec_coded_bit_errors": total_pre_fec_errors, "total_post_fec_protocol_bit_errors": total_post_fec_errors, "object_byte_errors": byte_errors, "object_bit_errors": bit_errors, "sha_match": sha_match, "frames": frame_diagnostics, "rx_sha256": _sha256_file(rx_path), "recovered_object": ( str(output_directory / "recovered_object.bin") if recovered is not None else None ), "cleanup": cleanup, } final_result = result 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) assert final_result is not None cleanup["clock_outputs_off"] = not cleanup.get("clock_output_errors") cleanup["no_lingering_hackrf_transfer"] = ( cleanup.get("lingering_hackrf_transfer") == [] and "process_check_error" not in cleanup ) cleanup_passed = bool( cleanup["clock_outputs_off"] and cleanup["no_lingering_hackrf_transfer"] ) final_result["cleanup_passed"] = cleanup_passed if not cleanup_passed: final_result["classification"] = "HARDWARE_ERROR_AFTER_TX" (output_directory / "result.json").write_text( json.dumps(final_result, ensure_ascii=False, indent=2), encoding="utf-8", ) return final_result def run_functional_tests() -> None: _validate_fixed_phy() frames = lab046.build_object_frames(bytes(OBJECT_SIZE_BYTES), OBJECT_ID) assert len(frames) == 68 assert frames[0].plan.waveform_sample_count == 2_306_400 assert sum(frame.plan.waveform_sample_count for frame in frames) == 156_835_200 assert maximum_phy_waveform_samples() > frames[0].plan.waveform_sample_count def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--prepare", action="store_true") parser.add_argument("--preflight-only", action="store_true") parser.add_argument("--hardware", action="store_true") parser.add_argument("--i-understand-hardware", action="store_true") parser.add_argument("--authorize-one-rf-tx", action="store_true") parser.add_argument("--output-directory", type=Path) arguments = parser.parse_args() selected_modes = sum((arguments.prepare, arguments.preflight_only, arguments.hardware)) if selected_modes > 1: parser.error("Выберите только один режим") if arguments.hardware: if not arguments.i_understand_hardware or not arguments.authorize_one_rf_tx: print("Аппаратный RF-режим заблокирован без двух явных ключей; RF TX = 0") return 2 if arguments.output_directory is None: parser.error("--hardware требует --output-directory с готовыми artifacts") result = run_hardware_experiment(arguments.output_directory) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if arguments.preflight_only: if not arguments.i_understand_hardware: print("Common-clock preflight заблокирован без --i-understand-hardware; RF TX = 0") return 2 result = run_common_clock_preflight_only() print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if arguments.prepare: prepared = prepare_transmission( arguments.output_directory or default_output_directory() ) print(json.dumps({ "output_directory": str(prepared.output_directory), "object_sha256": prepared.object_sha256, "stream_sha256": prepared.stream_sha256, "frame_count": prepared.frame_count, "stream_samples": prepared.stream_sample_count, "stream_file_bytes": prepared.stream_file_bytes, "stream_duration_seconds": prepared.stream_duration_seconds, "rf_tx_count": 0, }, ensure_ascii=False, indent=2)) return 0 run_functional_tests() print("LAB046C SOFTWARE PREFLIGHT = PASS") print("RF TX = 0") return 0 if __name__ == "__main__": raise SystemExit(main())