Files
SDR-Rover/experiments/lab046c_hackrf_bpsk_object.py
2026-09-01 15:42:00 +03:00

887 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())