Files
SDR-Rover/experiments/lab063_tx_underrun_localization.py
2026-09-14 15:11:46 +03:00

568 lines
29 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.
"""Lab063: офлайн-локализация TX underrun и ухудшения качества Lab062."""
from __future__ import annotations
from dataclasses import dataclass
import csv
import json
import math
from pathlib import Path
import re
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import matplotlib.pyplot as plt
import numpy as np
from scipy import signal
from experiments import lab058_abab_symbol_quality as lab058
from experiments import lab062_full_packet_rf_preflight as lab062
ROOT = Path(__file__).resolve().parents[1]
RAW = ROOT / "data/raw/lab062"
LAB062 = ROOT / "data/processed/lab062"
OUTPUT = ROOT / "data/processed/lab063"
TX_PATH = RAW / "tx_stream.cs8"
RX_PATH = RAW / "rx_stream.cs8"
FRAME_SAMPLES = 92_256
PACKETS = 16
SAMPLE_RATE = 8_000_000
SYMBOL_RATE = 500_000
WINDOW_SYMBOLS = 512
WINDOW_SAMPLES = WINDOW_SYMBOLS * (SAMPLE_RATE // SYMBOL_RATE)
STEP_SYMBOLS = 256
STEP_SAMPLES = STEP_SYMBOLS * (SAMPLE_RATE // SYMBOL_RATE)
SEARCH_MARGIN_SAMPLES = 12_000
MIN_LOCAL_CORRELATION = 0.15
RELIABLE_PACKET_START = 24_000
RELIABLE_PACKET_END = 86_000
LAB056_B = {
"symbol_error_rate": 0.03495896,
"evm_rms": 0.845350,
"matched_residual_db": 1.464846,
"band_ripple_db": 5.471382,
}
@dataclass(frozen=True)
class ArrayReader:
"""Минимальный интерфейс чтения для автономных проверок алгоритма."""
values: np.ndarray
@property
def size(self) -> int:
return int(len(self.values))
def read(self, start: int, count: int) -> np.ndarray:
return np.asarray(self.values[start:start + count], dtype=np.complex64)
def underrun_scale(byte_count: int = 16_672) -> dict:
if type(byte_count) is not int or byte_count <= 0 or byte_count % 2:
raise ValueError("Размер cs8 underrun должен быть положительным чётным числом байт")
samples = byte_count // 2
duration = samples / SAMPLE_RATE
symbols = samples * SYMBOL_RATE / SAMPLE_RATE
return {
"bytes": byte_count,
"complex_samples": samples,
"duration_seconds": duration,
"duration_milliseconds": duration * 1000,
"symbols": symbols,
"fraction_of_packet": samples / FRAME_SAMPLES,
"percent_of_packet": 100 * samples / FRAME_SAMPLES,
"interpretation": "Scale only; RX does not prove that this many RF samples were lost or repeated",
}
def actual_gap() -> dict:
metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8"))
total = int(metadata["tx_samples"])
expected = FRAME_SAMPLES * PACKETS
return {
"tx_samples": total,
"packets": PACKETS,
"packet_waveform_samples": FRAME_SAMPLES,
"packet_product_samples": expected,
"identity_confirmed": total == expected,
"additional_inter_packet_gap_samples": total - expected,
"additional_inter_packet_gap_seconds": (total - expected) / SAMPLE_RATE,
"actual": "0 additional samples (no planned 5 ms gaps)",
"reason": (
"IMPLEMENTATION_OMISSION: prepare() writes the 16 complete packet waveforms "
"back-to-back and has no gap parameter or zero-fill operation"
),
"packet_internal_note": "Each packet waveform still contains its own guards and RRC tails",
}
def normalized_match(transmitted: np.ndarray, search: np.ndarray) -> dict:
"""Найти локальный участок RX с нормировкой по AC-энергии каждого положения."""
transmitted = np.asarray(transmitted, dtype=np.complex128)
search = np.asarray(search, dtype=np.complex128)
if transmitted.ndim != 1 or search.ndim != 1 or len(search) < len(transmitted):
raise ValueError("Нужны одномерные TX и более длинное RX-окно")
centered_tx = transmitted - np.mean(transmitted)
tx_energy = float(np.vdot(centered_tx, centered_tx).real)
if tx_energy <= 0:
raise ValueError("TX-окно не содержит AC-энергии")
correlation = signal.correlate(search, centered_tx, mode="valid", method="fft")
count = len(centered_tx)
kernel = np.ones(count, dtype=np.float64)
sums = signal.fftconvolve(search, kernel, mode="valid")
powers = signal.fftconvolve(abs(search) ** 2, kernel, mode="valid")
rx_energies = np.maximum(powers - abs(sums) ** 2 / count, 0.0)
normalized = abs(correlation) / np.sqrt(np.maximum(tx_energy * rx_energies, 1e-30))
best = int(np.argmax(normalized))
centered_rx = search[best:best + count] - np.mean(search[best:best + count])
gain = complex(np.vdot(centered_tx, centered_rx) / tx_energy)
residual = centered_rx - gain * centered_tx
return {
"offset": best,
"normalized_correlation": float(normalized[best]),
"gain": gain,
"gain_amplitude": float(abs(gain)),
"phase_rad": float(np.angle(gain)),
"residual_power": float(np.mean(abs(residual) ** 2)),
"rx_ac_power": float(np.mean(abs(centered_rx) ** 2)),
}
def track_alignment(tx, rx, rx_start: int, *, frame_samples: int = FRAME_SAMPLES,
window_samples: int = WINDOW_SAMPLES,
step_samples: int = STEP_SAMPLES,
search_margin: int = SEARCH_MARGIN_SAMPLES,
reliable_packet_start: int = RELIABLE_PACKET_START,
reliable_packet_end: int = RELIABLE_PACKET_END) -> list[dict]:
"""Сопоставить короткие перекрывающиеся TX-окна с RX независимо по времени."""
rows = []
for tx_start in range(0, tx.size - window_samples + 1, step_samples):
expected_rx = rx_start + tx_start
low = max(0, expected_rx - search_margin)
high = min(rx.size, expected_rx + search_margin + window_samples)
match = normalized_match(tx.read(tx_start, window_samples), rx.read(low, high - low))
matched_rx = low + match.pop("offset")
packet = min(PACKETS, tx_start // frame_samples + 1)
position_inside_packet = tx_start - (packet - 1) * frame_samples
inside_reliable_region = reliable_packet_start <= position_inside_packet <= reliable_packet_end
correlation_above_threshold = match["normalized_correlation"] >= MIN_LOCAL_CORRELATION
rows.append({
"window_index": len(rows),
"tx_sample": tx_start,
"rx_sample": matched_rx,
"time_seconds": tx_start / SAMPLE_RATE,
"local_delay_samples": matched_rx - expected_rx,
"packet_index": packet,
"position_inside_packet_samples": position_inside_packet,
"inside_reliable_region": inside_reliable_region,
"correlation_above_threshold": correlation_above_threshold,
"reliable_for_delay": inside_reliable_region and correlation_above_threshold,
**match,
})
return rows
def alignment_verdict(rows: list[dict], expected_jump: int) -> dict:
valid = [row for row in rows if row.get("reliable_for_delay") is True]
internal_low_correlation = [
row for row in rows
if row.get("inside_reliable_region") is True
and row.get("correlation_above_threshold") is False
]
jumps = []
for left, right in zip(valid, valid[1:]):
change = right["local_delay_samples"] - left["local_delay_samples"]
if abs(change) >= max(256, expected_jump // 4):
jumps.append({
"left_window": left["window_index"], "right_window": right["window_index"],
"tx_sample": right["tx_sample"], "rx_sample": right["rx_sample"],
"packet_index": right["packet_index"],
"position_inside_packet_samples": right["position_inside_packet_samples"],
"delay_change_samples": change,
"left_correlation": left["normalized_correlation"],
"right_correlation": right["normalized_correlation"],
})
delays = np.asarray([row["local_delay_samples"] for row in valid], dtype=np.float64)
correlations = np.asarray([row["normalized_correlation"] for row in rows], dtype=np.float64)
return {
"valid_windows": len(valid),
"total_windows": len(rows),
"minimum_correlation": float(np.min(correlations)),
"median_correlation": float(np.median(correlations)),
"maximum_correlation": float(np.max(correlations)),
"delay_min_samples": int(np.min(delays)) if len(delays) else None,
"delay_median_samples": float(np.median(delays)) if len(delays) else None,
"delay_max_samples": int(np.max(delays)) if len(delays) else None,
"jump_candidates": jumps,
"local_delay_discontinuity": bool(jumps),
"local_delay_discontinuity_scope": (
f"NO_IN_{len(valid)}_RELIABLE_WINDOWS; "
f"UNDETERMINED_IN_{len(internal_low_correlation)}_INTERNAL_LOW_CORRELATION_WINDOWS"
if not jumps else "DETECTED_IN_RELIABLE_WINDOWS"
),
"underrun_location": "NOT_LOCALIZED_FROM_RX" if not jumps else jumps[0],
"reliable_region_inside_packet_samples": [RELIABLE_PACKET_START, RELIABLE_PACKET_END],
"correlation_threshold": MIN_LOCAL_CORRELATION,
"internal_low_correlation_windows": {
"count": len(internal_low_correlation),
"packet_counts": {
str(packet): sum(row["packet_index"] == packet for row in internal_low_correlation)
for packet in sorted({row["packet_index"] for row in internal_low_correlation})
},
"windows": [
{
"window_index": row["window_index"],
"packet_index": row["packet_index"],
"tx_sample": row["tx_sample"],
"rx_sample_selected_but_not_used_for_delay_verdict": row["rx_sample"],
"position_inside_packet_samples": row["position_inside_packet_samples"],
"normalized_correlation": row["normalized_correlation"],
}
for row in internal_low_correlation
],
"interpretation": (
"Delay is not established in these windows; low correlation is itself a local anomaly, "
"but it does not identify an underrun without a reliable timing discontinuity"
),
},
"excluded_region_reason": (
"Packet edges and repeated PRBS preamble have correlation aliases spaced by 2032 samples; "
"22 additional internal windows are below the 0.15 correlation threshold. All are retained "
"in CSV, and delay is not asserted for any excluded window"
),
}
def moments(values: np.ndarray) -> dict:
values = np.asarray(values)
mean = complex(np.mean(values, dtype=np.complex128))
power = float(np.mean(abs(values.astype(np.complex128)) ** 2))
ac_power = max(0.0, power - abs(mean) ** 2)
return {
"raw_power_code2": power,
"rms_full_iq_code": math.sqrt(power),
"mean_i": float(mean.real), "mean_q": float(mean.imag),
"ac_power_code2": ac_power, "ac_rms_code": math.sqrt(ac_power),
"max_abs_i": float(np.max(abs(values.real))),
"max_abs_q": float(np.max(abs(values.imag))),
"max_complex_magnitude": float(np.max(abs(values))),
}
def packet_quality(tx, rx, rx_start: int, background_ac_power: float,
lab062_analysis: dict, alignment: list[dict]) -> list[dict]:
rows = []
physical = lab062_analysis["lab058_quality"]["packets"]
for index in range(1, PACKETS + 1):
values = rx.read(rx_start + (index - 1) * FRAME_SAMPLES, FRAME_SAMPLES)
level = moments(values)
metrics = physical[index - 1]["oracle"]["metrics"]
channel = physical[index - 1]["channel"]
local = [row for row in alignment if row["packet_index"] == index and row["reliable_for_delay"]]
phases = np.unwrap([row["phase_rad"] for row in local]) if local else np.empty(0)
row = {
"packet_index": index,
**level,
"background_ac_power_code2": background_ac_power,
"background_subtracted_useful_power_code2": max(0.0, level["ac_power_code2"] - background_ac_power),
"symbol_error_rate": metrics["ber"],
"symbol_rms_deviation": metrics["evm_rms"],
"matched_component_power": metrics["matched_component_power"],
"residual_power": metrics["residual_power"],
"matched_residual_db": metrics["matched_residual_db"],
"band_gain_nonuniformity_db": channel["amplitude_ripple_p95_p05_db"],
"initial_frequency_hz": physical[index - 1]["oracle"]["cfo_hz"],
"diagnostic_residual_phase_error_rad": channel["residual_phase_ripple_p95_p05_rad"],
"local_delay_median_samples": float(np.median([item["local_delay_samples"] for item in local])),
"local_delay_min_samples": int(min(item["local_delay_samples"] for item in local)),
"local_delay_max_samples": int(max(item["local_delay_samples"] for item in local)),
"local_correlation_median": float(np.median([item["normalized_correlation"] for item in local])),
"local_correlation_min": float(min(item["normalized_correlation"] for item in local)),
"local_gain_amplitude_median": float(np.median([item["gain_amplitude"] for item in local])),
"local_phase_unwrapped_span_rad": float(np.ptp(phases)) if len(phases) else None,
"ser_minus_lab056_b": metrics["ber"] - LAB056_B["symbol_error_rate"],
"evm_minus_lab056_b": metrics["evm_rms"] - LAB056_B["evm_rms"],
"matched_residual_minus_lab056_b_db": metrics["matched_residual_db"] - LAB056_B["matched_residual_db"],
"ripple_minus_lab056_b_db": channel["amplitude_ripple_p95_p05_db"] - LAB056_B["band_ripple_db"],
}
rows.append(row)
return rows
def group_trends(rows: list[dict]) -> list[dict]:
result = []
for low in (1, 5, 9, 13):
selected = rows[low - 1:low + 3]
result.append({
"packets": f"{low}-{low + 3}",
"useful_power_mean": float(np.mean([row["background_subtracted_useful_power_code2"] for row in selected])),
"symbol_error_rate_mean": float(np.mean([row["symbol_error_rate"] for row in selected])),
"symbol_rms_deviation_mean": float(np.mean([row["symbol_rms_deviation"] for row in selected])),
"matched_component_power_mean": float(np.mean([row["matched_component_power"] for row in selected])),
"residual_power_mean": float(np.mean([row["residual_power"] for row in selected])),
"matched_residual_db_mean": float(np.mean([row["matched_residual_db"] for row in selected])),
"initial_frequency_hz_mean": float(np.mean([row["initial_frequency_hz"] for row in selected])),
"diagnostic_residual_phase_error_rad_mean": float(np.mean(
[row["diagnostic_residual_phase_error_rad"] for row in selected]
)),
"local_phase_unwrapped_span_rad_mean": float(np.mean(
[row["local_phase_unwrapped_span_rad"] for row in selected]
)),
"local_gain_amplitude_median": float(np.median([row["local_gain_amplitude_median"] for row in selected])),
})
return result
def classify(packet_rows: list[dict], alignment: dict) -> dict:
worse = [
row for row in packet_rows
if row["symbol_error_rate"] > LAB056_B["symbol_error_rate"]
and row["symbol_rms_deviation"] > LAB056_B["evm_rms"]
and row["matched_residual_db"] < LAB056_B["matched_residual_db"]
]
global_degradation = len(worse) >= 12
discontinuity = alignment["local_delay_discontinuity"]
if discontinuity and global_degradation:
distribution, primary = "MIXED", "PARTIALLY_LOCALIZED"
elif discontinuity:
distribution, primary = "LOCALIZED", "PARTIALLY_LOCALIZED"
elif global_degradation:
distribution, primary = "GLOBAL", "PARTIALLY_LOCALIZED"
else:
distribution, primary = "NOT_LOCALIZED", "NOT_LOCALIZED"
return {
"quality_distribution": distribution,
"primary_cause_status": primary,
"packets_worse_than_lab056_on_ser_evm_and_matched_residual": [row["packet_index"] for row in worse],
"global_degradation_rule": "at least 12/16 packets worse than Lab056 B on SER, EVM and matched/residual",
"global_degradation": global_degradation,
"local_delay_discontinuity": discontinuity,
}
def write_csv(path: Path, rows: list[dict]) -> None:
with path.open("w", newline="", encoding="utf-8-sig") as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
for row in rows:
writer.writerow({key: (json.dumps(value) if isinstance(value, (dict, list)) else value)
for key, value in row.items()})
def plots(alignment: list[dict], packets: list[dict]) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
time_ms = np.asarray([row["time_seconds"] * 1000 for row in alignment])
packet_indexes = np.arange(1, PACKETS + 1)
def line(name: str, values, ylabel: str, title: str) -> None:
figure, axis = plt.subplots(figsize=(9, 4.5))
axis.plot(time_ms, values, linewidth=1.2)
axis.set(xlabel="Время TX, мс", ylabel=ylabel, title=title)
axis.grid(True, alpha=0.3); figure.tight_layout()
figure.savefig(OUTPUT / name, dpi=160); plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 4.5))
unreliable = [row for row in alignment if not row["reliable_for_delay"]]
reliable = [row for row in alignment if row["reliable_for_delay"]]
axis.scatter([row["time_seconds"] * 1000 for row in unreliable],
[row["local_delay_samples"] for row in unreliable], s=7, alpha=0.25,
label="исключено: края, преамбула или низкая корреляция")
axis.plot([row["time_seconds"] * 1000 for row in reliable],
[row["local_delay_samples"] for row in reliable], linewidth=1.2,
label="надёжная внутренняя часть")
axis.set(xlabel="Время TX, мс", ylabel="Локальная задержка, отсчёты",
title="Локальная временная привязка TX и RX")
axis.grid(True, alpha=0.3); axis.legend(); figure.tight_layout()
figure.savefig(OUTPUT / "local_delay_vs_time.png", dpi=160); plt.close(figure)
line("normalized_correlation_vs_time.png", [row["normalized_correlation"] for row in alignment],
"Нормированная корреляция", "Устойчивость локального сопоставления")
reliable_time_ms = np.asarray([row["time_seconds"] * 1000 for row in reliable])
figure, axis = plt.subplots(figsize=(9, 4.5))
axis.plot(reliable_time_ms, [row["gain_amplitude"] for row in reliable], linewidth=1.2)
axis.set(xlabel="Время TX, мс", ylabel="|h|, коды RX / код TX",
title="Локальная амплитуда канала в надёжных окнах")
axis.grid(True, alpha=0.3); figure.tight_layout()
figure.savefig(OUTPUT / "gain_vs_time.png", dpi=160); plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 4.5))
phase = np.unwrap([row["phase_rad"] for row in reliable])
axis.plot(reliable_time_ms, phase, linewidth=1.2)
axis.set(xlabel="Время TX, мс", ylabel="Развёрнутая фаза, рад",
title="Локальная фаза канала в надёжных окнах")
axis.grid(True, alpha=0.3); figure.tight_layout()
figure.savefig(OUTPUT / "phase_vs_time.png", dpi=160); plt.close(figure)
def bars(name: str, values, ylabel: str, title: str, baseline=None) -> None:
figure, axis = plt.subplots(figsize=(9, 4.5))
axis.bar(packet_indexes, values)
if baseline is not None:
axis.axhline(baseline, color="tab:red", linestyle="--", label="Lab056 B")
axis.legend()
axis.set(xlabel="Номер пакета", ylabel=ylabel, title=title, xticks=packet_indexes)
axis.grid(True, axis="y", alpha=0.3); figure.tight_layout()
figure.savefig(OUTPUT / name, dpi=160); plt.close(figure)
bars("received_power_by_packet.png", [row["background_subtracted_useful_power_code2"] for row in packets],
"Полезная AC-мощность, код²", "Принятая мощность по пакетам")
bars("symbol_error_rate_by_packet.png", [100 * row["symbol_error_rate"] for row in packets],
"Ошибочные символы, %", "Диагностическая SER по пакетам", 100 * LAB056_B["symbol_error_rate"])
bars("matched_residual_by_packet.png", [row["matched_residual_db"] for row in packets],
"Matched/residual, дБ", "Полезная согласованная составляющая к остатку", LAB056_B["matched_residual_db"])
figure, axes = plt.subplots(2, 2, figsize=(11, 7))
comparisons = [
("SER, %", [100 * row["symbol_error_rate"] for row in packets], 100 * LAB056_B["symbol_error_rate"]),
("EVM RMS", [row["symbol_rms_deviation"] for row in packets], LAB056_B["evm_rms"]),
("Matched/residual, дБ", [row["matched_residual_db"] for row in packets], LAB056_B["matched_residual_db"]),
("Неравномерность, дБ", [row["band_gain_nonuniformity_db"] for row in packets], LAB056_B["band_ripple_db"]),
]
for axis, (label, values, baseline) in zip(axes.flat, comparisons):
axis.bar(packet_indexes, values); axis.axhline(baseline, color="tab:red", linestyle="--")
axis.set(xlabel="Пакет", ylabel=label, xticks=packet_indexes); axis.grid(True, axis="y", alpha=0.3)
figure.suptitle("Lab056 B и пакеты Lab062"); figure.tight_layout()
figure.savefig(OUTPUT / "lab056_b_vs_lab062_packets.png", dpi=160); plt.close(figure)
def local_counter_semantics() -> dict:
binary = Path(r"C:\Users\user\Tools\HackRF\Library\bin\hackrf_transfer.exe")
source_candidates = list(binary.parent.parent.rglob("hackrf_transfer.c")) if binary.parent.parent.exists() else []
return {
"binary_path": str(binary), "binary_exists": binary.exists(),
"local_source_files": [str(path) for path in source_candidates],
"progress_line": "1 underruns, longest 16672 bytes",
"final_line": "0 underruns, longest 0 bytes",
"counter_semantics": "NOT_ESTABLISHED" if not source_candidates else "LOCAL_SOURCE_REQUIRES_REVIEW",
"note": "No local source or documentation was found that explains the reset between progress and final counters",
}
def run() -> dict:
OUTPUT.mkdir(parents=True, exist_ok=True)
lab062_analysis = json.loads((LAB062 / "rf_analysis.json").read_text(encoding="utf-8"))
if lab062_analysis["status"] != "RF_SESSION_INVALID_TX_UNDERRUN":
raise RuntimeError("Lab062 must remain INVALID_TX_UNDERRUN")
tx, rx = lab058.Cs8File(TX_PATH), lab058.Cs8File(RX_PATH)
rx_start = int(lab062_analysis["location"]["rx_start_sample"])
gap = actual_gap()
if not gap["identity_confirmed"] or gap["additional_inter_packet_gap_samples"] != 0:
raise RuntimeError("Unexpected Lab062 TX accounting")
scale = underrun_scale()
alignment_rows = track_alignment(tx, rx, rx_start)
alignment = alignment_verdict(alignment_rows, scale["complex_samples"])
before = moments(rx.read(rx_start - FRAME_SAMPLES, FRAME_SAMPLES))
after = moments(rx.read(rx_start + tx.size, FRAME_SAMPLES))
background_ac_power = 0.5 * (before["ac_power_code2"] + after["ac_power_code2"])
packet_rows = packet_quality(tx, rx, rx_start, background_ac_power, lab062_analysis, alignment_rows)
trends = group_trends(packet_rows)
classification = classify(packet_rows, alignment)
most_affected = sorted(
packet_rows,
key=lambda row: (row["symbol_error_rate"], row["symbol_rms_deviation"], -row["matched_residual_db"]),
reverse=True,
)[:4]
selected = {3, 4, 12, 14}
special = [row for row in packet_rows if row["packet_index"] in selected]
special_common = {
"packets": sorted(selected),
"all_have_power_drop_below_median": all(
row["background_subtracted_useful_power_code2"] < np.median(
[item["background_subtracted_useful_power_code2"] for item in packet_rows]
) for row in special
),
"all_have_correlation_below_median": all(
row["local_correlation_median"] < np.median([item["local_correlation_median"] for item in packet_rows])
for row in special
),
"all_have_large_delay_change": all(
row["local_delay_max_samples"] - row["local_delay_min_samples"] >= scale["complex_samples"] // 4
for row in special
),
"interpretation": "No common underrun signature unless one of all three booleans is true with localized alignment evidence",
}
result = {
"lab": "Lab063", "status": "COMPLETED_OFFLINE", "rf_tx": 0,
"hardware_queries": 0, "lab062_status_preserved": lab062_analysis["status"],
"lab062_commit": "f8ae6c6bfceaa21b6c08dcf55d2bff996dd0dae5",
"physical_device": "Auron HackRF Plus",
"api_firmware_identification": "HackRF One r9",
"integrity": {
"tx_sha256": lab062.sha256(TX_PATH), "rx_sha256": lab062.sha256(RX_PATH),
"tx_samples": tx.size, "rx_samples": rx.size,
},
"actual_inter_packet_gap": gap,
"tx_underrun_scale": scale,
"tx_underrun_log": local_counter_semantics(),
"background": {"before": before, "after": after, "ac_power_code2_used": background_ac_power,
"preflight_ac_rms_code": 0.9854828456229909},
"local_alignment": alignment,
"packet_quality": packet_rows,
"group_trends": trends,
"special_packets_3_4_12_14": special_common,
"packets_most_affected": [row["packet_index"] for row in most_affected],
"active_rx_level": {
"ac_rms_min": min(row["ac_rms_code"] for row in packet_rows),
"ac_rms_max": max(row["ac_rms_code"] for row in packet_rows),
"max_abs_i_min": min(row["max_abs_i"] for row in packet_rows),
"max_abs_i_max": max(row["max_abs_i"] for row in packet_rows),
"max_abs_q_min": min(row["max_abs_q"] for row in packet_rows),
"max_abs_q_max": max(row["max_abs_q"] for row in packet_rows),
"mean_i_min": min(row["mean_i"] for row in packet_rows),
"mean_i_max": max(row["mean_i"] for row in packet_rows),
"mean_q_min": min(row["mean_q"] for row in packet_rows),
"mean_q_max": max(row["mean_q"] for row in packet_rows),
"local_gain_amplitude_median_min": min(row["local_gain_amplitude_median"] for row in packet_rows),
"local_gain_amplitude_median_max": max(row["local_gain_amplitude_median"] for row in packet_rows),
"analog_overload": "LESS_LIKELY_FROM_DIGITAL_LEVEL_BUT_NOT_EXCLUDED_WITHOUT_INSTRUMENT",
},
"lab056_comparison": {"reference": LAB056_B,
"packets_worse_on_all_three_core_metrics": classification["packets_worse_than_lab056_on_ser_evm_and_matched_residual"]},
**classification,
"next_single_step": (
"Before another TX, investigate the low active RX level, gain/geometry and possible analogue compression; "
"also change TX streaming/logging so any underrun is unambiguous. Do not change RF parameters yet."
if classification["quality_distribution"] == "GLOBAL" else
"Improve TX streaming and underrun logging before any separately authorized repeat."
),
"lab063_commit": "NOT_PERFORMED", "push": "NOT_PERFORMED", "lab064": "NOT_CREATED",
}
write_csv(OUTPUT / "local_alignment.csv", alignment_rows)
write_csv(OUTPUT / "packet_quality.csv", packet_rows)
plots(alignment_rows, packet_rows)
(OUTPUT / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
def run_functional_tests() -> None:
scale = underrun_scale()
assert scale["complex_samples"] == 8336
assert abs(scale["duration_milliseconds"] - 1.042) < 1e-12
assert scale["symbols"] == 521.0
transmitted = np.exp(1j * np.arange(128) * 0.31)
search = np.concatenate((np.zeros(37, complex), (0.4 + 0.2j) * transmitted, np.zeros(19, complex)))
result = normalized_match(transmitted, search)
assert result["offset"] == 37 and result["normalized_correlation"] > 0.999999
def main() -> None:
result = run()
print(json.dumps({
"status": result["status"],
"actual_inter_packet_gap": result["actual_inter_packet_gap"],
"tx_underrun_scale": result["tx_underrun_scale"],
"local_alignment": result["local_alignment"],
"packets_most_affected": result["packets_most_affected"],
"active_rx_level": result["active_rx_level"],
"quality_distribution": result["quality_distribution"],
"primary_cause_status": result["primary_cause_status"],
"next_single_step": result["next_single_step"],
"rf_tx": result["rf_tx"],
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()