Lab062: record invalidated 500 ksymbol/s RF packet trial
This commit is contained in:
330
experiments/lab062_rf_analysis.py
Normal file
330
experiments/lab062_rf_analysis.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Lab062: офлайн-анализ единственной сохранённой RX IQ-записи."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
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 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
|
||||
|
||||
|
||||
def counter_values(text: str, name: str) -> list[int]:
|
||||
pattern = re.compile(rf"(?<![\w.+-])(\d+)\s+(?:(?:rx|tx)\s+)?{name}s?\b", re.I)
|
||||
return [int(match.group(1)) for match in pattern.finditer(text)]
|
||||
|
||||
|
||||
def locate_burst(tx: lab058.Cs8File, rx: lab058.Cs8File, frame_samples: int) -> dict:
|
||||
"""Найти начало по первому полному пакету, не передавая границы декодеру."""
|
||||
stride = lab062.MODE.samples_per_symbol
|
||||
tx_raw = np.memmap(tx.path, np.int8, mode="r")
|
||||
rx_raw = np.memmap(rx.path, np.int8, mode="r")
|
||||
tx_decimated = (
|
||||
tx_raw[0:2 * frame_samples:2 * stride].astype(np.float32)
|
||||
+ 1j * tx_raw[1:2 * frame_samples:2 * stride].astype(np.float32)
|
||||
)
|
||||
rx_decimated = (
|
||||
rx_raw[0::2 * stride].astype(np.float32)
|
||||
+ 1j * rx_raw[1::2 * stride].astype(np.float32)
|
||||
)
|
||||
tx_decimated -= np.mean(tx_decimated)
|
||||
rx_decimated -= np.mean(rx_decimated)
|
||||
correlation = signal.correlate(rx_decimated, tx_decimated, mode="valid", method="fft")
|
||||
coarse = int(np.argmax(abs(correlation))) * stride
|
||||
first = tx.read(0, frame_samples).astype(np.complex128)
|
||||
first -= np.mean(first)
|
||||
candidates = range(max(0, coarse - 2 * stride), min(rx.size - frame_samples, coarse + 2 * stride) + 1)
|
||||
values = []
|
||||
for start in candidates:
|
||||
received = rx.read(start, frame_samples).astype(np.complex128)
|
||||
received -= np.mean(received)
|
||||
values.append(abs(np.vdot(first, received)))
|
||||
offset = int(np.argmax(values))
|
||||
start = candidates.start + offset
|
||||
median = float(np.median(abs(correlation)))
|
||||
return {
|
||||
"rx_start_sample": start,
|
||||
"coarse_start_sample": coarse,
|
||||
"correlation_peak_to_median": float(abs(correlation[coarse // stride]) / max(median, 1e-30)),
|
||||
"method": "TX first-frame correlation; used only after strict receiver verdict",
|
||||
}
|
||||
|
||||
|
||||
def strict_row(rows: list[dict], nominal: int) -> dict | None:
|
||||
if not rows:
|
||||
return None
|
||||
selected = min(rows, key=lambda row: abs(row["absolute_refined"] - nominal))
|
||||
return selected if abs(selected["absolute_refined"] - nominal) <= 64 else None
|
||||
|
||||
|
||||
def diagnostic_symbol_rows(rx: lab058.Cs8File, start: int, frame_samples: int,
|
||||
mode: str) -> list[dict]:
|
||||
processor = lab062.packet_processing.PacketProcessor(carrier_tracking_mode=mode)
|
||||
rows = []
|
||||
for index, frame in enumerate(lab062.plans(), 1):
|
||||
values = rx.read(start + (index - 1) * frame_samples, frame_samples).astype(np.complex64)
|
||||
context = lab062.packet_processing.PacketMeasure()
|
||||
token = lab062.packet_processing.CURRENT.set(context)
|
||||
try:
|
||||
probe = processor.extract(values, 0, frame.plan.frame_symbol_count)
|
||||
finally:
|
||||
lab062.packet_processing.CURRENT.reset(token)
|
||||
expected = 2.0 * frame.plan.frame_bits.astype(np.float64) - 1.0
|
||||
if not probe.passed or probe.normalized_real.size != expected.size:
|
||||
rows.append({"packet_index": index, "measurable": False, "reason": probe.reason})
|
||||
continue
|
||||
soft = np.asarray(probe.normalized_real, dtype=np.float64)
|
||||
errors = np.signbit(soft) != np.signbit(expected)
|
||||
p = lab062.phy.lab045.PREAMBLE_BIT_COUNT
|
||||
h = p + lab062.phy.lab045.PHY_HEADER_BIT_COUNT
|
||||
traces = context.cache.get("carrier_tracking_traces", ())
|
||||
final_frequency = None
|
||||
if traces and len(traces[-1].frequency_hz):
|
||||
final_frequency = float(traces[-1].frequency_hz[-1])
|
||||
rows.append({
|
||||
"packet_index": index,
|
||||
"measurable": True,
|
||||
"preamble_symbol_errors": int(np.sum(errors[:p])),
|
||||
"header_symbol_errors": int(np.sum(errors[p:h])),
|
||||
"payload_symbols_before_fec_errors": int(np.sum(errors[h:])),
|
||||
"symbols": int(len(expected)),
|
||||
"symbol_errors": int(np.sum(errors)),
|
||||
"symbol_error_rate": float(np.mean(errors)),
|
||||
"symbol_rms_deviation": float(np.sqrt(np.mean((soft - expected) ** 2))),
|
||||
"initial_frequency_hz": float(probe.carrier["cfo_hz"]),
|
||||
"t2_final_frequency_hz": final_frequency,
|
||||
"t2_lock_status": "NOT_DEFINED_BY_IMPLEMENTATION" if mode == "t2" else "NOT_APPLICABLE",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def waveform_quality(tx: lab058.Cs8File, rx: lab058.Cs8File, start: int) -> dict:
|
||||
transmitted = tx.read(0, tx.size).astype(np.complex128)
|
||||
received = rx.read(start, tx.size).astype(np.complex128)
|
||||
transmitted -= np.mean(transmitted)
|
||||
received -= np.mean(received)
|
||||
product = received * np.conj(transmitted)
|
||||
block = 4096
|
||||
count = len(product) // block
|
||||
block_values = np.mean(product[:count * block].reshape(count, block), axis=1)
|
||||
block_times = (np.arange(count) * block + 0.5 * block) / lab062.MODE.sample_rate_hz
|
||||
phase = np.unwrap(np.angle(block_values))
|
||||
weights = np.maximum(abs(block_values), 1e-12)
|
||||
design = np.column_stack((np.ones(count), block_times))
|
||||
root = np.sqrt(weights / np.max(weights))
|
||||
coefficients, *_ = np.linalg.lstsq(design * root[:, None], phase * root, rcond=None)
|
||||
frequency_hz = float(coefficients[1] / (2 * np.pi))
|
||||
times = np.arange(len(received)) / lab062.MODE.sample_rate_hz
|
||||
corrected = received * np.exp(-2j * np.pi * frequency_hz * times)
|
||||
gain = np.vdot(transmitted, corrected) / np.vdot(transmitted, transmitted)
|
||||
matched = gain * transmitted
|
||||
residual = corrected - matched
|
||||
matched_power = float(np.mean(abs(matched) ** 2))
|
||||
residual_power = float(np.mean(abs(residual) ** 2))
|
||||
|
||||
frequencies, tx_psd = signal.welch(
|
||||
transmitted, fs=lab062.MODE.sample_rate_hz, nperseg=8192,
|
||||
noverlap=4096, return_onesided=False,
|
||||
)
|
||||
_, rx_psd = signal.welch(
|
||||
corrected, fs=lab062.MODE.sample_rate_hz, nperseg=8192,
|
||||
noverlap=4096, return_onesided=False,
|
||||
)
|
||||
_, cross = signal.csd(
|
||||
corrected, transmitted, fs=lab062.MODE.sample_rate_hz, nperseg=8192,
|
||||
noverlap=4096, return_onesided=False,
|
||||
)
|
||||
transfer = np.conj(cross) / np.maximum(tx_psd, 1e-30)
|
||||
coherence = abs(cross) ** 2 / np.maximum(tx_psd * rx_psd, 1e-30)
|
||||
occupied = tx_psd >= np.max(tx_psd) * 0.01
|
||||
stable = occupied & (coherence >= 0.5)
|
||||
gain_db = 20 * np.log10(np.maximum(abs(transfer[stable]), 1e-30))
|
||||
return {
|
||||
"frequency_offset_hz": frequency_hz,
|
||||
"complex_gain": {"real": float(gain.real), "imag": float(gain.imag)},
|
||||
"matched_component_power_code2": matched_power,
|
||||
"residual_power_code2": residual_power,
|
||||
"matched_residual_db": float(10 * math.log10(matched_power / residual_power)),
|
||||
"residual_interpretation": "Contains noise, channel distortion and any TX-underrun effect; not pure noise",
|
||||
"band_bins": int(np.sum(stable)),
|
||||
"band_gain_nonuniformity_p95_p05_db": (
|
||||
float(np.percentile(gain_db, 95) - np.percentile(gain_db, 5)) if gain_db.size else None
|
||||
),
|
||||
"band_gain_max_min_db": float(np.ptp(gain_db)) if gain_db.size else None,
|
||||
"median_coherence": float(np.median(coherence[stable])) if np.any(stable) else None,
|
||||
"tx_psd_threshold_db": -20.0,
|
||||
"coherence_threshold": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def lab058_quality(tx: lab058.Cs8File, rx: lab058.Cs8File, start: int,
|
||||
frame_samples: int) -> dict:
|
||||
"""Применить к каждому полному PHY-кадру неизменённую методику Lab058."""
|
||||
rows = []
|
||||
for index, frame in enumerate(lab062.plans(), 1):
|
||||
offset = (index - 1) * frame_samples
|
||||
transmitted = tx.read(offset, frame_samples)
|
||||
received = rx.read(start + offset, frame_samples)
|
||||
expected = 2.0 * frame.plan.frame_bits.astype(np.float64) - 1.0
|
||||
metrics, _working = lab058.analyze_values(received, expected, lab062.MODE.symbol_rate)
|
||||
channel, _arrays = lab058.channel_metrics(
|
||||
transmitted, received, lab062.MODE.symbol_rate, metrics["oracle"]["cfo_hz"]
|
||||
)
|
||||
rows.append({"packet_index": index, "oracle": metrics["oracle"], "channel": channel})
|
||||
selected = [row["oracle"]["metrics"] for row in rows]
|
||||
errors = sum(row["errors"] for row in selected)
|
||||
symbols = sum(row["symbols"] for row in selected)
|
||||
matched = float(np.mean([row["matched_component_power"] for row in selected]))
|
||||
residual = float(np.mean([row["residual_power"] for row in selected]))
|
||||
ripple = [row["channel"]["amplitude_ripple_p95_p05_db"] for row in rows]
|
||||
return {
|
||||
"method": "Lab058 analyze_values/oracle validation tiles, independently per full PHY packet",
|
||||
"packets": rows,
|
||||
"symbol_errors": errors,
|
||||
"symbols": symbols,
|
||||
"symbol_error_rate": errors / symbols,
|
||||
"evm_rms": float(math.sqrt(residual / matched)),
|
||||
"matched_component_power": matched,
|
||||
"residual_power": residual,
|
||||
"matched_residual_db": float(10 * math.log10(matched / residual)),
|
||||
"residual_interpretation": "Contains noise and unmodelled distortion; not pure noise",
|
||||
"band_gain_nonuniformity_p95_p05_db_median": float(np.median(ripple)),
|
||||
"band_gain_nonuniformity_p95_p05_db_min": float(np.min(ripple)),
|
||||
"band_gain_nonuniformity_p95_p05_db_max": float(np.max(ripple)),
|
||||
}
|
||||
|
||||
|
||||
def analyze() -> dict:
|
||||
capture = json.loads((lab062.RAW / "capture_result.json").read_text(encoding="utf-8"))
|
||||
legacy = json.loads((lab062.OUTPUT / "legacy_rf_decode.json").read_text(encoding="utf-8"))
|
||||
t2 = json.loads((lab062.OUTPUT / "t2_rf_decode.json").read_text(encoding="utf-8"))
|
||||
tx = lab058.Cs8File(lab062.RAW / "tx_stream.cs8")
|
||||
rx = lab058.Cs8File(lab062.RAW / "rx_stream.cs8")
|
||||
frame_samples = lab062.phy.frame_waveform_sample_count(lab062.plans()[0].plan, lab062.MODE)
|
||||
location = locate_burst(tx, rx, frame_samples)
|
||||
start = location["rx_start_sample"]
|
||||
legacy_symbols = diagnostic_symbol_rows(rx, start, frame_samples, "legacy")
|
||||
t2_symbols = diagnostic_symbol_rows(rx, start, frame_samples, "t2")
|
||||
|
||||
packet_rows = []
|
||||
for index in range(1, lab062.PACKETS + 1):
|
||||
nominal = start + (index - 1) * frame_samples
|
||||
legacy_strict = strict_row(legacy["rows"], nominal)
|
||||
t2_strict = strict_row(t2["rows"], nominal)
|
||||
packet_rows.append({
|
||||
"packet_index": index,
|
||||
"nominal_rx_start": nominal,
|
||||
"legacy": legacy_strict,
|
||||
"t2": t2_strict,
|
||||
"diagnostic_legacy": legacy_symbols[index - 1],
|
||||
"diagnostic_t2": t2_symbols[index - 1],
|
||||
})
|
||||
|
||||
rx_log = (lab062.RAW / "rx_stderr.txt").read_text(encoding="utf-8")
|
||||
tx_process = json.loads((lab062.RAW / "tx_process_result.json").read_text(encoding="utf-8"))
|
||||
tx_log = (tx_process.get("stdout") or "") + (tx_process.get("stderr") or "")
|
||||
level = lab062.hardware.receive_level(lab062.RAW / "rx_stream.cs8")
|
||||
level.pop("symbol_energy_code2_seconds", None)
|
||||
quality = waveform_quality(tx, rx, start)
|
||||
comparable_quality = lab058_quality(tx, rx, start, frame_samples)
|
||||
all_diag = [row for row in t2_symbols if row.get("measurable")]
|
||||
total_errors = sum(row["symbol_errors"] for row in all_diag)
|
||||
total_symbols = sum(row["symbols"] for row in all_diag)
|
||||
rms = math.sqrt(sum(row["symbol_rms_deviation"] ** 2 * row["symbols"] for row in all_diag) / total_symbols)
|
||||
legacy_set = set(legacy["passed_packet_indexes"])
|
||||
t2_set = set(t2["passed_packet_indexes"])
|
||||
result = {
|
||||
"lab": "Lab062",
|
||||
"status": "RF_SESSION_INVALID_TX_UNDERRUN",
|
||||
"rf_tx_count": capture["rf_tx_count"],
|
||||
"automatic_tx_repeats": capture["automatic_tx_repeats"],
|
||||
"integrity": {
|
||||
"tx_sha256": lab062.sha256(lab062.RAW / "tx_stream.cs8"),
|
||||
"rx_sha256": lab062.sha256(lab062.RAW / "rx_stream.cs8"),
|
||||
"rx_samples": rx.size,
|
||||
"rx_bytes": (lab062.RAW / "rx_stream.cs8").stat().st_size,
|
||||
"rx_duration_seconds": rx.size / lab062.MODE.sample_rate_hz,
|
||||
"rx_overrun_values": counter_values(rx_log, "overrun"),
|
||||
"tx_underrun_values": counter_values(tx_log, "underrun"),
|
||||
"rx_overruns_max": max(counter_values(rx_log, "overrun"), default=None),
|
||||
"tx_underruns_max": max(counter_values(tx_log, "underrun"), default=None),
|
||||
"rx_clipping": bool(level["saturation_detected"]),
|
||||
"rx_clipped_samples_fraction": level["clipped_samples_fraction"],
|
||||
},
|
||||
"clock": capture["clock"],
|
||||
"cleanup_errors": capture["cleanup_errors"],
|
||||
"location": location,
|
||||
"legacy": {key: value for key, value in legacy.items() if key != "rows"},
|
||||
"t2": {key: value for key, value in t2.items() if key != "rows"},
|
||||
"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, 17)) - legacy_set - t2_set),
|
||||
"packet_rows": packet_rows,
|
||||
"diagnostic_symbol_error_rate_t2": total_errors / total_symbols,
|
||||
"symbol_rms_deviation_t2": rms,
|
||||
"lab058_quality": comparable_quality,
|
||||
"waveform_quality": quality,
|
||||
"lab056_comparison": {
|
||||
"lab056_B_oracle_symbol_error_rate": 0.03495896,
|
||||
"lab056_B_oracle_evm_rms": 0.845350,
|
||||
"scope": "Lab056 used a different PRBS ABAB waveform and method; values are contextual, not identical estimands",
|
||||
},
|
||||
"primary_result": (
|
||||
"The sole RF session is invalid as a clean legacy-versus-T2 acceptance test because "
|
||||
"hackrf_transfer reported one transient TX underrun; both strict modes produced 0/16."
|
||||
),
|
||||
"recommended_next_single_step": (
|
||||
"Offline localize the underrun position against packet boundaries before deciding whether a new RF lab is justified."
|
||||
),
|
||||
"rf_tx_now": 0,
|
||||
"commit": "NOT_PERFORMED", "push": "NOT_PERFORMED", "lab063": "NOT_CREATED",
|
||||
}
|
||||
lab062.OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
(lab062.OUTPUT / "rf_analysis.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
with (lab062.OUTPUT / "packet_comparison.csv").open("w", newline="", encoding="utf-8-sig") as stream:
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow([
|
||||
"packet_index", "legacy_header", "legacy_fec", "legacy_crc", "legacy_output",
|
||||
"t2_header", "t2_fec", "t2_crc", "t2_output", "preamble_errors_t2",
|
||||
"header_errors_t2", "payload_errors_before_fec_t2", "initial_frequency_hz_t2",
|
||||
"t2_final_frequency_hz", "t2_lock_status",
|
||||
])
|
||||
for row in packet_rows:
|
||||
legacy_row, t2_row, diagnostic = row["legacy"], row["t2"], row["diagnostic_t2"]
|
||||
writer.writerow([
|
||||
row["packet_index"], *(legacy_row.get(key) if legacy_row else False for key in ("header", "fec", "crc", "packet_output")),
|
||||
*(t2_row.get(key) if t2_row else False for key in ("header", "fec", "crc", "packet_output")),
|
||||
diagnostic.get("preamble_symbol_errors"), diagnostic.get("header_symbol_errors"),
|
||||
diagnostic.get("payload_symbols_before_fec_errors"), diagnostic.get("initial_frequency_hz"),
|
||||
diagnostic.get("t2_final_frequency_hz"), diagnostic.get("t2_lock_status"),
|
||||
])
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = analyze()
|
||||
concise = {key: result[key] for key in (
|
||||
"status", "rf_tx_count", "automatic_tx_repeats", "integrity", "legacy", "t2",
|
||||
"passed_by_both", "passed_only_by_legacy", "passed_only_by_t2", "failed_by_both",
|
||||
"diagnostic_symbol_error_rate_t2", "symbol_rms_deviation_t2", "waveform_quality",
|
||||
"primary_result", "recommended_next_single_step", "rf_tx_now",
|
||||
)}
|
||||
print(json.dumps(concise, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user