787 lines
37 KiB
Python
787 lines
37 KiB
Python
"""Lab066: исправленный повтор полного 500K PHY-сеанса.
|
||
|
||
Подготовка, программная проверка и preflight не передают RF. Единственный
|
||
аппаратный сеанс доступен только отдельной командой с явным разрешением.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import asdict
|
||
import argparse
|
||
import csv
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
if __package__ in (None, ""):
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
import numpy as np
|
||
from scipy import signal
|
||
|
||
from experiments import lab049_bpsk_symbol_rate as phy
|
||
from experiments import lab053_packet_processing as packet_processing
|
||
from experiments import lab053_streaming_receiver as streaming
|
||
from experiments import lab054_hardware_control as lab054
|
||
from experiments import lab056_capture_runner as hardware
|
||
from experiments import lab058_abab_symbol_quality as lab058
|
||
from experiments import lab064_rf_level_linearity as lab064
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
RAW = ROOT / "data/raw/lab066"
|
||
OUTPUT = ROOT / "data/processed/lab066"
|
||
POINT = hardware.OperatingPoint(
|
||
frequency_hz=1_400_000_000,
|
||
physical_path="MASTER TX near window -> LOS indoor -> SLAVE RX on desk",
|
||
path_confirmed=True,
|
||
tx_antenna="existing 1400 MHz antenna near window",
|
||
rx_antenna="existing 1400 MHz antenna on desk",
|
||
distance_m=1.2,
|
||
orientation="same polarization; fixed during preflight and RF session",
|
||
visibility="LOS indoor",
|
||
)
|
||
MODE = phy.PhyMode(500_000)
|
||
PACKETS = 16
|
||
PAYLOAD_BYTES = 256
|
||
PACKET_SAMPLES = 92_256
|
||
INTER_PACKET_GAP_SECONDS = 0.005
|
||
INTER_PACKET_GAP_SAMPLES = 40_000
|
||
NUMBER_OF_GAPS = PACKETS - 1
|
||
GUARD_SECONDS = 0.1
|
||
GUARD_SAMPLES = 800_000
|
||
BASE_TX_SAMPLES = PACKETS * PACKET_SAMPLES + NUMBER_OF_GAPS * INTER_PACKET_GAP_SAMPLES
|
||
EXPECTED_TX_SAMPLES = BASE_TX_SAMPLES + 2 * GUARD_SAMPLES
|
||
PREFLIGHT_SAMPLES = 800_000
|
||
RX_SECONDS = 2
|
||
RX_SAMPLES = RX_SECONDS * MODE.sample_rate_hz
|
||
RX_LEAD_SECONDS = 0.5
|
||
TX_VGA_DB = 20
|
||
STRONG_AC_RMS_CODE = 20.0
|
||
STRONG_COMPONENT_CODE = 100.0
|
||
|
||
|
||
def save_new(path: Path, value: dict) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("x", encoding="utf-8") as stream:
|
||
json.dump(value, stream, ensure_ascii=False, indent=2, allow_nan=False)
|
||
stream.write("\n")
|
||
|
||
|
||
def sha256(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as stream:
|
||
while block := stream.read(1024 * 1024):
|
||
digest.update(block)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def plans():
|
||
result = lab054.plans()
|
||
payloads = [frame.plan.payload for frame in result]
|
||
if len(result) != PACKETS or any(len(payload) != PAYLOAD_BYTES for payload in payloads):
|
||
raise RuntimeError("Штатный генератор не дал 16 payload по 256 байт")
|
||
if len(set(payloads)) != PACKETS:
|
||
raise RuntimeError("Payload пакетов должны однозначно различаться")
|
||
return result
|
||
|
||
|
||
def parameters() -> dict:
|
||
return {
|
||
"rf_frequency_hz": POINT.frequency_hz,
|
||
"sample_rate_hz": MODE.sample_rate_hz,
|
||
"symbol_rate": MODE.symbol_rate,
|
||
"samples_per_symbol": MODE.samples_per_symbol,
|
||
"digital_carrier_hz": lab054.phy.lab045.CARRIER_OFFSET_HZ,
|
||
"packets": PACKETS,
|
||
"payload_bytes": PAYLOAD_BYTES,
|
||
"tx_vga_db": TX_VGA_DB,
|
||
"rx_lna_db": 24,
|
||
"rx_vga_db": 24,
|
||
"rf_amplifier": False,
|
||
"bias_tee": False,
|
||
"geometry": asdict(POINT),
|
||
"geometry_unchanged": True,
|
||
"clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN",
|
||
}
|
||
|
||
|
||
def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]:
|
||
command = hardware.command(POINT, path, samples, transmit=transmit)
|
||
if transmit:
|
||
command[command.index("-x") + 1] = str(TX_VGA_DB)
|
||
return command
|
||
|
||
|
||
def build_waveform() -> tuple[bytes, list[dict]]:
|
||
"""Собрать финальный поток с нулевыми guards и паузами."""
|
||
blocks: list[bytes] = [bytes(2 * GUARD_SAMPLES)]
|
||
layout: list[dict] = []
|
||
cursor = GUARD_SAMPLES
|
||
for index, frame in enumerate(plans(), start=1):
|
||
packet = phy.build_parameterized_tx_cs8(frame.plan, MODE)
|
||
if len(packet) != 2 * PACKET_SAMPLES:
|
||
raise RuntimeError(f"Пакет {index}: ожидалось {PACKET_SAMPLES} отсчётов")
|
||
layout.append({
|
||
"packet_index": index,
|
||
"start_sample": cursor,
|
||
"samples": PACKET_SAMPLES,
|
||
"payload_sha256": hashlib.sha256(frame.plan.payload).hexdigest(),
|
||
})
|
||
blocks.append(packet)
|
||
cursor += PACKET_SAMPLES
|
||
if index != PACKETS:
|
||
blocks.append(bytes(2 * INTER_PACKET_GAP_SAMPLES))
|
||
layout[-1]["following_gap_start_sample"] = cursor
|
||
layout[-1]["following_gap_samples"] = INTER_PACKET_GAP_SAMPLES
|
||
cursor += INTER_PACKET_GAP_SAMPLES
|
||
blocks.append(bytes(2 * GUARD_SAMPLES))
|
||
waveform = b"".join(blocks)
|
||
if len(waveform) != 2 * EXPECTED_TX_SAMPLES or cursor + GUARD_SAMPLES != EXPECTED_TX_SAMPLES:
|
||
raise RuntimeError("Неверная длина итогового TX-потока Lab066")
|
||
return waveform, layout
|
||
|
||
|
||
def prepare() -> dict:
|
||
RAW.mkdir(parents=True, exist_ok=True)
|
||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
waveform_path = RAW / "tx_stream.cs8"
|
||
if waveform_path.exists():
|
||
raise FileExistsError("Lab066 TX уже существует; перезапись запрещена")
|
||
waveform, layout = build_waveform()
|
||
waveform_path.write_bytes(waveform)
|
||
result = {
|
||
"lab": "Lab066", "stage": "PREPARATION_ONLY", "rf_tx_count": 0,
|
||
"head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||
"parameters": parameters(), "packet_samples": PACKET_SAMPLES,
|
||
"inter_packet_gap_seconds": INTER_PACKET_GAP_SECONDS,
|
||
"inter_packet_gap_samples": INTER_PACKET_GAP_SAMPLES,
|
||
"number_of_gaps": NUMBER_OF_GAPS,
|
||
"zero_guard_before_samples": GUARD_SAMPLES,
|
||
"zero_guard_after_samples": GUARD_SAMPLES,
|
||
"base_tx_samples": BASE_TX_SAMPLES,
|
||
"expected_tx_samples": EXPECTED_TX_SAMPLES,
|
||
"tx_samples": EXPECTED_TX_SAMPLES, "tx_bytes": len(waveform),
|
||
"tx_duration_seconds": EXPECTED_TX_SAMPLES / MODE.sample_rate_hz,
|
||
"tx_sha256": sha256(waveform_path), "layout": layout,
|
||
"rx_samples": RX_SAMPLES, "rx_duration_seconds": RX_SECONDS,
|
||
"automatic_retry": False,
|
||
"tx_command_prepared_not_run": transfer_command(waveform_path, EXPECTED_TX_SAMPLES, transmit=True),
|
||
"rx_command_prepared_not_run": transfer_command(RAW / "rx_stream.cs8", RX_SAMPLES),
|
||
}
|
||
save_new(RAW / "tx_metadata.json", result)
|
||
save_new(OUTPUT / "preparation.json", result)
|
||
return result
|
||
|
||
|
||
def validate_prepared() -> dict:
|
||
metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8"))
|
||
path = RAW / "tx_stream.cs8"
|
||
expected, layout = build_waveform()
|
||
expected_digest = hashlib.sha256(expected).hexdigest()
|
||
actual = path.read_bytes()
|
||
if (
|
||
metadata.get("lab") != "Lab066"
|
||
or metadata.get("stage") != "PREPARATION_ONLY"
|
||
or metadata.get("rf_tx_count") != 0
|
||
or metadata.get("parameters") != parameters()
|
||
or metadata.get("layout") != layout
|
||
or metadata.get("expected_tx_samples") != EXPECTED_TX_SAMPLES
|
||
or metadata.get("tx_samples") != EXPECTED_TX_SAMPLES
|
||
or len(actual) != 2 * EXPECTED_TX_SAMPLES
|
||
or metadata.get("tx_sha256") != expected_digest
|
||
or hashlib.sha256(actual).hexdigest() != expected_digest
|
||
or actual != expected
|
||
):
|
||
raise RuntimeError("Подготовленный Lab066 TX или его разметка изменились")
|
||
return metadata
|
||
|
||
|
||
def decode(mode: str, source_path: Path | None = None, *, diagnostics: bool = False) -> dict:
|
||
"""Строго декодировать весь поток; legacy и T2 запускаются отдельно."""
|
||
observations = []
|
||
receiver = streaming.StreamingReceiver(
|
||
processor=packet_processing.PacketProcessor(carrier_tracking_mode=mode),
|
||
packet_observer=lambda row, outcome: observations.append((row, outcome)),
|
||
)
|
||
received = []
|
||
with (source_path or (RAW / "tx_stream.cs8")).open("rb") as stream:
|
||
while block := stream.read(2 * receiver.block_samples):
|
||
values = np.frombuffer(block, np.int8)
|
||
samples = values[::2].astype(np.float32) + 1j * values[1::2].astype(np.float32)
|
||
received.extend(receiver.push_iq_block(samples.astype(np.complex64)))
|
||
received.extend(receiver.finish())
|
||
expected = [frame.plan.payload for frame in plans()]
|
||
actual = [item.payload for item in received]
|
||
payload_to_index = {payload: index for index, payload in enumerate(expected, start=1)}
|
||
result = {
|
||
"mode": mode, "candidates": len(observations),
|
||
"header_pass": sum(outcome.phy_header_pass for _, outcome in observations),
|
||
"fec_started": sum(outcome.fec_attempted for _, outcome in observations),
|
||
"fec_pass": sum(outcome.fec_pass for _, outcome in observations),
|
||
"crc_started": sum(outcome.crc != "NOT_REACHED" for _, outcome in observations),
|
||
"crc_pass": sum(outcome.crc == "PASS" for _, outcome in observations),
|
||
"packet_output": len(actual),
|
||
"exact_payload_match": sum(payload in payload_to_index for payload in actual),
|
||
"packet_indexes": [payload_to_index.get(payload) for payload in actual],
|
||
"exact_ordered_payloads": actual == expected,
|
||
"t2_state_scope": "NEW_PREAMBLE_ESTIMATE_PER_PACKET" if mode == "t2" else "NOT_APPLICABLE",
|
||
"fallback_between_modes": False,
|
||
"passed": len(actual) == PACKETS and actual == expected,
|
||
}
|
||
if diagnostics:
|
||
result["rows"] = [{
|
||
"absolute_refined": int(row["absolute_refined"]),
|
||
"reason": outcome.reason,
|
||
"header": bool(outcome.phy_header_pass),
|
||
"fec_started": bool(outcome.fec_attempted), "fec": bool(outcome.fec_pass),
|
||
"crc_started": outcome.crc != "NOT_REACHED", "crc": outcome.crc == "PASS",
|
||
"packet_output": bool(outcome.accepted and outcome.crc == "PASS"),
|
||
"packet_index_after_verdict": payload_to_index.get(
|
||
bytes(outcome.frame.reception.payload_bytes)
|
||
if outcome.frame is not None and outcome.frame.reception.payload_bytes is not None else b""
|
||
),
|
||
"t2_tracking_runs_for_candidate": len(row.get("carrier_tracking_traces", ())),
|
||
} for row, outcome in observations]
|
||
result["detector_statistics"] = dict(receiver.detection_statistics)
|
||
result["candidate_overflow"] = receiver.candidate_overflow
|
||
if mode == "t2":
|
||
result["t2_state_reset_evidence"] = {
|
||
"estimate_source": "each candidate's own preamble",
|
||
"context_scope": "new PacketMeasure context for every candidate",
|
||
"tracking_runs_per_accepted_packet": [
|
||
row["t2_tracking_runs_for_candidate"]
|
||
for row in result["rows"] if row["packet_output"]
|
||
],
|
||
"all_accepted_packets_have_fresh_tracking_runs": all(
|
||
row["t2_tracking_runs_for_candidate"] > 0
|
||
for row in result["rows"] if row["packet_output"]
|
||
),
|
||
"persistent_tracking_state_in_packet_processor": False,
|
||
}
|
||
return result
|
||
|
||
|
||
def software_gate() -> dict:
|
||
metadata = validate_prepared()
|
||
legacy = decode("legacy", diagnostics=True)
|
||
t2 = decode("t2", diagnostics=True)
|
||
result = {
|
||
"lab": "Lab066", "stage": "SOFTWARE_GATE", "rf_tx_count": 0,
|
||
"tx_sha256": metadata["tx_sha256"], "legacy": legacy, "t2": t2,
|
||
"gaps_verified_zero": True,
|
||
"t2_state_reset_verified_by_fresh_preamble_per_packet": bool(
|
||
t2.get("t2_state_reset_evidence", {}).get(
|
||
"all_accepted_packets_have_fresh_tracking_runs"
|
||
)
|
||
),
|
||
"passed": (
|
||
legacy["passed"] and t2["passed"]
|
||
and t2.get("t2_state_reset_evidence", {}).get(
|
||
"all_accepted_packets_have_fresh_tracking_runs"
|
||
) is True
|
||
),
|
||
}
|
||
save_new(OUTPUT / "software_gate.json", result)
|
||
if not result["passed"]:
|
||
raise RuntimeError("Lab066 software gate FAIL")
|
||
return result
|
||
|
||
|
||
def preflight(*, runner=None, process_checker=None, clock_checker=None,
|
||
sleep_function=time.sleep) -> dict:
|
||
metadata = validate_prepared()
|
||
software = json.loads((OUTPUT / "software_gate.json").read_text(encoding="utf-8"))
|
||
if not software.get("passed") or software.get("tx_sha256") != metadata["tx_sha256"]:
|
||
raise RuntimeError("Нет действующего software gate Lab066")
|
||
save_new(RAW / "preflight_attempt.json", {
|
||
"lab": "Lab066", "rf_tx_count": 0, "point": asdict(POINT),
|
||
"tx_sha256": metadata["tx_sha256"],
|
||
})
|
||
actual_runner = runner or lab054.clock._default_command_runner
|
||
process_checker = process_checker or lab054.check_processes
|
||
clock_checker = clock_checker or hardware.common_clock
|
||
|
||
def rx_only(command):
|
||
if "-t" in command:
|
||
raise PermissionError("TX запрещён в Lab066 preflight")
|
||
return actual_runner(command)
|
||
|
||
result = {
|
||
"lab": "Lab066", "stage": "RX_ONLY_PREFLIGHT", "passed": False,
|
||
"rf_tx_count": 0, "point": asdict(POINT), "parameters": parameters(),
|
||
"tx_sha256": metadata["tx_sha256"], "stop_before_tx": True,
|
||
}
|
||
clock_started = False
|
||
try:
|
||
before = process_checker()
|
||
result["process_before"] = before.to_dict()
|
||
lab054.require_clear(before)
|
||
result["inventory"] = lab054.phy.lab045._verify_device_inventory(rx_only)
|
||
clock_started = True
|
||
result["clock"] = clock_checker(
|
||
POINT, rx_only, lab054.clock.TransmitGuard(), sleep_function=sleep_function
|
||
)
|
||
hardware.require_clock_pass(result["clock"])
|
||
path = RAW / "preflight_rx.cs8"
|
||
if path.exists():
|
||
raise FileExistsError("Lab066 preflight RX уже существует")
|
||
captured = rx_only(transfer_command(path, PREFLIGHT_SAMPLES))
|
||
stdout, stderr = captured.stdout or "", captured.stderr or ""
|
||
result["rx_process"] = {"returncode": captured.returncode, "stdout": stdout, "stderr": stderr}
|
||
result["rx_counter_summary"] = lab064.counter_summary(stdout + stderr, "overrun")
|
||
if captured.returncode or not result["rx_counter_summary"]["clean"]:
|
||
raise RuntimeError("RX preflight: процесс или максимум overruns не прошёл")
|
||
if path.stat().st_size != 2 * PREFLIGHT_SAMPLES:
|
||
raise RuntimeError("Неверная длина Lab066 preflight RX")
|
||
level = hardware.receive_level(path)
|
||
level.pop("symbol_energy_code2_seconds", None)
|
||
level["symbol_energy"] = "NOT_APPLICABLE_TO_BACKGROUND"
|
||
ac_rms = float(np.sqrt(max(0.0, level["ac_power_code2"])))
|
||
strong = bool(ac_rms >= STRONG_AC_RMS_CODE or level["max_abs_i"] >= STRONG_COMPONENT_CODE
|
||
or level["max_abs_q"] >= STRONG_COMPONENT_CODE)
|
||
level.update({"ac_rms_code": ac_rms, "unexpected_strong_signal": strong})
|
||
result["rx_level"] = level
|
||
result["rx_sha256"] = sha256(path)
|
||
result["rx_overruns"] = result["rx_counter_summary"]["maximum_count"]
|
||
if level["saturation_detected"]:
|
||
raise RuntimeError("RX clipping; STOP без изменения gain")
|
||
if strong:
|
||
raise RuntimeError("Неожиданно сильный RX-only фон; STOP без изменения gain")
|
||
result["passed"] = True
|
||
except Exception as error:
|
||
result["error"] = str(error)
|
||
raise
|
||
finally:
|
||
errors = lab054.disable_clock_outputs(rx_only) if clock_started else []
|
||
try:
|
||
after = process_checker()
|
||
result["process_after"] = after.to_dict()
|
||
lab054.require_clear(after)
|
||
except Exception as error:
|
||
errors.append(str(error))
|
||
result["cleanup_errors"] = errors
|
||
result["passed"] = result["passed"] and not errors
|
||
save_new(RAW / "preflight_result.json", result)
|
||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
(OUTPUT / "preflight_result.json").write_text(
|
||
json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8"
|
||
)
|
||
if not result["passed"]:
|
||
raise RuntimeError("Lab066 preflight FAIL")
|
||
return result
|
||
|
||
|
||
def require_preflight(metadata: dict) -> dict:
|
||
record = json.loads((RAW / "preflight_result.json").read_text(encoding="utf-8"))
|
||
if (record.get("passed") is not True or record.get("rf_tx_count") != 0
|
||
or record.get("point") != asdict(POINT) or record.get("parameters") != parameters()
|
||
or record.get("tx_sha256") != metadata["tx_sha256"]):
|
||
raise PermissionError("Нет успешного RX-only preflight для конфигурации Lab066")
|
||
hardware.require_clock_pass(record.get("clock", {}))
|
||
if (record.get("rx_overruns") != 0
|
||
or record.get("rx_level", {}).get("saturation_detected") is not False
|
||
or record.get("rx_level", {}).get("unexpected_strong_signal") is not False):
|
||
raise PermissionError("RX-only preflight не разрешает передачу")
|
||
return record
|
||
|
||
|
||
def capture_once(*, authorization: bool = False, runner=None, process_starter=None,
|
||
process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict:
|
||
"""Выполнить будущий единственный RX-first сеанс после отдельного разрешения."""
|
||
if authorization is not True:
|
||
raise PermissionError("Нужно отдельное разрешение на единственный RF-сеанс Lab066")
|
||
metadata = validate_prepared()
|
||
require_preflight(metadata)
|
||
receive_path = RAW / "rx_stream.cs8"
|
||
if receive_path.exists() or (RAW / "capture_result.json").exists():
|
||
raise FileExistsError("Аппаратный результат Lab066 уже существует")
|
||
actual_runner = runner or lab054.clock._default_command_runner
|
||
process_starter = process_starter or lab054.clock._default_process_starter
|
||
process_checker = process_checker or lab054.check_processes
|
||
clock_checker = clock_checker or hardware.common_clock
|
||
save_new(RAW / "hardware_attempt.json", {
|
||
"lab": "Lab066", "authorized_once": True, "automatic_retry": False,
|
||
"point": asdict(POINT), "parameters": parameters(), "tx_sha256": metadata["tx_sha256"],
|
||
})
|
||
result = {
|
||
"lab": "Lab066", "stage": "SINGLE_RF_CAPTURE", "passed": False,
|
||
"rf_tx_count": 0, "automatic_tx_repeats": 0, "point": asdict(POINT),
|
||
"parameters": parameters(), "tx_sha256": metadata["tx_sha256"],
|
||
}
|
||
guard, process, clock_started = lab054.clock.TransmitGuard(), None, False
|
||
try:
|
||
before = process_checker(); result["process_before"] = before.to_dict(); lab054.require_clear(before)
|
||
result["inventory"] = lab054.phy.lab045._verify_device_inventory(actual_runner)
|
||
clock_started = True
|
||
result["clock"] = clock_checker(POINT, actual_runner, guard, sleep_function=sleep_function)
|
||
hardware.require_clock_pass(result["clock"])
|
||
process = process_starter(transfer_command(receive_path, RX_SAMPLES), RAW / "rx_stdout.txt", RAW / "rx_stderr.txt")
|
||
sleep_function(RX_LEAD_SECONDS)
|
||
if process.poll() is not None:
|
||
raise RuntimeError("RX завершился до TX")
|
||
before_tx = hardware.receive_level(receive_path, tail_samples=65_536)
|
||
before_tx.pop("symbol_energy_code2_seconds", None)
|
||
before_tx_ac_rms = float(np.sqrt(max(0.0, before_tx["ac_power_code2"])))
|
||
before_tx_strong = bool(
|
||
before_tx_ac_rms >= STRONG_AC_RMS_CODE
|
||
or before_tx["max_abs_i"] >= STRONG_COMPONENT_CODE
|
||
or before_tx["max_abs_q"] >= STRONG_COMPONENT_CODE
|
||
)
|
||
before_tx.update({
|
||
"ac_rms_code": before_tx_ac_rms,
|
||
"unexpected_strong_signal": before_tx_strong,
|
||
})
|
||
result["rx_level_before_tx"] = before_tx
|
||
if before_tx["saturation_detected"]:
|
||
raise RuntimeError("RX clipping до TX; RF TX = 0")
|
||
if before_tx_strong:
|
||
raise RuntimeError("Неожиданно сильный фон непосредственно перед TX; RF TX = 0")
|
||
guard.register_transmit(); result["rf_tx_count"] = guard.transmit_calls
|
||
transmitted = actual_runner(transfer_command(RAW / "tx_stream.cs8", EXPECTED_TX_SAMPLES, transmit=True))
|
||
tx_stdout, tx_stderr = transmitted.stdout or "", transmitted.stderr or ""
|
||
(RAW / "tx_stdout.txt").write_text(tx_stdout, encoding="utf-8")
|
||
(RAW / "tx_stderr.txt").write_text(tx_stderr, encoding="utf-8")
|
||
result["tx_process"] = {"returncode": transmitted.returncode,
|
||
"stdout_file": "tx_stdout.txt", "stderr_file": "tx_stderr.txt"}
|
||
result["tx_counter_summary"] = lab064.counter_summary(tx_stdout + tx_stderr, "underrun")
|
||
if transmitted.returncode:
|
||
raise RuntimeError("Единственный TX завершился ошибкой; повтор запрещён")
|
||
if process.wait(RX_SECONDS + 10):
|
||
raise RuntimeError("RX завершился ошибкой; повтор запрещён")
|
||
process.close_logs()
|
||
rx_log = (RAW / "rx_stdout.txt").read_text(encoding="utf-8") + (RAW / "rx_stderr.txt").read_text(encoding="utf-8")
|
||
result["rx_counter_summary"] = lab064.counter_summary(rx_log, "overrun")
|
||
result["rx_samples"] = receive_path.stat().st_size // 2
|
||
result["rx_sha256"] = sha256(receive_path)
|
||
result["rx_level"] = hardware.receive_level(receive_path)
|
||
result["rx_clipping"] = bool(result["rx_level"]["saturation_detected"])
|
||
result["passed"] = result["rx_samples"] == RX_SAMPLES
|
||
validity = lab064.capture_validity(result)
|
||
if not validity["valid"] or not result["passed"]:
|
||
result["passed"] = False
|
||
raise RuntimeError("INVALID_CAPTURE; запись сохранена, повтор запрещён")
|
||
except Exception as error:
|
||
result["error"] = str(error)
|
||
raise
|
||
finally:
|
||
errors = []
|
||
if process is not None:
|
||
try: process.terminate()
|
||
except Exception as error: errors.append(str(error))
|
||
if clock_started: errors.extend(lab054.disable_clock_outputs(actual_runner))
|
||
try:
|
||
after = process_checker(); result["process_after"] = after.to_dict(); lab054.require_clear(after)
|
||
except Exception as error: errors.append(str(error))
|
||
result["cleanup_errors"] = errors
|
||
result["passed"] = result["passed"] and not errors
|
||
save_new(RAW / "capture_result.json", result)
|
||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
(OUTPUT / "capture_result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||
if not result["passed"]:
|
||
raise RuntimeError("Lab066 RF-сеанс невалиден; повтор запрещён")
|
||
return result
|
||
|
||
|
||
def locate_first_packet(tx: lab058.Cs8File, rx: lab058.Cs8File) -> dict:
|
||
"""Найти первый пакет после строгого verdict, не раскрывая границы декодеру."""
|
||
stride = MODE.samples_per_symbol
|
||
reference = tx.read(GUARD_SAMPLES, PACKET_SAMPLES).astype(np.complex64)
|
||
received = rx.read(0, rx.size).astype(np.complex64)
|
||
reference = reference[::stride] - np.mean(reference[::stride])
|
||
received = received[::stride] - np.mean(received[::stride])
|
||
correlation = signal.correlate(received, reference, mode="valid", method="fft")
|
||
coarse = int(np.argmax(abs(correlation))) * stride
|
||
full_reference = tx.read(GUARD_SAMPLES, PACKET_SAMPLES).astype(np.complex128)
|
||
full_reference -= np.mean(full_reference)
|
||
starts = range(max(0, coarse - 2 * stride), min(rx.size - PACKET_SAMPLES, coarse + 2 * stride) + 1)
|
||
scores = []
|
||
for start in starts:
|
||
values = rx.read(start, PACKET_SAMPLES).astype(np.complex128)
|
||
values -= np.mean(values)
|
||
scores.append(abs(np.vdot(full_reference, values)))
|
||
first_start = starts.start + int(np.argmax(scores))
|
||
return {
|
||
"first_packet_rx_start_sample": first_start,
|
||
"stream_rx_start_sample": first_start - GUARD_SAMPLES,
|
||
"coarse_first_packet_start_sample": coarse,
|
||
"method": "first known packet correlation after strict receiver verdict",
|
||
}
|
||
|
||
|
||
def physical_packet_diagnostics(tx: lab058.Cs8File, rx: lab058.Cs8File,
|
||
location: dict) -> dict:
|
||
"""Оценить 16 пакетов известным TX только после строгого решения."""
|
||
rows = []
|
||
stream_start = location["stream_rx_start_sample"]
|
||
for layout, frame in zip(validate_prepared()["layout"], plans()):
|
||
packet_index = layout["packet_index"]
|
||
tx_start = layout["start_sample"]
|
||
rx_start = stream_start + tx_start
|
||
transmitted = tx.read(tx_start, PACKET_SAMPLES)
|
||
received = rx.read(rx_start, PACKET_SAMPLES)
|
||
expected = 2.0 * frame.plan.frame_bits.astype(np.float64) - 1.0
|
||
metrics, _working = lab058.analyze_values(received, expected, MODE.symbol_rate)
|
||
channel, _arrays = lab058.channel_metrics(
|
||
transmitted, received, MODE.symbol_rate, metrics["oracle"]["cfo_hz"]
|
||
)
|
||
oracle = metrics["oracle"]["metrics"]
|
||
rows.append({
|
||
"packet_index": packet_index, "tx_start_sample": tx_start,
|
||
"rx_start_sample": rx_start,
|
||
"symbol_error_rate": oracle["errors"] / oracle["symbols"],
|
||
"symbol_rms_deviation": float(np.sqrt(
|
||
oracle["residual_power"] / oracle["matched_component_power"]
|
||
)),
|
||
"matched_component_power": oracle["matched_component_power"],
|
||
"residual_power": oracle["residual_power"],
|
||
"matched_residual_db": float(10 * np.log10(
|
||
oracle["matched_component_power"] / oracle["residual_power"]
|
||
)),
|
||
"band_gain_nonuniformity_p95_p05_db": channel["amplitude_ripple_p95_p05_db"],
|
||
})
|
||
return {
|
||
"method": "Lab058 oracle diagnostics per packet after strict verdict",
|
||
"rows": rows,
|
||
"comparison": {
|
||
"lab056_B_oracle_symbol_error_rate": 0.03495896,
|
||
"lab056_B_oracle_evm_rms": 0.845350,
|
||
"lab062_source": "data/processed/lab062/rf_analysis.json",
|
||
"scope": "Lab056 B and Lab062 are contextual comparisons, not identical estimands",
|
||
},
|
||
}
|
||
|
||
|
||
def summarize_analysis(result: dict) -> dict:
|
||
"""Свести строгие и физические результаты без повторного декодирования."""
|
||
legacy, t2 = result["run_a_legacy"], result["run_b_t2"]
|
||
legacy_set, t2_set = set(legacy["packet_indexes"]), set(t2["packet_indexes"])
|
||
layout = validate_prepared()["layout"]
|
||
stream_start = result["location_after_strict_runs"]["stream_rx_start_sample"]
|
||
packet_rows = []
|
||
for layout_row in layout:
|
||
index = layout_row["packet_index"]
|
||
nominal = stream_start + layout_row["start_sample"]
|
||
modes = {}
|
||
for label, strict, accepted in (
|
||
("legacy", legacy, index in legacy_set), ("t2", t2, index in t2_set)
|
||
):
|
||
candidates = [
|
||
row for row in strict["rows"]
|
||
if abs(row["absolute_refined"] - nominal) <= 64
|
||
]
|
||
best = max(candidates, key=lambda row: (
|
||
row["packet_output"], row["crc"], row["fec"], row["header"],
|
||
-abs(row["absolute_refined"] - nominal),
|
||
)) if candidates else None
|
||
modes[label] = {
|
||
"candidate_at_expected_packet": best is not None,
|
||
"header": accepted or bool(best and best["header"]),
|
||
"fec": accepted or bool(best and best["fec"]),
|
||
"crc": accepted or bool(best and best["crc"]),
|
||
"packet_output": accepted,
|
||
}
|
||
packet_rows.append({"packet_index": index, **modes})
|
||
|
||
physical_rows = result["physical_diagnostics"]["rows"]
|
||
matched = float(np.mean([row["matched_component_power"] for row in physical_rows]))
|
||
residual = float(np.mean([row["residual_power"] for row in physical_rows]))
|
||
physical = {
|
||
"symbol_error_rate": float(np.mean([row["symbol_error_rate"] for row in physical_rows])),
|
||
"rms_symbol_deviation": float(np.sqrt(np.mean([
|
||
row["symbol_rms_deviation"] ** 2 for row in physical_rows
|
||
]))),
|
||
"matched_component_power": matched,
|
||
"residual_power": residual,
|
||
"matched_residual_db": float(10 * np.log10(matched / residual)),
|
||
"band_gain_nonuniformity_p95_p05_db_median": float(np.median([
|
||
row["band_gain_nonuniformity_p95_p05_db"] for row in physical_rows
|
||
])),
|
||
"band_gain_nonuniformity_p95_p05_db_min": float(np.min([
|
||
row["band_gain_nonuniformity_p95_p05_db"] for row in physical_rows
|
||
])),
|
||
"band_gain_nonuniformity_p95_p05_db_max": float(np.max([
|
||
row["band_gain_nonuniformity_p95_p05_db"] for row in physical_rows
|
||
])),
|
||
}
|
||
lab056 = {
|
||
"symbol_error_rate": 0.03495896464646465,
|
||
"rms_symbol_deviation": 0.8453497751219741,
|
||
"matched_residual_db": 1.4648455366051558,
|
||
"band_gain_nonuniformity_db": 5.471381501203027,
|
||
}
|
||
lab062_result = json.loads(
|
||
(ROOT / "data/processed/lab062/rf_analysis.json").read_text(encoding="utf-8")
|
||
)["lab058_quality"]
|
||
lab062 = {
|
||
"symbol_error_rate": lab062_result["symbol_error_rate"],
|
||
"rms_symbol_deviation": lab062_result["evm_rms"],
|
||
"matched_residual_db": lab062_result["matched_residual_db"],
|
||
"band_gain_nonuniformity_db": lab062_result[
|
||
"band_gain_nonuniformity_p95_p05_db_median"
|
||
],
|
||
"capture_status": "RF_SESSION_INVALID_TX_UNDERRUN",
|
||
}
|
||
|
||
def differences(reference: dict) -> dict:
|
||
return {
|
||
"symbol_error_rate_absolute": (
|
||
physical["symbol_error_rate"] - reference["symbol_error_rate"]
|
||
),
|
||
"rms_symbol_deviation_absolute": (
|
||
physical["rms_symbol_deviation"] - reference["rms_symbol_deviation"]
|
||
),
|
||
"matched_residual_db": (
|
||
physical["matched_residual_db"] - reference["matched_residual_db"]
|
||
),
|
||
"band_gain_nonuniformity_db": (
|
||
physical["band_gain_nonuniformity_p95_p05_db_median"]
|
||
- reference["band_gain_nonuniformity_db"]
|
||
),
|
||
}
|
||
|
||
return {
|
||
"lab": "Lab066", "capture_valid": result["capture_valid"],
|
||
"legacy": {key: legacy[key] for key in (
|
||
"candidates", "header_pass", "fec_started", "fec_pass", "crc_started",
|
||
"crc_pass", "packet_output", "exact_payload_match", "packet_indexes",
|
||
)},
|
||
"t2": {key: t2[key] for key in (
|
||
"candidates", "header_pass", "fec_started", "fec_pass", "crc_started",
|
||
"crc_pass", "packet_output", "exact_payload_match", "packet_indexes",
|
||
)},
|
||
"passed_by_both": sorted(legacy_set & t2_set),
|
||
"passed_only_by_legacy": sorted(legacy_set - t2_set),
|
||
"passed_only_by_t2": sorted(t2_set - legacy_set),
|
||
"failed_by_both": sorted(set(range(1, PACKETS + 1)) - legacy_set - t2_set),
|
||
"packet_rows": packet_rows, "physical": physical,
|
||
"lab056_B": lab056, "difference_from_lab056_B": differences(lab056),
|
||
"lab062": lab062, "difference_from_lab062": differences(lab062),
|
||
"classification": "PARTIAL_PACKET_RECOVERY_NO_T2_GAIN",
|
||
"primary_result": (
|
||
"Аппаратно валидный Lab066 дал одинаковые 10/16 точных пакетов "
|
||
"в legacy и T2; физическое качество лучше Lab056 B и невалидной Lab062."
|
||
),
|
||
"recommended_next_single_step": (
|
||
"Отдельно разрешить офлайн-сравнение header-ошибок пакетов "
|
||
"1-3, 9, 15-16 с принятыми пакетами по сохранённому RX; новый TX не нужен."
|
||
),
|
||
}
|
||
|
||
|
||
def write_summary_outputs(result: dict) -> dict:
|
||
summary = summarize_analysis(result)
|
||
save_new(OUTPUT / "rf_summary.json", summary)
|
||
with (OUTPUT / "packet_comparison.csv").open("x", newline="", encoding="utf-8-sig") as stream:
|
||
writer = csv.writer(stream)
|
||
writer.writerow([
|
||
"packet_index", "legacy_header", "legacy_fec", "legacy_crc",
|
||
"legacy_packet_output", "t2_header", "t2_fec", "t2_crc", "t2_packet_output",
|
||
])
|
||
for row in summary["packet_rows"]:
|
||
writer.writerow([
|
||
row["packet_index"], row["legacy"]["header"], row["legacy"]["fec"],
|
||
row["legacy"]["crc"], row["legacy"]["packet_output"],
|
||
row["t2"]["header"], row["t2"]["fec"], row["t2"]["crc"],
|
||
row["t2"]["packet_output"],
|
||
])
|
||
return summary
|
||
|
||
|
||
def analyze_saved_capture() -> dict:
|
||
"""Строго обработать одну будущую RX-запись обоими режимами."""
|
||
metadata = validate_prepared()
|
||
capture = json.loads((RAW / "capture_result.json").read_text(encoding="utf-8"))
|
||
rx_path = RAW / "rx_stream.cs8"
|
||
if (
|
||
capture.get("lab") != "Lab066"
|
||
or capture.get("rf_tx_count") != 1
|
||
or capture.get("automatic_tx_repeats") != 0
|
||
or capture.get("point") != asdict(POINT)
|
||
or capture.get("parameters") != parameters()
|
||
or capture.get("tx_sha256") != metadata["tx_sha256"]
|
||
or capture.get("rx_sha256") != sha256(rx_path)
|
||
):
|
||
raise RuntimeError("Нет подтверждённой единственной RX-записи Lab066")
|
||
validity = lab064.capture_validity(capture)
|
||
if not validity["valid"]:
|
||
result = {
|
||
"lab": "Lab066", "stage": "STRICT_DUAL_OFFLINE_ANALYSIS",
|
||
"classification": "INVALID_CAPTURE", "capture_valid": False,
|
||
"capture_invalid_reasons": validity["invalid_reasons"],
|
||
"rf_tx_count": capture.get("rf_tx_count"), "automatic_tx_repeats": 0,
|
||
}
|
||
save_new(OUTPUT / "rf_result.json", result)
|
||
return result
|
||
legacy = decode("legacy", rx_path, diagnostics=True)
|
||
t2 = decode("t2", rx_path, diagnostics=True)
|
||
strict_runs_complete = (
|
||
legacy.get("candidate_overflow") == 0
|
||
and t2.get("candidate_overflow") == 0
|
||
and isinstance(legacy.get("rows"), list)
|
||
and isinstance(t2.get("rows"), list)
|
||
)
|
||
if not strict_runs_complete:
|
||
raise RuntimeError("Строгие legacy/T2 прогоны не завершены; oracle-диагностика запрещена")
|
||
tx = lab058.Cs8File(RAW / "tx_stream.cs8")
|
||
rx = lab058.Cs8File(rx_path)
|
||
location = locate_first_packet(tx, rx)
|
||
result = {
|
||
"lab": "Lab066", "stage": "STRICT_DUAL_OFFLINE_ANALYSIS",
|
||
"tx_sha256": metadata["tx_sha256"], "rx_sha256": capture["rx_sha256"],
|
||
"capture_valid": validity["valid"], "capture_invalid_reasons": validity["invalid_reasons"],
|
||
"run_a_legacy": legacy, "run_b_t2": t2,
|
||
"same_rx_capture": True, "fallback_between_modes": False,
|
||
"strict_runs_complete": True,
|
||
"location_after_strict_runs": location,
|
||
"physical_diagnostics_scope": "KNOWN_TX_ONLY_AFTER_COMPLETE_STRICT_RUNS",
|
||
"physical_diagnostics": physical_packet_diagnostics(tx, rx, location),
|
||
"comparison_targets": ["Lab056 B", "Lab062", "Lab066"],
|
||
}
|
||
save_new(OUTPUT / "rf_result.json", result)
|
||
return result
|
||
|
||
|
||
def summarize_saved_analysis() -> dict:
|
||
return write_summary_outputs(json.loads(
|
||
(OUTPUT / "rf_result.json").read_text(encoding="utf-8")
|
||
))
|
||
|
||
|
||
def run_functional_tests() -> None:
|
||
assert EXPECTED_TX_SAMPLES == 3_676_096
|
||
assert transfer_command("NUL", 100, transmit=True)[-7:-5] == ["-x", "20"]
|
||
assert "-t" not in transfer_command("NUL", 100)
|
||
try:
|
||
capture_once(authorization=False)
|
||
except PermissionError:
|
||
pass
|
||
else:
|
||
raise AssertionError("RF без отдельного разрешения")
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument(
|
||
"--stage",
|
||
choices=("prepare", "software", "preflight", "capture", "analyze", "summarize"),
|
||
required=True,
|
||
)
|
||
parser.add_argument("--authorize-one-rf", action="store_true")
|
||
arguments = parser.parse_args()
|
||
if arguments.stage == "prepare": result = prepare()
|
||
elif arguments.stage == "software": result = software_gate()
|
||
elif arguments.stage == "preflight": result = preflight()
|
||
elif arguments.stage == "capture": result = capture_once(authorization=arguments.authorize_one_rf)
|
||
elif arguments.stage == "analyze": result = analyze_saved_capture()
|
||
else: result = summarize_saved_analysis()
|
||
print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))
|
||
print("RF TX NOW = 0")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|