Lab062: record invalidated 500 ksymbol/s RF packet trial

This commit is contained in:
LittleSam129
2026-09-14 13:58:42 +03:00
parent 25cc89f0c7
commit f8ae6c6bfc
16 changed files with 8774 additions and 0 deletions

View File

@@ -0,0 +1,475 @@
"""Lab062: подготовка полного 500K PHY-сеанса и RX-only preflight."""
from __future__ import annotations
from dataclasses import asdict
import hashlib
import json
from pathlib import Path
import subprocess
import sys
import time
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from experiments import lab049_bpsk_symbol_rate as phy
from experiments import lab053_packet_processing as packet_processing
from experiments import lab053_streaming_receiver as streaming
from experiments import lab054_hardware_control as lab054
from experiments import lab056_abab_energy as measurements
from experiments import lab056_capture_runner as hardware
ROOT = Path(__file__).resolve().parents[1]
RAW = ROOT / "data/raw/lab062"
OUTPUT = ROOT / "data/processed/lab062"
POINT = hardware.OperatingPoint(
frequency_hz=1_400_000_000,
physical_path="MASTER TX near window -> LOS indoor -> SLAVE RX on desk",
path_confirmed=True,
tx_antenna="existing 1400 MHz antenna near window",
rx_antenna="existing 1400 MHz antenna on desk",
distance_m=1.2,
orientation="same polarization; fixed during preflight and RF session",
visibility="LOS indoor",
)
MODE = phy.PhyMode(500_000)
PACKETS = 16
PAYLOAD_BYTES = 256
PREFLIGHT_SAMPLES = 800_000
RX_SECONDS = 3
RX_SAMPLES = RX_SECONDS * MODE.sample_rate_hz
RX_LEAD_SECONDS = 0.5
STRONG_AC_RMS_CODE = 20.0
STRONG_COMPONENT_CODE = 100.0
def save_new(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("x", encoding="utf-8") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def plans():
result = lab054.plans()
if len(result) != PACKETS or any(len(frame.plan.payload) != PAYLOAD_BYTES for frame in result):
raise RuntimeError("Штатный генератор не дал 16 payload по 256 байт")
return result
def parameters() -> dict:
return {
"rf_frequency_hz": POINT.frequency_hz,
"sample_rate_hz": MODE.sample_rate_hz,
"symbol_rate": MODE.symbol_rate,
"samples_per_symbol": MODE.samples_per_symbol,
"digital_carrier_hz": lab054.phy.lab045.CARRIER_OFFSET_HZ,
"packets": PACKETS,
"payload_bytes": PAYLOAD_BYTES,
"tx_vga_db": 10,
"rx_lna_db": 24,
"rx_vga_db": 24,
"rf_amplifier": False,
"bias_tee": False,
"geometry": asdict(POINT),
"clock_path": "10 MHz TCXO -> MASTER -> MASTER CLKOUT -> SLAVE CLKIN",
}
def transfer_command(path: Path | str, samples: int, *, transmit: bool = False) -> list[str]:
return hardware.command(POINT, path, samples, transmit=transmit)
def prepare() -> dict:
RAW.mkdir(parents=True, exist_ok=True)
OUTPUT.mkdir(parents=True, exist_ok=True)
waveform_path = RAW / "tx_stream.cs8"
if waveform_path.exists():
raise FileExistsError("Lab062 TX уже существует; перезапись запрещена")
with waveform_path.open("xb") as stream:
for frame in plans():
stream.write(phy.build_parameterized_tx_cs8(frame.plan, MODE))
samples = waveform_path.stat().st_size // 2
result = {
"lab": "Lab062", "stage": "PREPARATION_ONLY", "rf_tx_count": 0,
"head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"parameters": parameters(), "tx_samples": samples,
"tx_bytes": waveform_path.stat().st_size,
"tx_duration_seconds": samples / MODE.sample_rate_hz,
"tx_sha256": sha256(waveform_path), "rx_samples": RX_SAMPLES,
"rx_duration_seconds": RX_SECONDS, "automatic_retry": False,
"tx_command_prepared_not_run": transfer_command(waveform_path, samples, transmit=True),
"rx_command_prepared_not_run": transfer_command(RAW / "rx_stream.cs8", RX_SAMPLES),
}
save_new(RAW / "tx_metadata.json", result)
save_new(OUTPUT / "preparation.json", result)
return result
def validate_prepared() -> dict:
metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8"))
path = RAW / "tx_stream.cs8"
expected = hashlib.sha256()
expected_bytes = 0
for frame in plans():
block = phy.build_parameterized_tx_cs8(frame.plan, MODE)
expected.update(block)
expected_bytes += len(block)
if (metadata.get("stage") != "PREPARATION_ONLY" or metadata.get("rf_tx_count") != 0
or metadata.get("parameters") != parameters() or path.stat().st_size != expected_bytes
or metadata.get("tx_sha256") != expected.hexdigest() or sha256(path) != expected.hexdigest()):
raise RuntimeError("Подготовленный Lab062 TX или параметры изменились")
return metadata
def decode(mode: str, source_path: Path | None = None, *, diagnostics: bool = False) -> dict:
observations = []
receiver = streaming.StreamingReceiver(
processor=packet_processing.PacketProcessor(carrier_tracking_mode=mode),
packet_observer=lambda row, outcome: observations.append((row, outcome)),
)
received = []
with (source_path or (RAW / "tx_stream.cs8")).open("rb") as stream:
while block := stream.read(2 * receiver.block_samples):
values = np.frombuffer(block, np.int8)
samples = values[::2].astype(np.float32) + 1j * values[1::2].astype(np.float32)
received.extend(receiver.push_iq_block(samples.astype(np.complex64)))
received.extend(receiver.finish())
expected = [frame.plan.payload for frame in plans()]
actual = [item.payload for item in received]
result = {
"mode": mode, "candidates": len(observations), "true_packets_found": len(actual),
"header_pass": sum(outcome.phy_header_pass for _, outcome in observations),
"fec_started": sum(outcome.fec_attempted for _, outcome in observations),
"fec_pass": sum(outcome.fec_pass for _, outcome in observations),
"crc_started": sum(outcome.crc != "NOT_REACHED" for _, outcome in observations),
"crc_pass": sum(outcome.crc == "PASS" for _, outcome in observations),
"packet_output": len(actual),
"exact_payload_match": sum(payload in expected for payload in actual),
"exact_ordered_payloads": actual == expected,
"passed": len(actual) == PACKETS and actual == expected,
}
if diagnostics:
payload_to_index = {payload: index + 1 for index, payload in enumerate(expected)}
result["passed_packet_indexes"] = sorted(
payload_to_index[payload] for payload in actual if payload in payload_to_index
)
rows = []
for row, outcome in observations:
payload = None
if outcome.frame is not None and outcome.frame.reception.payload_bytes is not None:
payload = bytes(outcome.frame.reception.payload_bytes)
traces = row.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({
"absolute_refined": int(row["absolute_refined"]),
"reason": outcome.reason,
"header": bool(outcome.phy_header_pass),
"fec_started": bool(outcome.fec_attempted),
"fec": bool(outcome.fec_pass),
"crc_started": outcome.crc != "NOT_REACHED",
"crc": outcome.crc == "PASS",
"packet_output": bool(outcome.accepted and outcome.crc == "PASS"),
"packet_index_after_verdict": payload_to_index.get(payload),
"initial_frequency_hz": row.get("cfo_hz"),
"t2_final_frequency_hz": final_frequency,
"t2_lock_status": "NOT_DEFINED_BY_IMPLEMENTATION" if mode == "t2" else "NOT_APPLICABLE",
})
result["rows"] = rows
result["detector_statistics"] = dict(receiver.detection_statistics)
result["candidate_overflow"] = receiver.candidate_overflow
return result
def software_gate() -> dict:
metadata = validate_prepared()
legacy = decode("legacy")
t2 = decode("t2")
result = {
"lab": "Lab062", "stage": "SOFTWARE_GATE", "rf_tx_count": 0,
"tx_sha256": metadata["tx_sha256"], "legacy": legacy, "t2": t2,
"passed": legacy["passed"] and t2["passed"],
}
save_new(OUTPUT / "software_gate.json", result)
if not result["passed"]:
raise RuntimeError("Lab062 software gate FAIL")
return result
def preflight(*, runner=None, process_checker=None, clock_checker=None,
sleep_function=time.sleep) -> dict:
metadata = validate_prepared()
software = json.loads((OUTPUT / "software_gate.json").read_text(encoding="utf-8"))
if not software.get("passed") or software.get("tx_sha256") != metadata["tx_sha256"]:
raise RuntimeError("Нет действующего software gate")
save_new(RAW / "preflight_attempt.json", {
"lab": "Lab062", "rf_tx_count": 0, "point": asdict(POINT),
"tx_sha256": metadata["tx_sha256"],
})
actual_runner = runner or lab054.clock._default_command_runner
process_checker = process_checker or lab054.check_processes
clock_checker = clock_checker or hardware.common_clock
def rx_only(command):
if "-t" in command:
raise PermissionError("TX запрещён в Lab062 preflight")
return actual_runner(command)
result = {
"lab": "Lab062", "stage": "RX_ONLY_PREFLIGHT", "passed": False,
"rf_tx_count": 0, "point": asdict(POINT), "parameters": parameters(),
"tx_sha256": metadata["tx_sha256"], "stop_before_tx": True,
}
clock_started = False
try:
before = process_checker()
result["process_before"] = before.to_dict()
lab054.require_clear(before)
result["inventory"] = lab054.phy.lab045._verify_device_inventory(rx_only)
clock_started = True
result["clock"] = clock_checker(
POINT, rx_only, lab054.clock.TransmitGuard(), sleep_function=sleep_function
)
hardware.require_clock_pass(result["clock"])
path = RAW / "preflight_rx.cs8"
if path.exists():
raise FileExistsError("Lab062 preflight RX уже существует")
captured = rx_only(transfer_command(path, PREFLIGHT_SAMPLES))
result["rx_process"] = {
"returncode": captured.returncode,
"stdout": captured.stdout or "", "stderr": captured.stderr or "",
}
log = (captured.stdout or "") + (captured.stderr or "")
if captured.returncode or not hardware.clean_counter_log(log, "overrun"):
raise RuntimeError("RX preflight: процесс или счётчик overruns не прошёл")
if path.stat().st_size != 2 * PREFLIGHT_SAMPLES:
raise RuntimeError("Неверная длина Lab062 preflight RX")
level = hardware.receive_level(path)
# У RX-only фона нет символов. Унаследованная метрика Lab056 была бы
# привязана к её 20 ksymbol/s и здесь физически неприменима.
level.pop("symbol_energy_code2_seconds", None)
level["symbol_energy"] = "NOT_APPLICABLE_TO_BACKGROUND"
ac_rms = float(np.sqrt(max(0.0, level["ac_power_code2"])))
strong = bool(ac_rms >= STRONG_AC_RMS_CODE or level["max_abs_i"] >= STRONG_COMPONENT_CODE
or level["max_abs_q"] >= STRONG_COMPONENT_CODE)
level.update({
"ac_rms_code": ac_rms,
"unexpected_strong_signal": strong,
"strong_signal_rule": (
f"AC RMS >= {STRONG_AC_RMS_CODE} code or max |I|/|Q| >= "
f"{STRONG_COMPONENT_CODE} code"
),
})
result["rx_level"] = level
result["rx_sha256"] = sha256(path)
result["rx_overruns"] = 0
if level["saturation_detected"]:
raise RuntimeError("RX clipping; STOP без изменения gain")
if strong:
raise RuntimeError("Неожиданно сильный RX-only фон; STOP без изменения gain")
result["passed"] = True
except Exception as error:
result["error"] = str(error)
raise
finally:
errors = lab054.disable_clock_outputs(rx_only) if clock_started else []
try:
after = process_checker()
result["process_after"] = after.to_dict()
lab054.require_clear(after)
except Exception as error:
errors.append(str(error))
result["cleanup_errors"] = errors
result["passed"] = result["passed"] and not errors
save_new(RAW / "preflight_result.json", result)
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / "preflight_result.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
if not result["passed"]:
raise RuntimeError("Lab062 preflight cleanup FAIL")
return result
def require_preflight(metadata: dict) -> dict:
"""Проверить сохранённый preflight именно для подготовленного потока Lab062."""
record = json.loads((RAW / "preflight_result.json").read_text(encoding="utf-8"))
if (record.get("passed") is not True or record.get("rf_tx_count") != 0
or record.get("point") != asdict(POINT)
or record.get("parameters") != parameters()
or record.get("tx_sha256") != metadata["tx_sha256"]):
raise PermissionError("Нет успешного RX-only preflight для этой конфигурации Lab062")
hardware.require_clock_pass(record.get("clock", {}))
level = record.get("rx_level", {})
if (level.get("saturation_detected") is not False
or level.get("unexpected_strong_signal") is not False
or record.get("rx_overruns") != 0):
raise PermissionError("RX-only preflight не разрешает передачу")
return record
def capture_once(*, authorization: bool = False, runner=None, process_starter=None,
process_checker=None, clock_checker=None,
sleep_function=time.sleep) -> dict:
"""Выполнить один RX-first сеанс и навсегда израсходовать попытку до обращения к SDR."""
if authorization is not True:
raise PermissionError("Нужно отдельное разрешение на единственный RF-сеанс Lab062")
metadata = validate_prepared()
require_preflight(metadata)
receive_path = RAW / "rx_stream.cs8"
if receive_path.exists() or (RAW / "capture_result.json").exists():
raise FileExistsError("Аппаратный результат Lab062 уже существует")
actual_runner = runner or lab054.clock._default_command_runner
process_starter = process_starter or lab054.clock._default_process_starter
process_checker = process_checker or lab054.check_processes
clock_checker = clock_checker or hardware.common_clock
save_new(RAW / "hardware_attempt.json", {
"lab": "Lab062", "authorized_once": True, "automatic_retry": False,
"point": asdict(POINT), "parameters": parameters(),
"tx_sha256": metadata["tx_sha256"],
})
result = {
"lab": "Lab062", "stage": "SINGLE_RF_CAPTURE", "passed": False,
"rf_tx_count": 0, "automatic_tx_repeats": 0,
"point": asdict(POINT), "parameters": parameters(),
"tx_sha256": metadata["tx_sha256"],
}
guard = lab054.clock.TransmitGuard()
process = None
clock_started = False
try:
before = process_checker()
result["process_before"] = before.to_dict()
lab054.require_clear(before)
result["inventory"] = lab054.phy.lab045._verify_device_inventory(actual_runner)
clock_started = True
result["clock"] = clock_checker(
POINT, actual_runner, guard, sleep_function=sleep_function
)
hardware.require_clock_pass(result["clock"])
process = process_starter(
transfer_command(receive_path, RX_SAMPLES),
RAW / "rx_stdout.txt", RAW / "rx_stderr.txt",
)
sleep_function(RX_LEAD_SECONDS)
if process.poll() is not None:
raise RuntimeError("RX завершился до TX")
before_tx = hardware.receive_level(receive_path, tail_samples=65536)
before_tx.pop("symbol_energy_code2_seconds", None)
result["rx_level_before_tx"] = before_tx
if before_tx["saturation_detected"]:
raise RuntimeError("RX clipping до TX; RF TX = 0")
guard.register_transmit()
result["rf_tx_count"] = guard.transmit_calls
transmitted = actual_runner(transfer_command(
RAW / "tx_stream.cs8", metadata["tx_samples"], transmit=True
))
save_new(RAW / "tx_process_result.json", {
"returncode": transmitted.returncode,
"stdout": transmitted.stdout or "", "stderr": transmitted.stderr or "",
})
if transmitted.returncode:
raise RuntimeError("Единственный TX завершился ошибкой; повтор запрещён")
if process.wait(RX_SECONDS + 10):
raise RuntimeError("RX завершился ошибкой; передача уже выполнена, повтор запрещён")
process.close_logs()
result["rx_samples"] = receive_path.stat().st_size // 2
result["rx_bytes"] = receive_path.stat().st_size
result["rx_duration_seconds"] = result["rx_samples"] / MODE.sample_rate_hz
if result["rx_bytes"] != RX_SAMPLES * 2:
raise RuntimeError("Повреждённая длина RX; передача уже выполнена, повтор запрещён")
rx_log = "".join(
(RAW / f"rx_{kind}.txt").read_text(encoding="utf-8")
for kind in ("stdout", "stderr")
)
tx_log = (transmitted.stdout or "") + (transmitted.stderr or "")
if not hardware.clean_counter_log(rx_log, "overrun"):
raise RuntimeError("RX overrun или неоднозначный счётчик; повтор запрещён")
if not hardware.clean_counter_log(tx_log, "underrun"):
raise RuntimeError("TX underrun или неоднозначный счётчик; повтор запрещён")
result["rx_overruns"] = 0
result["tx_underruns"] = 0
result["rx_sha256"] = sha256(receive_path)
level = hardware.receive_level(receive_path)
level.pop("symbol_energy_code2_seconds", None)
result["rx_level"] = level
result["rx_clipping"] = bool(level["saturation_detected"])
if result["rx_clipping"]:
raise RuntimeError("RX clipping в записи; передача уже выполнена, повтор запрещён")
result["passed"] = True
except Exception as error:
result["error"] = str(error)
raise
finally:
errors = []
if process is not None:
try:
process.terminate()
except Exception as error:
errors.append(str(error))
if clock_started:
errors.extend(lab054.disable_clock_outputs(actual_runner))
try:
after = process_checker()
result["process_after"] = after.to_dict()
lab054.require_clear(after)
except Exception as error:
errors.append(str(error))
result["cleanup_errors"] = errors
result["passed"] = result["passed"] and not errors
save_new(RAW / "capture_result.json", result)
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / "capture_result.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
if not result["passed"]:
raise RuntimeError("Lab062 RF-сеанс завершён с ошибкой; повтор запрещён")
return result
def run_functional_tests() -> None:
assert MODE.samples_per_symbol == 16
assert len(plans()) == 16
assert all(len(frame.plan.payload) == 256 for frame in plans())
assert "-t" not in transfer_command("NUL", 65536)
assert "-t" in transfer_command("NUL", 65536, transmit=True)
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage", choices=("prepare", "software", "preflight", "capture"), required=True)
parser.add_argument("--authorize-one-rf", action="store_true")
arguments = parser.parse_args()
if arguments.stage == "prepare":
result = prepare()
elif arguments.stage == "software":
result = software_gate()
elif arguments.stage == "preflight":
result = preflight()
else:
result = capture_once(authorization=arguments.authorize_one_rf)
print(json.dumps(result, ensure_ascii=False, indent=2))
print("RF TX = 0")
if __name__ == "__main__":
main()

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