Lab061: integrate T2 into the strict receiver

This commit is contained in:
LittleSam129
2026-09-14 11:07:40 +03:00
parent 92b0e0f8dd
commit 25cc89f0c7
20 changed files with 1290 additions and 20 deletions

View File

@@ -25,6 +25,7 @@ from scipy.signal import oaconvolve
from experiments import lab052_candidate_detection as detection
from experiments.lab053_viterbi import viterbi_decode_soft, warmup
from protocol import bpsk_carrier_tracking as carrier_tracking
previous = detection.previous
physical = previous.lab045
@@ -83,9 +84,11 @@ def measured(name):
@lru_cache(maxsize=16)
def rotation(length: int) -> np.ndarray:
def rotation(length: int, sample_rate_hz: int = SAMPLE_RATE) -> np.ndarray:
indexes = np.arange(length)
values = np.exp(-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE).astype(np.complex64)
values = np.exp(
-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / sample_rate_hz
).astype(np.complex64)
values.flags.writeable = False
return values
@@ -97,24 +100,46 @@ class PacketProcessor:
Полное тело по-прежнему запрашивается только после валидного PHY header.
"""
def __init__(self, *, viterbi=viterbi_decode_soft):
def __init__(
self,
*,
viterbi=viterbi_decode_soft,
carrier_tracking_mode: str = "legacy",
sample_rate_hz: int = SAMPLE_RATE,
symbol_rate: int = SYMBOL_RATE,
):
if carrier_tracking_mode not in ("legacy", "t2"):
raise ValueError("carrier_tracking_mode должен быть legacy или t2")
if type(sample_rate_hz) is not int or type(symbol_rate) is not int:
raise TypeError("Fs и Rs должны быть целыми")
if sample_rate_hz <= 0 or symbol_rate <= 0 or sample_rate_hz % symbol_rate:
raise ValueError("Fs должен быть положительным целым кратным Rs")
self.carrier_tracking_mode = carrier_tracking_mode
self.sample_rate_hz = sample_rate_hz
self.symbol_rate = symbol_rate
self.samples_per_symbol = sample_rate_hz // symbol_rate
self.startup = warmup() if viterbi is viterbi_decode_soft else {"backend": "CUSTOM", "warmup_seconds": 0}
constants = {"SAMPLES_PER_SYMBOL": SAMPLES_PER_SYMBOL, "SYMBOL_RATE": SYMBOL_RATE}
constants = {
"SAMPLES_PER_SYMBOL": self.samples_per_symbol,
"SYMBOL_RATE": self.symbol_rate,
}
scale = bind(physical.lab044._scale_and_carrier, **constants)
self.bits = physical.lab044.preamble_bits().copy()
waveform = scale(previous.iq.radio.bpsk_modulate(self.bits))
self.reference = physical.lab044.signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
self.taps = bind(lab043._matched_filter_taps, **constants)()
self.expected = previous.iq.radio.bpsk_modulate(self.bits)
self.kernel = np.zeros((len(self.bits) - 1) * SAMPLES_PER_SYMBOL + 1, np.complex64)
self.kernel[::SAMPLES_PER_SYMBOL] = np.conj(self.expected)
self.kernel = np.zeros(
(len(self.bits) - 1) * self.samples_per_symbol + 1, np.complex64
)
self.kernel[::self.samples_per_symbol] = np.conj(self.expected)
self.kernel = self.kernel[::-1].copy()
for values in (self.bits, self.reference, self.taps, self.expected, self.kernel):
values.flags.writeable = False
slow_offsets = bind(lab043._timing_offsets, **constants)
slow_timing = bind(previous.ORIGINAL_TIMING, _timing_offsets=slow_offsets, **constants)
self.timing = measured("timing")(bind(previous.fast_preamble_timing,
lab045=SimpleNamespace(SAMPLES_PER_SYMBOL=SAMPLES_PER_SYMBOL),
lab045=SimpleNamespace(SAMPLES_PER_SYMBOL=self.samples_per_symbol),
timing_kernel=lambda unused: self.kernel, ORIGINAL_TIMING=slow_timing))
estimate = measured("cfo_estimation")(bind(lab043._estimate_cfo_gain, **constants))
self.carrier = measured("phase_polarity")(bind(lab043._evaluate_symbols,
@@ -142,7 +167,20 @@ class PacketProcessor:
@measured("frequency_shift")
def shift(self, segment):
return (segment - np.mean(segment)) * rotation(len(segment))
if self.carrier_tracking_mode == "legacy":
dc_reference = segment
else:
# Последний raw-отсчёт, способный попасть через причинный RRC в
# последнюю P3-точку преамбулы. Header в оценку DC не попадает.
fixed_prefix = (
16 * self.samples_per_symbol
+ self.taps.size
+ (carrier_tracking.PREAMBLE_SYMBOLS - 1) * self.samples_per_symbol
)
dc_reference = segment[:min(len(segment), fixed_prefix)]
return (segment - np.mean(dc_reference)) * rotation(
len(segment), self.sample_rate_hz
)
@measured("matched_rrc_filtering")
def filter(self, values):
@@ -150,22 +188,55 @@ class PacketProcessor:
@measured("soft_symbol_extraction")
def normalize(self, matched, nominal, timing, count, carrier):
symbols = matched[nominal + timing:nominal + timing + count * SAMPLES_PER_SYMBOL:SAMPLES_PER_SYMBOL].astype(np.complex128)
symbols = matched[
nominal + timing:
nominal + timing + count * self.samples_per_symbol:
self.samples_per_symbol
].astype(np.complex128)
if symbols.size != count:
return None
symbol_time = (np.arange(count, dtype=np.float64) + .5) / SYMBOL_RATE
normalized = symbols * np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time)
normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
return normalized.real
if self.carrier_tracking_mode == "legacy":
symbol_time = (
np.arange(count, dtype=np.float64) + 0.5
) / self.symbol_rate
normalized = symbols * np.exp(
-2j * math.pi * carrier["cfo_hz"] * symbol_time
)
normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
return normalized.real
if count < carrier_tracking.PREAMBLE_SYMBOLS:
return None
estimate = carrier_tracking.estimate_p3(
symbols[:carrier_tracking.PREAMBLE_SYMBOLS],
self.expected,
self.symbol_rate,
)
carrier.update({
"cfo_hz": estimate.frequency_hz,
"abs_h": float(abs(estimate.gain)),
"phase_rad": float(np.angle(estimate.gain)),
"polarity": "resolved from P3 full preamble",
})
result = carrier_tracking.track_t2(
symbols,
self.symbol_rate,
estimate,
start_index=carrier_tracking.PREAMBLE_SYMBOLS,
)
context = CURRENT.get()
if context is not None:
context.cache.setdefault("carrier_tracking_traces", []).append(result)
return result.corrected.real
@measured("extract_total")
def extract(self, samples, frame_start, symbol_count):
length = (symbol_count + 42) * SAMPLES_PER_SYMBOL
length = (symbol_count + 42) * self.samples_per_symbol
if frame_start < 0 or frame_start + length > samples.size:
return previous.iq.SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW")
segment = samples[frame_start:frame_start + length]
matched = self.filter(self.shift(segment))
nominal = 16 * SAMPLES_PER_SYMBOL + self.taps.size - 1
nominal = 16 * self.samples_per_symbol + self.taps.size - 1
timing = self.timing(matched, nominal, self.expected)
carrier = self.carrier(matched, nominal, timing, self.bits, len(self.bits))
if carrier["abs_h"] == 0:
@@ -174,7 +245,7 @@ class PacketProcessor:
if values is None:
return previous.iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS")
return previous.iq.SoftWindow(True, values, int(timing),
int((frame_start + nominal + timing) % SAMPLES_PER_SYMBOL), nominal, carrier, None)
int((frame_start + nominal + timing) % self.samples_per_symbol), nominal, carrier, None)
def decode(self, samples, candidate, number=1):
context = PacketMeasure()
@@ -189,7 +260,11 @@ class PacketProcessor:
"thread_cpu_seconds": time.thread_time() - cpu_started,
"stages": dict(context.seconds), "exclusive_stages": dict(context.exclusive_seconds),
"calls": dict(context.calls), "coarse": candidate.sample_offset,
"refined": outcome.refined_start_sample}
"refined": outcome.refined_start_sample,
"carrier_tracking_mode": self.carrier_tracking_mode,
"carrier_tracking_traces": tuple(
context.cache.get("carrier_tracking_traces", ())
)}
if outcome.frame is not None:
row.update(timing=outcome.frame.timing_offset_samples, cfo_hz=outcome.frame.estimated_cfo_hz,
phase_rad=outcome.frame.estimated_phase_rad, polarity=outcome.frame.bpsk_polarity)
@@ -201,6 +276,7 @@ class PacketProcessor:
def run_functional_tests() -> None:
processor = PacketProcessor()
assert len(processor.bits) == 1280 and len(processor.taps) == 161
assert processor.carrier_tracking_mode == "legacy"
assert not processor.reference.flags.writeable

View File

@@ -0,0 +1,260 @@
"""Lab061: офлайн-интеграция T2 в существующий строгий приёмник."""
from __future__ import annotations
import csv
import hashlib
import json
import math
from pathlib import Path
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 experiments import lab045_hackrf_bpsk_fec as lab045
from experiments import lab049_bpsk_symbol_rate as lab049
from experiments import lab052_candidate_detection as detection
from experiments import lab053_packet_processing as packet
from experiments import lab053_streaming_receiver as streaming
from experiments import lab058_abab_symbol_quality as lab058
from experiments import lab059_preamble_carrier_limits as lab059
from experiments import lab060_decision_directed_carrier_tracking as lab060
from protocol import bpsk_carrier_tracking as tracking
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab061"
FREQUENCIES = (-10, -5, -2, -1, -0.5, -0.25, 0, 0.25, 0.5, 1, 2, 5, 10)
QUALITIES = {"GOOD": 2.0, "MEDIUM": 24.0, "LAB056_B_LIKE": 105.0}
def write_csv(path: Path, rows: list[dict]) -> None:
if not rows:
return
columns = list(rows[0])
with path.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, columns)
writer.writeheader()
writer.writerows(rows)
def full_packet() -> tuple[np.ndarray, bytes, np.ndarray]:
payload = bytes(range(256))
plan = lab045.build_transmission_plan(payload)
raw = np.frombuffer(
lab049.build_parameterized_tx_cs8(plan, lab049.PhyMode(500_000)), np.int8
)
values = raw[::2].astype(np.float32) + 1j * raw[1::2].astype(np.float32)
expected = lab049.radio.bpsk_modulate(plan.frame_bits)
return values.astype(np.complex64), payload, expected
def stream_decode(values: np.ndarray, mode: str) -> tuple[list, streaming.StreamingReceiver, list[dict]]:
observed: list[dict] = []
receiver = streaming.StreamingReceiver(
processor=packet.PacketProcessor(carrier_tracking_mode=mode),
packet_observer=lambda row, outcome: observed.append({
"reason": outcome.reason,
"accepted": outcome.accepted,
"header": bool(outcome.phy_header_pass),
"fec": bool(outcome.fec_pass),
"crc": outcome.crc == "PASS",
"traces": row["carrier_tracking_traces"],
}),
)
output = []
for start in range(0, len(values), receiver.block_samples):
output.extend(receiver.push_iq_block(values[start:start + receiver.block_samples]))
output.extend(receiver.finish())
return output, receiver, observed
def synthetic_study() -> tuple[list[dict], dict[str, np.ndarray]]:
clean, payload, expected = full_packet()
indexes = np.arange(len(clean))
rows: list[dict] = []
representative: dict[str, np.ndarray] = {}
for quality_number, (quality, noise_sigma) in enumerate(QUALITIES.items()):
for frequency in FREQUENCIES:
seed = 61_000 + quality_number * 100 + FREQUENCIES.index(frequency)
random_generator = np.random.default_rng(seed)
noise = random_generator.normal(0, noise_sigma, len(clean)) + 1j * random_generator.normal(0, noise_sigma, len(clean))
impaired = clean * np.exp(1j * (0.37 + 2 * np.pi * frequency * indexes / 8_000_000)) + noise
values = np.concatenate((np.zeros(20_000), impaired, np.zeros(20_000))).astype(np.complex64)
for mode in ("legacy", "t2"):
output, receiver, observed = stream_decode(values, mode)
header = any(row["header"] for row in observed)
fec = any(row["fec"] for row in observed)
crc = any(row["crc"] for row in observed)
traces = [trace for item in observed for trace in item["traces"]]
full_trace = max(traces, key=lambda item: len(item.corrected)) if traces else None
measured_evm = None
if full_trace is not None and len(full_trace.corrected) == len(expected):
selected = slice(1280, None)
gain = np.mean(full_trace.corrected[selected] * expected[selected])
residual = full_trace.corrected[selected] - gain * expected[selected]
measured_evm = float(np.sqrt(np.mean(abs(residual)**2) / abs(gain)**2))
rows.append({
"quality": quality, "noise_sigma": noise_sigma,
"initial_frequency_error_hz": frequency, "initial_phase_rad": 0.37,
"seed": seed, "mode": mode, "measured_evm_rms": measured_evm,
"candidates": receiver.candidate_count,
"HEADER_ACCEPTED": header, "FEC_STARTED": header,
"FEC_PASS": fec, "CRC_STARTED": fec, "CRC_PASS": crc,
"PACKET_OUTPUT": len(output) == 1 and output[0].payload == payload,
})
if mode == "t2" and quality == "LAB056_B_LIKE" and frequency == 10 and observed:
if traces:
trace = max(traces, key=lambda item: len(item.phase_rad))
representative = {
"phase_rad": trace.phase_rad,
"frequency_hz": trace.frequency_hz,
"detector_error_rad": trace.detector_error_rad,
}
return rows, representative
def decode_known_candidates() -> list[dict]:
lab054_items, lab047_items, lab054_reader, lab047_reader = lab059._packet_sources()
rows: list[dict] = []
for dataset, symbol_rate, items, reader, key in (
("Lab047", 20_000, lab047_items, lab047_reader, "candidate_offset"),
("Lab054", 500_000, lab054_items, lab054_reader, "coarse_start"),
):
for mode in ("legacy", "t2"):
processor = packet.PacketProcessor(carrier_tracking_mode=mode, symbol_rate=symbol_rate)
for number, item in enumerate(items, 1):
start = int(item[key])
low = max(0, start - 4_000)
length = 2_400_000 if symbol_rate == 20_000 else 120_000
values = reader.read(low, min(length, reader.size - low))
candidate = detection.offline.CoarseCandidate(start - low, 1.0)
outcome, detail = processor.decode(values, candidate, number)
rows.append({
"dataset": dataset, "frame": number, "mode": mode,
"HEADER_ACCEPTED": outcome.phy_header_pass,
"FEC_STARTED": outcome.phy_header_pass,
"FEC_PASS": outcome.fec_pass,
"CRC_STARTED": outcome.fec_pass,
"CRC_PASS": outcome.crc == "PASS", "PACKET_OUTPUT": outcome.accepted,
"reason": outcome.reason, "initial_frequency_hz": detail.get("cfo_hz"),
})
return rows
def lab056_study() -> tuple[list[dict], dict[str, np.ndarray]]:
rows, trajectories = [], {}
for item in lab060._lab056_data():
source = item["estimate"]
estimate = tracking.P3CarrierEstimate(
source.frequency_hz, source.gain, source.phase_fit_rmse_rad, source.mean_block_coherence
)
result = tracking.track_t2(item["symbols"], item["symbol_rate"], estimate)
compatible = lab060.TrackResult(
tracker="T2", corrected=result.corrected,
hard_decisions=result.hard_decisions, phase_rad=result.phase_rad,
frequency_hz=result.frequency_hz,
detector_error_rad=result.detector_error_rad,
parameters=lab060.loop_parameters("T2", 0.0005),
initial_frequency_hz=estimate.frequency_hz,
initial_phase_rad=float(np.angle(estimate.gain)),
)
metrics, blocks = lab060.evaluate_tracking(compatible, item["expected"], item["symbol_rate"])
rows.append({"label": item["label"], "symbol_rate": item["symbol_rate"], **{k: v for k, v in metrics.items() if k != "receiver_lock"}, "stable_lock": metrics["receiver_lock"]["stable_lock"]})
trajectories[f"{item['label']}_phase_rad"] = result.phase_rad
trajectories[f"{item['label']}_frequency_hz"] = result.frequency_hz
return rows, trajectories
def background_study() -> dict:
source = json.loads((ROOT / "data/processed/lab060/summary.json").read_text(encoding="utf-8"))
background = source["background"][0]
start, count = int(background["rx_start"]), int(background["samples"])
reader = lab058.Cs8File(ROOT / "data/raw/lab054/rx_stream.cs8")
values = reader.read(start, count)
results = {}
for mode in ("legacy", "t2"):
output, receiver, observed = stream_decode(values, mode)
results[mode] = {"headers": sum(row["header"] for row in observed), "packets": len(output), "candidates": receiver.candidate_count}
return results
def saved_tx_control() -> dict:
reader = lab058.Cs8File(ROOT / "data/raw/lab054/tx_stream.cs8")
values = reader.read(0, reader.size)
results = {}
for mode in ("legacy", "t2"):
output, receiver, observed = stream_decode(values, mode)
results[mode] = {"headers": sum(row["header"] for row in observed), "packets": len(output)}
return results
def robustness() -> list[dict]:
symbols, expected, unused = lab060.clean_rrc_symbols(500_000)
estimate = tracking.estimate_p3(symbols[:1280], expected[:1280], 500_000)
cases = {}
for name, selected in (("single", slice(2000, 2001)), ("short", slice(2500, 2510)), ("long", slice(3000, 3080))):
damaged = symbols.astype(np.complex128)
damaged[selected] *= -1
cases[name] = damaged
fade = symbols.astype(np.complex128); fade[3500:3700] *= 0.02; cases["amplitude_drop"] = fade
noise = symbols.astype(np.complex128); random_generator = np.random.default_rng(61999); noise[4000:4300] = random_generator.normal(size=300) + 1j*random_generator.normal(size=300); cases["noise"] = noise
rows = []
for name, values in cases.items():
result = tracking.track_t2(values, 500_000, estimate)
rows.append({"case": name, "finite": bool(np.all(np.isfinite(result.corrected)) and np.all(np.isfinite(result.frequency_hz))), "max_abs_frequency_hz": float(np.max(abs(result.frequency_hz)))})
return rows
def plots(synthetic: list[dict], representative: dict, real: list[dict], background: dict) -> None:
plt.figure(); plt.plot(np.arange(len(representative["phase_rad"]))/500_000, representative["phase_rad"]); plt.xlabel("Время, с"); plt.ylabel("Фаза T2, рад"); plt.tight_layout(); plt.savefig(OUTPUT/"synthetic_t2_phase.png", dpi=160); plt.close()
plt.figure(); plt.plot(np.arange(len(representative["frequency_hz"]))/500_000, representative["frequency_hz"]); plt.xlabel("Время, с"); plt.ylabel("Частотное состояние, Гц"); plt.tight_layout(); plt.savefig(OUTPUT/"synthetic_t2_frequency.png", dpi=160); plt.close()
for mode in ("legacy", "t2"):
values = [np.mean([r["PACKET_OUTPUT"] for r in synthetic if r["mode"] == mode and r["initial_frequency_error_hz"] == f]) for f in FREQUENCIES]
plt.plot(FREQUENCIES, values, marker="o", label=mode)
plt.xlabel("Начальная ошибка частоты, Гц"); plt.ylabel("Доля пакетов"); plt.legend(); plt.tight_layout(); plt.savefig(OUTPUT/"packet_success_by_initial_cfo.png", dpi=160); plt.close()
for dataset, filename in (("Lab047", "lab047_regression.png"), ("Lab054", "lab054_strict_result.png")):
values = [sum(r["PACKET_OUTPUT"] for r in real if r["dataset"] == dataset and r["mode"] == mode) for mode in ("legacy", "t2")]
plt.figure(); plt.bar(("legacy", "t2"), values); plt.ylabel("CRC PASS пакеты"); plt.tight_layout(); plt.savefig(OUTPUT/filename, dpi=160); plt.close()
plt.figure(); plt.bar(("legacy headers", "legacy packets", "T2 headers", "T2 packets"), (background["legacy"]["headers"], background["legacy"]["packets"], background["t2"]["headers"], background["t2"]["packets"])); plt.ylabel("Количество"); plt.tight_layout(); plt.savefig(OUTPUT/"background_result.png", dpi=160); plt.close()
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
synthetic, representative = synthetic_study()
real = decode_known_candidates()
lab056, lab056_trajectories = lab056_study()
background = background_study()
tx_control = saved_tx_control()
robust = robustness()
write_csv(OUTPUT/"synthetic_packet_results.csv", synthetic)
write_csv(OUTPUT/"real_results.csv", real)
write_csv(OUTPUT/"lab056_results.csv", lab056)
write_csv(OUTPUT/"robustness_results.csv", robust)
np.savez_compressed(OUTPUT/"trajectories.npz", **representative, **lab056_trajectories)
plots(synthetic, representative, real, background)
by_mode = {mode: {"header_pass_rate": float(np.mean([r["HEADER_ACCEPTED"] for r in synthetic if r["mode"] == mode])), "crc_pass_rate": float(np.mean([r["CRC_PASS"] for r in synthetic if r["mode"] == mode]))} for mode in ("legacy", "t2")}
by_frequency = {str(f): float(np.mean([r["PACKET_OUTPUT"] for r in synthetic if r["mode"] == "t2" and r["initial_frequency_error_hz"] == f])) for f in FREQUENCIES}
summary = {
"lab": "Lab061", "status": "COMPLETED", "scope": "OFFLINE", "rf_tx": 0,
"hardware_queries": 0, "legacy_default": True, "automatic_fallback": False,
"t2_parameters": vars(tracking.t2_parameters()),
"synthetic": by_mode, "t2_packet_success_by_initial_frequency_error": by_frequency,
"real": {dataset: {mode: sum(r["PACKET_OUTPUT"] for r in real if r["dataset"] == dataset and r["mode"] == mode) for mode in ("legacy", "t2")} for dataset in ("Lab047", "Lab054")},
"lab056": lab056, "background": background, "saved_tx_control": tx_control,
"robustness": robust,
"payload_truth_leak_test": "PASS: strict API and T2 have no reference payload input; causal prefix test PASS",
"production_decision": "READY_BEHIND_EXPLICIT_MODE",
"physical_packet": {"bytes": 268, "symbols": 5724, "duration_seconds": len(full_packet()[0])/8_000_000},
"source_sha256": {str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() for path in (ROOT/"data/raw/lab054/rx_stream.cs8",)},
}
(OUTPUT/"summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, default=lambda value: value.item()), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2, default=lambda value: value.item()))
print("RF TX = 0")
if __name__ == "__main__":
main()