Files
SDR-Rover/experiments/lab066_full_packet_rf_retest.py

787 lines
37 KiB
Python
Raw Blame History

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