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