"""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()