Lab063: localize Lab062 RF quality degradation offline

This commit is contained in:
LittleSam129
2026-09-14 15:11:46 +03:00
parent f8ae6c6bfc
commit 4437917465
17 changed files with 2374 additions and 0 deletions

View File

@@ -0,0 +1,567 @@
"""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()