Files
SDR-Rover/experiments/lab047_jpeg_object_transport.py
2026-09-03 13:13:23 +03:00

1412 lines
57 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.
"""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())