Files
SDR-Rover/experiments/lab059_preamble_carrier_limits.py

1057 lines
47 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Lab059: пределы preamble-only восстановления несущей по сохранённым IQ."""
from __future__ import annotations
import csv
from dataclasses import asdict, dataclass, replace
import hashlib
import json
import math
from pathlib import Path
import sys
from typing import Iterable
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from experiments import lab054_hardware_control as lab054
from experiments import lab057_symbol_diagnosis as lab057
from experiments import lab058_abab_symbol_quality as lab058
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab059"
METHODS = ("P0", "P1", "P2", "P3")
SAMPLE_RATE = 8_000_000
PREAMBLE_SYMBOLS = 1280
PROBE_SYMBOLS = 1424
FULL_BLOCK_SYMBOLS = 20
P2_BLOCK_SYMBOLS = 16
P2_TRAIN = np.arange(32, 608)
P2_VALIDATION = np.arange(672, 1248)
BURST_DURATION_SECONDS = 0.2048
MONTE_CARLO_TRIALS = 300
SYNTHETIC_FREQUENCIES_HZ = np.array(
[-100, -20, -10, -5, -2, -1, -0.5, -0.25, 0, 0.25, 0.5, 1, 2, 5, 10, 20, 100],
dtype=np.float64,
)
QUALITY_LEVELS = (
("LAB058_A_EVM", 0.317390),
("INTERMEDIATE_EVM", 0.55),
("LAB058_B_EVM", 0.845350),
)
CATASTROPHIC_ERROR_HZ = 1.0 / (2.0 * BURST_DURATION_SECONDS)
@dataclass(frozen=True)
class CarrierEstimate:
"""Оценка несущей с фазой, отнесённой к t=0."""
method: str
frequency_hz: float
gain: complex
estimation_symbols: int
estimation_duration_seconds: float
phase_fit_rmse_rad: float
mean_block_coherence: float
unambiguous_abs_hz: float
validation_phase_rmse_rad: float | None = None
validation_errors: int | None = None
validation_symbols: int | None = None
def serializable(value):
"""Преобразовать numpy и complex в строгий JSON."""
if isinstance(value, dict):
return {str(key): serializable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [serializable(item) for item in value]
if isinstance(value, np.ndarray):
return serializable(value.tolist())
if isinstance(value, complex):
return {"real": float(value.real), "imag": float(value.imag)}
if isinstance(value, np.generic):
return serializable(value.item())
return value
def write_json(path: Path, value) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(serializable(value), ensure_ascii=False, indent=2, allow_nan=False) + "\n",
encoding="utf-8",
)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def phase_slope_to_hz(slope_rad_per_second: float, *, squared_bpsk: bool) -> float:
"""Перевести наклон фазы в Гц с учётом удвоения при BPSK²."""
divisor = 4.0 * np.pi if squared_bpsk else 2.0 * np.pi
return float(slope_rad_per_second / divisor)
def phase_drift_degrees(frequency_error_hz: float, duration_seconds: float) -> float:
"""Накопленный фазовый уход от ошибки частоты."""
return float(360.0 * frequency_error_hz * duration_seconds)
def maximum_frequency_error_hz(phase_limit_degrees: float, duration_seconds: float) -> float:
"""Допустимая ошибка частоты для заданного фазового ухода."""
return float(phase_limit_degrees / (360.0 * duration_seconds))
def awgn_cfo_crlb_standard_deviation_hz(
evm_rms: float,
symbol_count: int,
symbol_rate: float,
) -> float:
"""CRLB СКО CFO для известных символов, неизвестной фазы и complex AWGN.
Здесь EVM² = E|n|²/|h|². Это модельная проверка масштаба, а не описание
частотно-селективного OTA-остатка.
"""
variance = (
6.0 * evm_rms**2 * symbol_rate**2
/ ((2.0 * np.pi) ** 2 * symbol_count * (symbol_count**2 - 1))
)
return float(np.sqrt(variance))
def _huber_phase_line(
block_values: np.ndarray,
block_times_seconds: np.ndarray,
) -> tuple[float, float, float, float]:
"""Устойчиво оценить phase(t)=phi0+slope*t заранее фиксированным IRLS."""
values = np.asarray(block_values, dtype=np.complex128)
times = np.asarray(block_times_seconds, dtype=np.float64)
if values.ndim != 1 or times.ndim != 1 or len(values) != len(times) or len(values) < 3:
raise ValueError("Недостаточно фазовых блоков")
phases = np.unwrap(np.angle(values))
base_weights = np.maximum(np.abs(values), 1e-12)
center_time = float(np.sum(base_weights * times) / np.sum(base_weights))
centered = times - center_time
design = np.column_stack((np.ones(len(times)), centered))
weights = base_weights.copy()
coefficients = np.zeros(2, dtype=np.float64)
for _iteration in range(12):
root = np.sqrt(weights / max(float(np.max(weights)), 1e-30))
updated, *_unused = np.linalg.lstsq(
design * root[:, None], phases * root, rcond=None
)
residual = phases - design @ updated
median = float(np.median(residual))
scale = 1.4826 * float(np.median(np.abs(residual - median))) + 1e-9
limit = 1.345 * scale
robust = np.ones_like(residual)
outside = np.abs(residual) > limit
robust[outside] = limit / np.abs(residual[outside])
weights = base_weights * robust
if np.allclose(updated, coefficients, rtol=0.0, atol=1e-12):
coefficients = updated
break
coefficients = updated
residual = phases - design @ coefficients
rmse = float(np.sqrt(np.sum(base_weights * residual**2) / np.sum(base_weights)))
phase_at_zero = float(coefficients[0] - coefficients[1] * center_time)
coherence = float(abs(np.sum(values)) / max(float(np.sum(abs(values))), 1e-30))
return phase_at_zero, float(coefficients[1]), rmse, coherence
def _block_values(
values: np.ndarray,
indexes: np.ndarray,
symbol_rate: int,
block_symbol_count: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Собрать неперекрывающиеся когерентные блоки по исходной шкале времени."""
values = np.asarray(values, dtype=np.complex128)
indexes = np.asarray(indexes, dtype=np.int64)
block_count = len(values) // block_symbol_count
if block_count < 3:
raise ValueError("Слишком мало когерентных блоков")
usable = block_count * block_symbol_count
reshaped_values = values[:usable].reshape(block_count, block_symbol_count)
reshaped_indexes = indexes[:usable].reshape(block_count, block_symbol_count)
return (
np.mean(reshaped_values, axis=1),
np.mean((reshaped_indexes + 0.5) / symbol_rate, axis=1),
)
def _gain_at_zero(
symbols: np.ndarray,
known_symbols: np.ndarray,
indexes: np.ndarray,
symbol_rate: int,
frequency_hz: float,
) -> complex:
times = (indexes + 0.5) / symbol_rate
return complex(
np.mean(
symbols * np.conj(known_symbols) * np.exp(-2j * np.pi * frequency_hz * times)
)
)
def _validation_metrics(
symbols: np.ndarray,
known_symbols: np.ndarray,
indexes: np.ndarray,
symbol_rate: int,
frequency_hz: float,
gain: complex,
) -> tuple[float, int]:
times = (indexes + 0.5) / symbol_rate
normalized = symbols * np.exp(-2j * np.pi * frequency_hz * times) / gain
phase_error = np.angle(normalized * np.conj(known_symbols))
errors = int(np.count_nonzero((normalized.real >= 0) != (known_symbols.real >= 0)))
return float(np.sqrt(np.mean(phase_error**2))), errors
def estimate_carrier(
preamble_symbols: np.ndarray,
symbol_rate: int,
method: str,
) -> CarrierEstimate:
"""Оценить частоту и фазу только по 1280 известным символам преамбулы."""
received = np.asarray(preamble_symbols, dtype=np.complex128)
known = lab057.EXPECTED.astype(np.complex128)
if received.shape != (PREAMBLE_SYMBOLS,):
raise ValueError("Оценивателю требуется ровно 1280 символов преамбулы")
all_indexes = np.arange(PREAMBLE_SYMBOLS, dtype=np.int64)
if method == "P0":
statistics = lab057.squared_cfo_statistics(received, symbol_rate)
frequency = float(statistics["cfo_hz"])
gain = _gain_at_zero(received, known, all_indexes, symbol_rate, frequency)
phase_rmse, validation_errors = _validation_metrics(
received[P2_VALIDATION], known[P2_VALIDATION], P2_VALIDATION,
symbol_rate, frequency, gain,
)
blocks = (received[:1024] ** 2).reshape(2, 512).mean(axis=1)
return CarrierEstimate(
method, frequency, gain, 1024, 1024 / symbol_rate,
0.0, float(abs(np.sum(blocks)) / max(float(np.sum(abs(blocks))), 1e-30)),
symbol_rate / 2048.0, phase_rmse, validation_errors, len(P2_VALIDATION),
)
if method == "P2":
indexes = P2_TRAIN
despread = received[indexes] * np.conj(known[indexes])
blocks, times = _block_values(despread, indexes, symbol_rate, P2_BLOCK_SYMBOLS)
_phase0, slope, rmse, coherence = _huber_phase_line(blocks, times)
frequency = phase_slope_to_hz(slope, squared_bpsk=False)
gain = _gain_at_zero(received[indexes], known[indexes], indexes, symbol_rate, frequency)
phase_rmse, validation_errors = _validation_metrics(
received[P2_VALIDATION], known[P2_VALIDATION], P2_VALIDATION,
symbol_rate, frequency, gain,
)
return CarrierEstimate(
method, frequency, gain, len(indexes),
(indexes[-1] - indexes[0] + 1) / symbol_rate,
rmse, coherence, symbol_rate / (2.0 * P2_BLOCK_SYMBOLS),
phase_rmse, validation_errors, len(P2_VALIDATION),
)
if method == "P1":
transformed = received * np.conj(known)
squared_bpsk = False
elif method == "P3":
transformed = received**2
squared_bpsk = True
else:
raise ValueError(f"Неизвестный метод {method}")
blocks, times = _block_values(
transformed, all_indexes, symbol_rate, FULL_BLOCK_SYMBOLS
)
_phase0, slope, rmse, coherence = _huber_phase_line(blocks, times)
frequency = phase_slope_to_hz(slope, squared_bpsk=squared_bpsk)
gain = _gain_at_zero(received, known, all_indexes, symbol_rate, frequency)
phase_rmse, validation_errors = _validation_metrics(
received[P2_VALIDATION], known[P2_VALIDATION], P2_VALIDATION,
symbol_rate, frequency, gain,
)
ambiguity_divisor = 4.0 if squared_bpsk else 2.0
return CarrierEstimate(
method, frequency, gain, PREAMBLE_SYMBOLS, PREAMBLE_SYMBOLS / symbol_rate,
rmse, coherence, symbol_rate / (ambiguity_divisor * FULL_BLOCK_SYMBOLS),
phase_rmse, validation_errors, len(P2_VALIDATION),
)
def estimate_from_burst(
burst_symbols: np.ndarray,
symbol_rate: int,
method: str,
) -> CarrierEstimate:
"""Явная граница: оцениватель видит только prefix-преамбулу."""
return estimate_carrier(np.asarray(burst_symbols)[:PREAMBLE_SYMBOLS], symbol_rate, method)
def compensate(
symbols: np.ndarray,
symbol_rate: int,
estimate: CarrierEstimate,
) -> np.ndarray:
"""Один раз компенсировать CFO и фазу по единой шкале времени."""
times = (np.arange(len(symbols), dtype=np.float64) + 0.5) / symbol_rate
return symbols * np.exp(-2j * np.pi * estimate.frequency_hz * times) / estimate.gain
def _extract_symbols(
matched: np.ndarray,
nominal: int,
timing_samples: int,
count: int,
samples_per_symbol: int,
) -> np.ndarray:
values = matched[
nominal + timing_samples:
nominal + timing_samples + count * samples_per_symbol:
samples_per_symbol
].astype(np.complex128)
if len(values) != count:
raise ValueError("Недостаточно отфильтрованных символов")
return values
def local_preamble_symbols(
raw_values: np.ndarray,
frontend: lab057.Frontend,
timing_samples: int,
selected_indexes: np.ndarray,
) -> np.ndarray:
"""RRC-точки, чьи raw DC и FIR-опора ограничены выбранной преамбулой."""
indexes = np.asarray(selected_indexes, dtype=np.int64)
if indexes.ndim != 1 or not len(indexes) or indexes.min() < 0 or indexes.max() >= PREAMBLE_SYMBOLS:
raise ValueError("Некорректные индексы локальной преамбулы")
positions = (
frontend.nominal + int(timing_samples)
+ indexes * frontend.samples_per_symbol
)
low = int(positions.min() - len(frontend.taps) + 1)
high = int(positions.max() + 1)
if low < 0 or high > len(raw_values):
raise ValueError("Сырая опора преамбулы выходит за окно")
local = np.asarray(raw_values[low:high], dtype=np.complex64)
rotation = lab057.packet.lab043._carrier_downconversion_vector(low, len(local))
filtered = lab057.oaconvolve(
(local - np.mean(local)) * rotation,
frontend.taps,
mode="full",
)
output = np.zeros(PREAMBLE_SYMBOLS, dtype=np.complex128)
output[indexes] = filtered[positions - low]
return output
def real_carrier_estimates(
raw_values: np.ndarray,
frontend: lab057.Frontend,
timing_samples: int,
stock_symbols: np.ndarray,
symbol_rate: int,
methods: Iterable[str],
) -> dict[str, CarrierEstimate]:
"""P0 остаётся stock; P1–P3 не читают raw после своей преамбульной опоры."""
requested = tuple(methods)
estimates: dict[str, CarrierEstimate] = {}
if "P0" in requested:
estimates["P0"] = estimate_from_burst(stock_symbols, symbol_rate, "P0")
full_local = None
if any(method in requested for method in ("P1", "P2", "P3")):
full_local = local_preamble_symbols(
raw_values, frontend, timing_samples, np.arange(PREAMBLE_SYMBOLS)
)
for method in ("P1", "P3"):
if method in requested:
estimates[method] = estimate_carrier(full_local, symbol_rate, method)
if "P2" in requested:
train_local = local_preamble_symbols(
raw_values, frontend, timing_samples, P2_TRAIN
)
estimate = estimate_carrier(train_local, symbol_rate, "P2")
validation_rmse, validation_errors = _validation_metrics(
full_local[P2_VALIDATION], lab057.EXPECTED[P2_VALIDATION], P2_VALIDATION,
symbol_rate, estimate.frequency_hz, estimate.gain,
)
estimates["P2"] = replace(
estimate,
validation_phase_rmse_rad=validation_rmse,
validation_errors=validation_errors,
)
return {method: estimates[method] for method in requested}
def _estimate_dict(estimate: CarrierEstimate) -> dict:
return asdict(estimate)
def _lab056_inputs() -> tuple[list[dict], dict[str, float]]:
metadata = json.loads((ROOT / "data/raw/lab056/tx_metadata.json").read_text(encoding="utf-8"))
analysis = json.loads((ROOT / "data/processed/lab056/rf_analysis.json").read_text(encoding="utf-8"))
lab058_summary = json.loads((ROOT / "data/processed/lab058/summary.json").read_text(encoding="utf-8"))
measured = {row["label"]: row for row in analysis["segments"]}
oracle = {row["label"]: row["oracle"]["cfo_hz"] for row in lab058_summary["segments"]}
inputs = []
for segment in metadata["segments"]:
inputs.append({
"label": segment["label"],
"symbol_rate": segment["symbol_rate"],
"samples": segment["samples"],
"rx_start": measured[segment["label"]]["measurement"]["start"],
})
return inputs, oracle
def analyze_lab056(methods: Iterable[str]) -> list[dict]:
"""Применить оценки к полным A1/B1/A2/B2, truth только для метрик после fit."""
reader = lab058.Cs8File(ROOT / "data/raw/lab056/rx_stream.cs8")
inputs, oracle = _lab056_inputs()
rows: list[dict] = []
for item in inputs:
rate = item["symbol_rate"]
expected = lab058.expected_symbols(rate)
values = reader.read(item["rx_start"], item["samples"])
frontend = lab057.Frontend(rate)
stock = frontend.extract(values, len(expected))
if not stock.passed:
raise RuntimeError(f"Lab056 {item['label']}: штатное извлечение {stock.reason}")
matched = frontend.filter(values)
symbols = _extract_symbols(
matched, frontend.nominal, stock.timing_offset_samples,
len(expected), frontend.samples_per_symbol,
)
estimates = real_carrier_estimates(
values, frontend, stock.timing_offset_samples, symbols, rate, methods
)
if "P0" in estimates:
if not np.isclose(estimates["P0"].frequency_hz, stock.carrier["cfo_hz"], atol=1e-10):
raise AssertionError("P0 Lab056 не воспроизвёл штатную оценку CFO")
selected = np.arange(len(expected)) >= PREAMBLE_SYMBOLS
for method, estimate in estimates.items():
result = lab058.evaluate(
{"symbols": symbols, "h": estimate.gain, "cfo_hz": estimate.frequency_hz},
expected, rate, selected,
)
reference = float(oracle[item["label"]])
rows.append({
"dataset": "Lab056",
"label": item["label"],
"frame": None,
"symbol_rate": rate,
"method": method,
"timing_samples": int(stock.timing_offset_samples),
"frequency_hz": estimate.frequency_hz,
"diagnostic_reference_hz": reference,
"frequency_error_hz": estimate.frequency_hz - reference,
"estimation_symbols": estimate.estimation_symbols,
"estimation_duration_seconds": estimate.estimation_duration_seconds,
"phase_drift_to_burst_end_degrees": phase_drift_degrees(
estimate.frequency_hz - reference, BURST_DURATION_SECONDS
),
"preamble_errors": int(np.count_nonzero(
(compensate(symbols[:PREAMBLE_SYMBOLS], rate, estimate).real >= 0)
!= lab057.PREAMBLE
)),
"validation_errors": estimate.validation_errors,
"validation_symbols": estimate.validation_symbols,
"validation_phase_rmse_rad": estimate.validation_phase_rmse_rad,
"header_raw_errors": None,
"header_voted_errors": None,
"HEADER_ACCEPTED": None,
"FEC_STARTED": None,
"CRC_PASS": None,
"PACKET_OUTPUT": None,
"symbol_errors": result["errors"],
"symbols": result["symbols"],
"symbol_error_rate": result["ber"],
"evm_rms": result["evm_rms"],
"matched_residual_db": result["matched_residual_db"],
"estimate": _estimate_dict(estimate),
})
return rows
def _packet_sources() -> tuple[list[dict], list[dict], lab058.Cs8File, lab058.Cs8File]:
mapping = json.loads((ROOT / "data/processed/lab057/candidate_mapping.json").read_text(encoding="utf-8"))
lab054_items = [row for row in mapping if row["true_start_frame"]]
audit = json.loads((ROOT / "data/processed/lab055/energy_audit_v2.json").read_text(encoding="utf-8"))
old_directory = Path(audit["experiments"]["Lab047"]["directory"])
old = json.loads((old_directory / "independent_reprocess_result_corrected.json").read_text(encoding="utf-8"))
lab047_items = [row for row in old["strict"]["outcomes"] if row["accepted"]]
return (
lab054_items,
lab047_items,
lab058.Cs8File(ROOT / "data/raw/lab054/rx_stream.cs8"),
lab058.Cs8File(old_directory / "rx_stream.cs8"),
)
def _packet_rows(
reader: lab058.Cs8File,
start: int,
symbol_rate: int,
dataset: str,
frame_number: int,
expected_header: np.ndarray,
methods: Iterable[str],
) -> list[dict]:
frontend = lab057.Frontend(symbol_rate)
probe_count = PROBE_SYMBOLS
probe_values = reader.read(start, (probe_count + 42) * frontend.samples_per_symbol)
stock = frontend.extract(probe_values, probe_count)
if not stock.passed:
raise RuntimeError(f"{dataset} frame {frame_number}: {stock.reason}")
matched = frontend.filter(probe_values)
symbols = _extract_symbols(
matched, frontend.nominal, stock.timing_offset_samples,
probe_count, frontend.samples_per_symbol,
)
estimates = real_carrier_estimates(
probe_values, frontend, stock.timing_offset_samples, symbols, symbol_rate, methods
)
if "P0" in estimates and not np.isclose(
estimates["P0"].frequency_hz, stock.carrier["cfo_hz"], atol=1e-10
):
raise AssertionError(f"P0 {dataset} не воспроизвёл штатную оценку")
rows = []
for method, estimate in estimates.items():
normalized_probe = compensate(symbols, symbol_rate, estimate)
header = lab057.physical.decode_phy_header_soft(
normalized_probe.real[PREAMBLE_SYMBOLS:PROBE_SYMBOLS]
)
full_soft = normalized_probe.real[PREAMBLE_SYMBOLS:]
if header["passed"]:
packet_bytes = int(header["packet_bytes"])
count = (
PREAMBLE_SYMBOLS + 144
+ lab057.physical.conv.encoded_bit_count(packet_bytes * 8)
)
full_values = reader.read(start, (count + 42) * frontend.samples_per_symbol)
if method == "P0":
full_stock = frontend.extract(full_values, count)
if not full_stock.passed:
raise RuntimeError(f"{dataset} P0 full extract: {full_stock.reason}")
full_soft = full_stock.normalized_real[PREAMBLE_SYMBOLS:]
else:
full_matched = frontend.filter(full_values)
full_symbols = _extract_symbols(
full_matched, frontend.nominal, stock.timing_offset_samples,
count, frontend.samples_per_symbol,
)
full_soft = compensate(full_symbols, symbol_rate, estimate).real[PREAMBLE_SYMBOLS:]
decoded, _packet = lab057.decode_body(full_soft)
decisions = normalized_probe.real >= 0
expected_header_bool = expected_header.astype(bool)
voted = normalized_probe.real[PREAMBLE_SYMBOLS:PROBE_SYMBOLS].reshape(48, 3).sum(axis=1) >= 0
rows.append({
"dataset": dataset,
"label": f"{dataset}_{frame_number:02d}",
"frame": frame_number,
"symbol_rate": symbol_rate,
"method": method,
"timing_samples": int(stock.timing_offset_samples),
"frequency_hz": estimate.frequency_hz,
"diagnostic_reference_hz": None,
"frequency_error_hz": None,
"estimation_symbols": estimate.estimation_symbols,
"estimation_duration_seconds": estimate.estimation_duration_seconds,
"phase_drift_to_burst_end_degrees": phase_drift_degrees(
estimate.frequency_hz, BURST_DURATION_SECONDS
),
"preamble_errors": int(np.count_nonzero(
decisions[:PREAMBLE_SYMBOLS] != lab057.PREAMBLE
)),
"validation_errors": estimate.validation_errors,
"validation_symbols": estimate.validation_symbols,
"validation_phase_rmse_rad": estimate.validation_phase_rmse_rad,
"header_raw_errors": int(np.count_nonzero(
decisions[PREAMBLE_SYMBOLS:PROBE_SYMBOLS] != expected_header_bool
)),
"header_voted_errors": int(np.count_nonzero(
voted != expected_header_bool[::3]
)),
"HEADER_ACCEPTED": bool(decoded["HEADER_ACCEPTED"]),
"FEC_STARTED": bool(decoded["FEC_STARTED"]),
"CRC_PASS": decoded["CRC_RESULT"] == lab057.physical.CRC_PASS,
"PACKET_OUTPUT": bool(decoded["PACKET_OUTPUT"]),
"symbol_errors": None,
"symbols": None,
"symbol_error_rate": None,
"evm_rms": None,
"matched_residual_db": None,
"estimate": _estimate_dict(estimate),
})
return rows
def analyze_packets(methods: Iterable[str]) -> list[dict]:
lab054_items, lab047_items, lab054_reader, lab047_reader = _packet_sources()
rows: list[dict] = []
plans = lab054.plans()
for item in lab054_items:
frame = int(item["true_start_frame"])
rows.extend(_packet_rows(
lab054_reader, int(item["refined_start"]), 500_000, "Lab054", frame,
plans[frame - 1].plan.phy_header_bits, methods,
))
expected_header_047 = lab057.physical.encode_phy_header(268)
for frame, item in enumerate(lab047_items, 1):
rows.extend(_packet_rows(
lab047_reader, int(item["refined_start_sample"]), 20_000, "Lab047", frame,
expected_header_047, methods,
))
return rows
def _clean_synthetic_preamble(symbol_rate: int) -> tuple[np.ndarray, dict]:
"""Сформировать чистые точки через фактические Fs и TX/RX RRC."""
values, truth = lab057.synthetic_waveform(
symbol_rate, delay_symbols=0.0, cfo_hz=0.0, phase_rad=0.0, es_n0_db=None
)
frontend = lab057.Frontend(symbol_rate)
probe = values[: (PROBE_SYMBOLS + 42) * frontend.samples_per_symbol]
stock = frontend.extract(probe, PROBE_SYMBOLS)
matched = frontend.filter(probe)
symbols = _extract_symbols(
matched, frontend.nominal, stock.timing_offset_samples,
PREAMBLE_SYMBOLS, frontend.samples_per_symbol,
)
indexes = np.arange(PREAMBLE_SYMBOLS)
gain = _gain_at_zero(symbols, lab057.EXPECTED, indexes, symbol_rate, 0.0)
normalized = symbols / gain
return normalized, {
"sample_rate_hz": SAMPLE_RATE,
"symbol_rate": symbol_rate,
"samples_per_symbol": frontend.samples_per_symbol,
"rrc_taps": len(frontend.taps),
"tx_truth": truth,
"clean_evm_rms": float(np.sqrt(np.mean(abs(normalized - lab057.EXPECTED) ** 2))),
}
def synthetic_study() -> tuple[list[dict], list[dict], dict, dict]:
"""Воспроизводимый Monte Carlo с фиксированными частотами, EVM и seed."""
aggregate_rows: list[dict] = []
by_cfo_rows: list[dict] = []
distributions: dict[tuple[int, str, str], np.ndarray] = {}
clean_metadata = {}
for symbol_rate in (20_000, 500_000):
clean, clean_info = _clean_synthetic_preamble(symbol_rate)
clean_metadata[str(symbol_rate)] = clean_info
times = (np.arange(PREAMBLE_SYMBOLS) + 0.5) / symbol_rate
for quality_index, (quality_name, evm_rms) in enumerate(QUALITY_LEVELS):
method_errors = {method: [] for method in METHODS}
method_validation = {method: [] for method in METHODS}
for frequency_index, true_frequency in enumerate(SYNTHETIC_FREQUENCIES_HZ):
seed = 59_000_000 + symbol_rate + quality_index * 100_000 + frequency_index * 1_000
random_generator = np.random.default_rng(seed)
errors_by_method = {method: [] for method in METHODS}
validation_by_method = {method: [] for method in METHODS}
for _trial in range(MONTE_CARLO_TRIALS):
phase = random_generator.uniform(-np.pi, np.pi)
noise = evm_rms / np.sqrt(2.0) * (
random_generator.normal(size=PREAMBLE_SYMBOLS)
+ 1j * random_generator.normal(size=PREAMBLE_SYMBOLS)
)
received = (
clean * np.exp(1j * (phase + 2 * np.pi * true_frequency * times))
+ noise
)
for method in METHODS:
estimate = estimate_carrier(received, symbol_rate, method)
error = estimate.frequency_hz - true_frequency
errors_by_method[method].append(error)
validation_by_method[method].append(estimate.validation_phase_rmse_rad)
for method in METHODS:
errors = np.asarray(errors_by_method[method], dtype=np.float64)
validation = np.asarray(validation_by_method[method], dtype=np.float64)
method_errors[method].extend(errors.tolist())
method_validation[method].extend(validation.tolist())
by_cfo_rows.append({
"symbol_rate": symbol_rate,
"quality": quality_name,
"target_evm_rms": evm_rms,
"true_frequency_hz": float(true_frequency),
"method": method,
"trials": MONTE_CARLO_TRIALS,
**_error_statistics(errors),
"validation_phase_rmse_rad": float(np.mean(validation)),
})
for method in METHODS:
errors = np.asarray(method_errors[method], dtype=np.float64)
validation = np.asarray(method_validation[method], dtype=np.float64)
distributions[(symbol_rate, quality_name, method)] = errors
aggregate_rows.append({
"symbol_rate": symbol_rate,
"quality": quality_name,
"target_evm_rms": evm_rms,
"method": method,
"trials": len(errors),
"cfo_values": len(SYNTHETIC_FREQUENCIES_HZ),
**_error_statistics(errors),
"validation_phase_rmse_rad": float(np.mean(validation)),
})
return aggregate_rows, by_cfo_rows, distributions, clean_metadata
def _error_statistics(errors: np.ndarray) -> dict:
absolute = np.abs(errors)
return {
"bias_hz": float(np.mean(errors)),
"rmse_hz": float(np.sqrt(np.mean(errors**2))),
"median_abs_error_hz": float(np.median(absolute)),
"p95_abs_error_hz": float(np.percentile(absolute, 95)),
"p99_abs_error_hz": float(np.percentile(absolute, 99)),
"catastrophic_error_rate": float(np.mean(absolute > CATASTROPHIC_ERROR_HZ)),
"probability_abs_error_le_0p25_hz": float(np.mean(absolute <= 0.25)),
"probability_abs_error_le_0p5_hz": float(np.mean(absolute <= 0.5)),
"probability_abs_error_le_1p0_hz": float(np.mean(absolute <= 1.0)),
}
def _csv_value(value):
if isinstance(value, complex):
return json.dumps(serializable(value), ensure_ascii=False)
if isinstance(value, (dict, list, tuple)):
return json.dumps(serializable(value), ensure_ascii=False, separators=(",", ":"))
return value
def write_csv(path: Path, rows: list[dict], exclude: tuple[str, ...] = ()) -> None:
fields: list[str] = []
for row in rows:
fields.extend(key for key in row if key not in fields and key not in exclude)
with path.open("w", newline="", encoding="utf-8-sig") as stream:
writer = csv.DictWriter(stream, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow({key: _csv_value(row.get(key)) for key in fields})
def summarize_real(rows: list[dict]) -> dict:
summary = {}
for dataset in ("Lab056", "Lab054", "Lab047"):
summary[dataset] = {}
for method in METHODS:
selected = [row for row in rows if row["dataset"] == dataset and row["method"] == method]
if not selected:
continue
if dataset == "Lab056":
b_rows = [row for row in selected if row["label"].startswith("B")]
a_rows = [row for row in selected if row["label"].startswith("A")]
def mean_or_none(items: list[dict], key: str) -> float | None:
return float(np.mean([row[key] for row in items])) if items else None
summary[dataset][method] = {
"segments": len(selected),
"mean_abs_frequency_error_hz": float(np.mean([abs(row["frequency_error_hz"]) for row in selected])),
"A_frequency_errors_hz": [row["frequency_error_hz"] for row in a_rows],
"B_frequency_errors_hz": [row["frequency_error_hz"] for row in b_rows],
"B_mean_abs_frequency_error_hz": (
float(np.mean([abs(row["frequency_error_hz"]) for row in b_rows]))
if b_rows else None
),
"B_mean_symbol_error_rate": mean_or_none(b_rows, "symbol_error_rate"),
"A_mean_symbol_error_rate": mean_or_none(a_rows, "symbol_error_rate"),
"B_mean_evm_rms": mean_or_none(b_rows, "evm_rms"),
"B_mean_matched_residual_db": mean_or_none(b_rows, "matched_residual_db"),
}
else:
summary[dataset][method] = {
"frames": len(selected),
"header_accepted": sum(bool(row["HEADER_ACCEPTED"]) for row in selected),
"fec_started": sum(bool(row["FEC_STARTED"]) for row in selected),
"crc_pass": sum(bool(row["CRC_PASS"]) for row in selected),
"packet_output": sum(bool(row["PACKET_OUTPUT"]) for row in selected),
"preamble_errors": sum(int(row["preamble_errors"]) for row in selected),
"header_raw_errors": sum(int(row["header_raw_errors"]) for row in selected),
"header_voted_errors": sum(int(row["header_voted_errors"]) for row in selected),
"mean_frequency_hz": float(np.mean([row["frequency_hz"] for row in selected])),
}
return summary
def phase_limit_table() -> list[dict]:
return [
{
"phase_limit_degrees": limit,
"max_frequency_error_hz": maximum_frequency_error_hz(limit, BURST_DURATION_SECONDS),
}
for limit in (15, 30, 45, 60, 90)
]
def mathematics_audit() -> dict:
return {
"active_input": "matched-filter symbol centers after nominal 50 kHz downconversion",
"p0_squares_bpsk": True,
"p0_symbol_ranges_half_open": [[0, 512], [512, 1024]],
"p0_discarded_preamble_symbols_half_open": [1024, 1280],
"block_center_symbol_times": [256.0, 768.0],
"center_distance_symbols": 512,
"phase_difference_formula": "angle(block1)-angle(block0), unwrapped",
"frequency_formula": "delta_phase/(4*pi*(512/Rs))",
"factor_two_after_squaring": True,
"compensation_sign": "negative complex exponential",
"reference_time": "gain phase at t=0; symbol k center at (k+0.5)/Rs",
"p0_unambiguous_abs_hz": {"20000": 20_000 / 2048, "500000": 500_000 / 2048},
"double_compensation": False,
}
def choose_verdict(real_summary: dict, synthetic_rows: list[dict]) -> tuple[str, str, str]:
b_quality = {
row["method"]: row
for row in synthetic_rows
if row["symbol_rate"] == 500_000 and row["quality"] == "LAB058_B_EVM"
}
stable = {
method: b_quality[method]["probability_abs_error_le_0p5_hz"] >= 0.95
for method in METHODS
}
best = min(METHODS, key=lambda method: real_summary["Lab056"][method]["B_mean_symbol_error_rate"])
diagnostic = 0.03495896
approaches_diagnostic = real_summary["Lab056"][best]["B_mean_symbol_error_rate"] <= diagnostic + 0.02
lab047_preserved = real_summary["Lab047"][best]["packet_output"] == 36
a_preserved = real_summary["Lab056"][best]["A_mean_symbol_error_rate"] <= 0.001
if stable[best] and approaches_diagnostic and lab047_preserved and a_preserved:
return (
"PREAMBLE_ONLY_SUFFICIENT",
f"{best} стабилен в Monte Carlo и сохраняет A/Lab047",
f"подготовить внедрение {best} в штатный receiver",
)
if max(row["probability_abs_error_le_0p5_hz"] for row in b_quality.values()) >= 0.5:
return (
"PREAMBLE_ONLY_MARGINAL",
"улучшение есть, но субгерцовая точность на качестве B достигается нестабильно",
"проверить decision-directed carrier tracking through burst",
)
return (
"PREAMBLE_ONLY_INSUFFICIENT",
"ни один preamble-only метод не обеспечивает устойчивую ошибку менее 0,5 Гц на качестве B",
"проверить decision-directed carrier tracking through burst",
)
def make_plots(
real_rows: list[dict],
synthetic_rows: list[dict],
distributions: dict,
) -> None:
colors = {"P0": "#6c757d", "P1": "#2878b5", "P2": "#e17c05", "P3": "#54a24b"}
b_rows = [row for row in real_rows if row["dataset"] == "Lab056" and row["label"].startswith("B")]
figure, axis = plt.subplots(figsize=(9, 5), constrained_layout=True)
x = np.arange(2)
width = 0.18
for index, method in enumerate(METHODS):
selected = sorted((row for row in b_rows if row["method"] == method), key=lambda row: row["label"])
axis.bar(x + (index - 1.5) * width, [row["frequency_error_hz"] for row in selected], width,
label=method, color=colors[method])
axis.axhline(0, color="black", linewidth=0.8)
axis.set(xticks=x, xticklabels=["B1", "B2"], ylabel="Ошибка оценки, Гц",
title="Lab056 B: preamble-only CFO относительно whole-burst reference")
axis.legend(ncol=4); axis.grid(axis="y", alpha=0.25)
figure.savefig(OUTPUT / "frequency_error_by_method.png", dpi=160)
plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 5), constrained_layout=True)
bins = np.linspace(-35, 35, 141)
for method in METHODS:
values = distributions[(500_000, "LAB058_B_EVM", method)]
axis.hist(values, bins=bins, density=True, histtype="step", linewidth=1.5,
label=method, color=colors[method])
axis.axvspan(-0.5, 0.5, color="green", alpha=0.12, label="±0,5 Гц")
axis.set(xlabel="Ошибка оценки частоты, Гц", ylabel="Плотность",
title="Synthetic 500 ксимв/с, качество Lab058 B")
axis.legend(); axis.grid(alpha=0.2)
figure.savefig(OUTPUT / "synthetic_500k_error_distribution.png", dpi=160)
plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 5), constrained_layout=True)
time = np.linspace(0, BURST_DURATION_SECONDS, 300)
for method in METHODS:
errors = [row["frequency_error_hz"] for row in b_rows if row["method"] == method]
axis.plot(time * 1000, 360 * float(np.mean(errors)) * time,
label=method, color=colors[method])
for limit in (-30, 30):
axis.axhline(limit, color="black", linestyle="--", linewidth=0.8)
axis.set(xlabel="Время от начала burst, мс", ylabel="Фазовый уход, градусы",
title="Средняя ошибка B1/B2, экстраполированная на burst")
axis.legend(); axis.grid(alpha=0.25)
figure.savefig(OUTPUT / "burst_phase_drift.png", dpi=160)
plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 5), constrained_layout=True)
for index, method in enumerate(METHODS):
selected = sorted((row for row in b_rows if row["method"] == method), key=lambda row: row["label"])
axis.bar(x + (index - 1.5) * width, [100 * row["symbol_error_rate"] for row in selected], width,
label=method, color=colors[method])
axis.axhline(3.495896, color="black", linestyle="--", label="whole-burst reference")
axis.set(xticks=x, xticklabels=["B1", "B2"], ylabel="Ошибочные символы, %",
title="Lab056 B: полный burst после preamble-only оценки")
axis.legend(ncol=3); axis.grid(axis="y", alpha=0.25)
figure.savefig(OUTPUT / "lab056_b_symbol_errors.png", dpi=160)
plt.close(figure)
lab054_rows = [row for row in real_rows if row["dataset"] == "Lab054"]
figure, axis = plt.subplots(figsize=(9, 5), constrained_layout=True)
accepted = [sum(row["HEADER_ACCEPTED"] for row in lab054_rows if row["method"] == method) for method in METHODS]
crc = [sum(row["CRC_PASS"] for row in lab054_rows if row["method"] == method) for method in METHODS]
positions = np.arange(len(METHODS))
axis.bar(positions - 0.18, accepted, 0.36, label="HEADER_ACCEPTED")
axis.bar(positions + 0.18, crc, 0.36, label="CRC_PASS")
axis.set(xticks=positions, xticklabels=METHODS, ylabel="Кадры из 16",
title="Lab054: строгий результат по preamble-only методам", ylim=(0, 16.5))
if max(accepted + crc) == 0:
axis.text(1.5, 8.0, "Все методы: 0/16 HEADER и CRC", ha="center", va="center")
axis.set_xlim(-0.6, 3.6)
axis.legend(); axis.grid(axis="y", alpha=0.25)
figure.savefig(OUTPUT / "lab054_result_by_method.png", dpi=160)
plt.close(figure)
def run_functional_tests() -> None:
"""Быстрые проверки формул без файлов IQ."""
assert maximum_frequency_error_hz(30, BURST_DURATION_SECONDS) == 0.40690104166666663
assert np.isclose(phase_slope_to_hz(2 * np.pi * 7, squared_bpsk=False), 7)
assert np.isclose(phase_slope_to_hz(4 * np.pi * -7, squared_bpsk=True), -7)
assert set(P2_TRAIN).isdisjoint(P2_VALIDATION)
assert len(P2_TRAIN) == len(P2_VALIDATION) == 576
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
run_functional_tests()
source_paths = [
ROOT / "data/raw/lab056/rx_stream.cs8",
ROOT / "data/raw/lab056/tx_abab.cs8",
ROOT / "data/raw/lab054/rx_stream.cs8",
ROOT / "data/raw/lab054/tx_stream.cs8",
]
audit = json.loads((ROOT / "data/processed/lab055/energy_audit_v2.json").read_text(encoding="utf-8"))
lab047_directory = Path(audit["experiments"]["Lab047"]["directory"])
source_paths.append(lab047_directory / "rx_stream.cs8")
integrity_before = {str(path): {"bytes": path.stat().st_size, "sha256": sha256(path)} for path in source_paths}
# P0 действительно завершается и сохраняется до вычисления P1/P2/P3.
baseline_rows = analyze_lab056(("P0",)) + analyze_packets(("P0",))
baseline = {
"algorithm": "unchanged active CURRENT estimator",
"rows": baseline_rows,
"summary": summarize_real(baseline_rows),
"mathematics": mathematics_audit(),
}
write_json(OUTPUT / "baseline_p0.json", baseline)
real_rows = analyze_lab056(METHODS) + analyze_packets(METHODS)
real_summary = summarize_real(real_rows)
synthetic_rows, by_cfo_rows, distributions, clean_metadata = synthetic_study()
verdict, reason, next_step = choose_verdict(real_summary, synthetic_rows)
integrity_after = {str(path): {"bytes": path.stat().st_size, "sha256": sha256(path)} for path in source_paths}
if integrity_before != integrity_after:
raise AssertionError("Сохранённые исходные IQ изменились")
phase_limits = phase_limit_table()
summary = {
"status": "COMPLETED",
"lab058_commit": "82f17499ace5e655991240348401b9700c7c3349",
"rf_tx": 0,
"hardware_access": 0,
"preamble_symbols": PREAMBLE_SYMBOLS,
"preamble_duration_seconds": {"20000": PREAMBLE_SYMBOLS / 20_000, "500000": PREAMBLE_SYMBOLS / 500_000},
"burst_duration_seconds": BURST_DURATION_SECONDS,
"phase_limits": phase_limits,
"catastrophic_error_threshold_hz": CATASTROPHIC_ERROR_HZ,
"mathematics": mathematics_audit(),
"p2_split": {"train_half_open": [32, 608], "validation_half_open": [672, 1248]},
"real": real_summary,
"synthetic": {
"trials_per_frequency_quality_rate": MONTE_CARLO_TRIALS,
"frequency_values_hz": SYNTHETIC_FREQUENCIES_HZ,
"quality_levels": [{"name": name, "evm_rms": value} for name, value in QUALITY_LEVELS],
"clean_rrc_controls": clean_metadata,
"awgn_crlb_standard_deviation_hz": {
"formula": "sqrt(6*EVM^2*Rs^2/((2*pi)^2*N*(N^2-1)))",
"assumptions": "known symbols, unknown constant phase, complex AWGN",
"500000_lab058_b_evm": awgn_cfo_crlb_standard_deviation_hz(
0.845350, PREAMBLE_SYMBOLS, 500_000
),
},
"aggregate": synthetic_rows,
},
"verdict": verdict,
"main_reason": reason,
"recommended_next_single_step": next_step,
"future_options": {
"decision_directed": "первый кандидат: не меняет формат и не знает payload truth",
"costas_loop": "альтернатива для слежения по всему burst; требует настройки полосы петли",
"periodic_pilots": "наиболее явная опора, но требует изменения формата и overhead",
},
"physical_limit": {
"lab058_b_diagnostic_symbol_error_rate": 0.03495896,
"lab058_b_evm_rms": 0.845350,
"lab058_b_matched_residual_db": 1.464846,
"lab058_b_channel_ripple_db": 5.471382,
},
"lab054_historical_result": "NOT_A, 0/16",
"lab059_commit": "NOT_PERFORMED",
"push": "NOT_PERFORMED",
"integrity": {"before": integrity_before, "after": integrity_after, "passed": True},
}
write_csv(OUTPUT / "real_results.csv", real_rows, exclude=("estimate",))
write_csv(OUTPUT / "synthetic_results.csv", synthetic_rows)
write_csv(OUTPUT / "synthetic_by_cfo.csv", by_cfo_rows)
write_json(OUTPUT / "summary.json", summary)
make_plots(real_rows, synthetic_rows, distributions)
print(json.dumps(serializable({
"status": summary["status"],
"verdict": verdict,
"main_reason": reason,
"real": real_summary,
"synthetic_500k_b": [
row for row in synthetic_rows
if row["symbol_rate"] == 500_000 and row["quality"] == "LAB058_B_EVM"
],
"rf_tx": 0,
}), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()