"""Lab057: изолированная диагностика сохранённых символов, без аппаратных команд.""" from __future__ import annotations import argparse import csv from collections import Counter from dataclasses import asdict, dataclass from functools import lru_cache import hashlib import json from pathlib import Path import sys from types import SimpleNamespace import numpy as np from scipy.signal import oaconvolve if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from experiments import lab053_packet_processing as packet from experiments import lab053_streaming_receiver as streaming from experiments import lab054_hardware_control as historical from experiments import lab055_energy_audit as energy physical = packet.physical radio = packet.previous.iq.radio ROOT = Path(__file__).resolve().parents[1] OUTPUT = ROOT / "data/processed/lab057" TRAIN = np.arange(32, 608) VALIDATION = np.arange(672, 1248) VARIANTS = ("S0", "D0", "D1", "D2", "D3") FS = 8_000_000 PROBE_SYMBOLS = 1424 PREAMBLE = physical.lab044.preamble_bits() EXPECTED = radio.bpsk_modulate(PREAMBLE) def plain(value): """JSON без потери комплексных статистик.""" if isinstance(value, dict): return {str(key): plain(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [plain(item) for item in value] if isinstance(value, np.ndarray): return plain(value.tolist()) if isinstance(value, complex): return {"real": value.real, "imag": value.imag} if isinstance(value, np.generic): return plain(value.item()) return value def save(name: str, value: dict | list) -> None: """Писать исключительно новый результат Lab057.""" OUTPUT.mkdir(parents=True, exist_ok=True) (OUTPUT / name).write_text(json.dumps(plain(value), ensure_ascii=False, indent=2), encoding="utf-8") class ObservedProcessor(packet.PacketProcessor): """Штатный обработчик с наблюдением первого probe; вычисления не подменяются.""" def __init__(self): super().__init__(viterbi=physical.conv.viterbi_decode_soft) self.probes = [] def extract(self, samples, frame_start, symbol_count): result = super().extract(samples, frame_start, symbol_count) self.probes.append(result) return result def decode(self, samples, candidate, number=1): self.probes = [] outcome, row = super().decode(samples, candidate, number) probe = self.probes[0] if self.probes else None row["probe"] = None if probe is None else { "timing": probe.timing_offset_samples, "carrier": probe.carrier, "soft": probe.normalized_real.tolist(), "passed": probe.passed, } row["fec_started"] = outcome.fec_attempted row["crc_result"] = outcome.crc row["header_accepted"] = outcome.phy_header_pass row["extract_calls"] = len(self.probes) return outcome, row def reproduce_baseline() -> dict: """Полный штатный поток получает только RX, без начала, числа кадров и TX.""" rows = [] def observe(row, outcome): rows.append(dict(row, candidate_id=len(rows) + 1, reason=outcome.reason, accepted=outcome.accepted)) receiver = streaming.StreamingReceiver(processor=ObservedProcessor(), packet_observer=observe) received = [] with (ROOT / "data/raw/lab054/rx_stream.cs8").open("rb") as source: while block := source.read(2 * 65536): values = np.frombuffer(block, np.int8) samples = (values[::2].astype(np.float32) + 1j * values[1::2]).astype(np.complex64) received.extend(receiver.push_iq_block(samples)) received.extend(receiver.finish()) expected = energy.read_json(ROOT / "data/processed/lab054/independent_result.json") comparisons = [(row["absolute_refined"], row["reason"]) for row in rows] old = [(row["start"], row["reason"]) for row in expected["rows"]] result = {"independent_stream": True, "tx_truth_used": False, "rf_tx": 0, "packets": len(received), "candidates": len(rows), "reasons": dict(Counter(row["reason"] for row in rows)), "fec_started": sum(row["fec_started"] for row in rows), "crc_started": sum(row["crc_result"] != "NOT_REACHED" for row in rows), "historical_order_and_starts_equal": comparisons == old, "rows": rows} result["passed"] = (comparisons == old and len(received) == 0 and len(rows) == 100 and result["fec_started"] == result["crc_started"] == 0 and set(result["reasons"]) == {"PHY_HEADER_MAGIC_MISMATCH"}) save("baseline.json", result) if not result["passed"]: raise RuntimeError("Baseline отличается от исторического: альтернативы запрещены") return result @dataclass(frozen=True) class Estimate: timing_samples: float cfo_hz: float gain: complex train_coherence: float frequency_boundary: bool = False timing_boundary: bool = False def interpolate(values: np.ndarray, positions: np.ndarray) -> np.ndarray: """Оконная sinc, ±8 исходных отсчётов; целые позиции сохраняются точно.""" positions = np.asarray(positions, np.float64) centers = np.floor(positions).astype(np.int64) offsets = np.arange(-7, 9) indexes = centers[..., None] + offsets if indexes.min() < 0 or indexes.max() >= len(values): raise ValueError("Опора интерполяции вне окна") distance = positions[..., None] - indexes weights = np.sinc(distance) * np.where(np.abs(distance) < 8, .5 + .5 * np.cos(np.pi * distance / 8), 0) weights /= weights.sum(axis=-1, keepdims=True) return np.sum(values[indexes] * weights, axis=-1) @lru_cache(maxsize=2) def frequency_grid(symbol_rate: int) -> tuple[np.ndarray, np.ndarray]: """Один заранее зафиксированный диапазон в циклах/символ.""" frequencies = np.linspace(-250, 250, 501) * (symbol_rate / 500_000) times = (TRAIN + .5) / symbol_rate rotations = np.exp(-2j * np.pi * frequencies[:, None] * times) return frequencies, rotations def fit_gain(symbols: np.ndarray, symbol_rate: int, cfo_hz: float) -> complex: """Единственная оценка комплексного коэффициента по TRAIN, фаза при t=0.""" times = (TRAIN + .5) / symbol_rate return complex(np.mean(symbols * EXPECTED[TRAIN] * np.exp(-2j * np.pi * cfo_hz * times))) def coherence(symbols: np.ndarray, gain: complex) -> float: """Нормированный TRAIN-критерий, без проверочных и информационных битов.""" return float(abs(gain) ** 2 / max(float(np.mean(abs(symbols) ** 2)), 1e-30)) def frequency_search(symbols: np.ndarray, symbol_rate: int) -> tuple[float, complex, float, bool]: """Сетка и единственное локальное уточнение; диапазон не расширяется.""" frequencies, rotations = frequency_grid(symbol_rate) power = abs(rotations @ (symbols * EXPECTED[TRAIN])) ** 2 index = int(np.argmax(power)) boundary = index in (0, len(frequencies) - 1) offset = 0.0 if not boundary: left, middle, right = power[index - 1:index + 2] denominator = left - 2 * middle + right if denominator < 0: offset = float(np.clip(.5 * (left - right) / denominator, -.5, .5)) frequency = float(frequencies[index] + offset * (frequencies[1] - frequencies[0])) gain = fit_gain(symbols, symbol_rate, frequency) return frequency, gain, coherence(symbols, gain), boundary def estimate_variant(matched: np.ndarray, nominal: int, symbol_rate: int, variant: str, stock_timing: float, stock_cfo: float) -> Estimate: """Оцениватель не получает ни header/payload, ни VALIDATION/CRC.""" samples_per_symbol = FS // symbol_rate if variant == "D3": offsets = np.linspace(-.5, .5, 33) * samples_per_symbol else: offsets = np.array([stock_timing]) best = None for offset in offsets: positions = nominal + offset + TRAIN * samples_per_symbol symbols = interpolate(matched, positions) if variant in ("D2", "D3"): frequency, gain, score, boundary = frequency_search(symbols, symbol_rate) else: frequency = stock_cfo if variant == "D0" else 0.0 gain = fit_gain(symbols, symbol_rate, frequency) score, boundary = coherence(symbols, gain), False candidate = Estimate(float(offset), frequency, gain, score, boundary, variant == "D3" and abs(offset) == samples_per_symbol / 2) if best is None or candidate.train_coherence > best.train_coherence: best = candidate return best def compensate(symbols: np.ndarray, symbol_rate: int, estimate: Estimate) -> np.ndarray: """Один знак компенсации, единая шкала времени для всех вариантов.""" if abs(estimate.gain) == 0: raise ValueError("Нулевой коэффициент") times = (np.arange(len(symbols)) + .5) / symbol_rate return symbols * np.exp(-2j * np.pi * estimate.cfo_hz * times) / estimate.gain def squared_cfo_statistics(symbols: np.ndarray, symbol_rate: int) -> dict: """Разложить ровно активную штатную двухблочную оценку, без иной модели.""" blocks = (symbols[:1024] ** 2).reshape(2, 512).mean(axis=1) phase = np.unwrap(np.angle(blocks)) delta = float(phase[1] - phase[0]) interval = 512 / symbol_rate return {"symbol_ranges": [[0, 512], [512, 1024]], "block_values": blocks, "block_abs": abs(blocks), "phase_difference_rad": delta, "block_center_times_seconds": [256 / symbol_rate, 768 / symbol_rate], "delta_time_seconds": interval, "cfo_hz": delta / (4 * np.pi * interval), "unambiguous_abs_hz": symbol_rate / 2048, "formula": "delta_phase / (4*pi*delta_time)", "input_rate_hz": symbol_rate, "discarded_symbols": [1024, 1280]} class Frontend: """Доступ к фактическому RX-фильтру и штатному извлечению нужной скорости.""" def __init__(self, symbol_rate: int): self.symbol_rate = symbol_rate self.samples_per_symbol = FS // symbol_rate self.processor = packet.PacketProcessor(viterbi=physical.conv.viterbi_decode_soft) self.taps = packet.bind(packet.lab043._matched_filter_taps, SAMPLES_PER_SYMBOL=self.samples_per_symbol)() self.nominal = 16 * self.samples_per_symbol + len(self.taps) - 1 def filter(self, values: np.ndarray) -> np.ndarray: if self.symbol_rate == 500_000: return self.processor.filter(self.processor.shift(values)) return packet.lab043._matched_waveform(values, 0, len(values))[0] def extract(self, values: np.ndarray, count: int): if self.symbol_rate == 500_000: return self.processor.extract(values, 0, count) return packet.previous.iq._extract_soft_window(values, 0, count) def train_support(self) -> tuple[int, int]: """Сырая опора всех TRAIN-проб: FIR, sinc и полсимвола по краям.""" margin = int(np.ceil(self.samples_per_symbol / 2)) + 8 low = self.nominal + int(TRAIN[0]) * self.samples_per_symbol - len(self.taps) + 1 - margin high = self.nominal + int(TRAIN[-1]) * self.samples_per_symbol + margin + 1 return low, high def estimation_filter(self, values: np.ndarray) -> np.ndarray: """Те же RRC/carrier; оценивание не читает IQ вне сырой опоры TRAIN. Локальный DC здесь — nuisance preprocessing оценивателя. Решения символов используют исходный штатный filter(), поэтому D0 не меняет вход решения. Короткая свёртка исключает даже округления FFT от проверочного участка. """ low, high = self.train_support() local = values[low:high] rotation = packet.lab043._carrier_downconversion_vector(low, len(local)) filtered = oaconvolve((local - np.mean(local)) * rotation, self.taps, mode="full") matched = np.zeros(len(values) + len(self.taps) - 1, dtype=filtered.dtype) matched[low:low + len(filtered)] = filtered return matched def decode_body(soft: np.ndarray) -> tuple[dict, bytes | None]: """Существующая проверка header/FEC/CRC; наблюдение реальных вызовов.""" calls = Counter() def viterbi(values, count): calls["fec"] += 1 return physical.conv.viterbi_decode_soft(values, count) def parse(values): calls["crc"] += 1 return physical.parse_packet(values) decoder = packet.bind(physical.analyze_post_preamble_soft, conv=SimpleNamespace(encoded_bit_count=physical.conv.encoded_bit_count, viterbi_decode_soft=viterbi), parse_packet=parse) reception = decoder(soft) result = reception.analysis return {"HEADER_ACCEPTED": bool(result["phy_header_pass"]), "FEC_STARTED": bool(calls["fec"]), "FEC_RESULT": ("PASS" if result["fec_decode_pass"] else "FAIL") if calls["fec"] else "NOT_REACHED", "CRC_STARTED": bool(calls["crc"]), "CRC_RESULT": result["crc"], "PACKET_OUTPUT": result["crc"] == physical.CRC_PASS, "protocol_header_pass": bool(result["protocol_header_pass"]), "decoder_analysis": result}, reception.packet_bytes def error_metrics(normalized: np.ndarray, symbols: np.ndarray, estimate: Estimate, symbol_rate: int, expected_header: np.ndarray | None) -> dict: """Оценка ошибки только после заморозки параметров; эталон не идёт оценивателю.""" decisions = normalized.real >= 0 errors = decisions[:1280] != PREAMBLE header = normalized.real[1280:1424] voted = header.reshape(48, 3).sum(axis=1) >= 0 times = (VALIDATION + .5) / symbol_rate predicted = estimate.gain * EXPECTED[VALIDATION] * np.exp(2j * np.pi * estimate.cfo_hz * times) residual = symbols[VALIDATION] - predicted result = {"preamble_errors": int(errors.sum()), "train_errors": int(errors[TRAIN].sum()), "validation_errors": int(errors[VALIDATION].sum()), "train_symbols": len(TRAIN), "validation_symbols": len(VALIDATION), "validation_residual_power": float(np.mean(abs(residual) ** 2)), "validation_model_power": float(np.mean(abs(predicted) ** 2)), "validation_actual_power": float(np.mean(abs(symbols[VALIDATION]) ** 2)), "decoded_header_hex": np.packbits(voted).tobytes().hex(), "header_raw_errors": None, "header_voted_errors": None, "magic_errors": None} if expected_header is not None: result.update(header_raw_errors=int(np.count_nonzero(decisions[1280:1424] != expected_header)), header_voted_errors=int(np.count_nonzero(voted != expected_header[::3])), magic_errors=int(np.count_nonzero(voted[:16] != expected_header[::3][:16]))) return result def analyze_frame(reader, start: int, symbol_rate: int, group: str, frame_number: int, expected_header: np.ndarray | None, stock_probe: dict | None = None, *, write_packets: bool = True) -> tuple[list, dict, dict]: """Диагностика при известном начале; длина тела берётся только из header.""" frontend = Frontend(symbol_rate) samples_per_symbol = frontend.samples_per_symbol values = reader.read(start, (PROBE_SYMBOLS + 42) * samples_per_symbol) window = frontend.extract(values, PROBE_SYMBOLS) matched = frontend.filter(values) estimation_matched = frontend.estimation_filter(values) nominal = frontend.nominal if stock_probe is not None: if not np.array_equal(window.normalized_real, stock_probe["soft"]): raise AssertionError("Диагностический probe не совпал со штатным baseline") stock_symbols = matched[nominal + window.timing_offset_samples: nominal + window.timing_offset_samples + PROBE_SYMBOLS * samples_per_symbol: samples_per_symbol].astype(np.complex128) statistics = squared_cfo_statistics(stock_symbols[:1280], symbol_rate) if not np.isclose(statistics["cfo_hz"], window.carrier["cfo_hz"], atol=1e-8): raise AssertionError("Разложение CFO не воспроизвело штатную ветвь") results, observations = [], {} for variant in VARIANTS: if not window.passed and variant != "D3": results.append({"group": group, "frame": frame_number, "variant": variant, "start_sample": start, "known_start_diagnostic": True, "symbol_rate": symbol_rate, "timing_samples": window.timing_offset_samples, "timing_symbols": window.timing_offset_samples / samples_per_symbol, "cfo_hz": window.carrier.get("cfo_hz"), "phase_rad": window.carrier.get("phase_rad"), "phase_epoch": "t=0, symbol k at (k+0.5)/Rs", "gain_abs": window.carrier.get("abs_h"), "train_coherence": None, "frequency_boundary": False, "timing_boundary": False, "header_reason": window.reason, "accepted_packet_bytes": None, "preamble_errors": None, "train_errors": None, "validation_errors": None, "train_symbols": len(TRAIN), "validation_symbols": len(VALIDATION), "header_raw_errors": None, "header_voted_errors": None, "magic_errors": None, "HEADER_ACCEPTED": False, "FEC_STARTED": False, "FEC_RESULT": "NOT_REACHED", "CRC_STARTED": False, "CRC_RESULT": "NOT_REACHED", "PACKET_OUTPUT": False, "diagnostic_available": False}) continue if variant == "S0": gain = window.carrier["abs_h"] * np.exp(1j * window.carrier["phase_rad"]) estimate = Estimate(window.timing_offset_samples, window.carrier["cfo_hz"], gain, coherence(stock_symbols[TRAIN], gain)) symbols = stock_symbols else: estimate = estimate_variant(estimation_matched, nominal, symbol_rate, variant, window.timing_offset_samples, window.carrier["cfo_hz"]) symbols = interpolate(matched, nominal + estimate.timing_samples + np.arange(PROBE_SYMBOLS) * samples_per_symbol) normalized = compensate(symbols, symbol_rate, estimate) if variant == "S0" and not np.allclose(normalized.real, window.normalized_real, atol=1e-12, rtol=1e-12): raise AssertionError("S0 отличается от штатной нормировки") header = physical.decode_phy_header_soft(normalized.real[1280:]) result = {"group": group, "frame": frame_number, "variant": variant, "start_sample": start, "diagnostic_available": True, "known_start_diagnostic": True, "symbol_rate": symbol_rate, "timing_samples": estimate.timing_samples, "timing_symbols": estimate.timing_samples / samples_per_symbol, "cfo_hz": estimate.cfo_hz, "phase_rad": float(np.angle(estimate.gain)), "phase_epoch": "t=0, symbol k at (k+0.5)/Rs", "estimator_preprocessing": "STOCK" if variant == "S0" else "TRAIN_SUPPORT_DC", "decision_preprocessing": "STOCK_GLOBAL_DC", "gain_abs": abs(estimate.gain), "train_coherence": estimate.train_coherence, "frequency_boundary": estimate.frequency_boundary, "timing_boundary": estimate.timing_boundary, "header_reason": header["reason"], "accepted_packet_bytes": header["packet_bytes"], **error_metrics(normalized, symbols, estimate, symbol_rate, expected_header)} body_soft = normalized.real[1280:] if header["passed"]: packet_bytes = int(header["packet_bytes"]) count = 1280 + 144 + physical.conv.encoded_bit_count(packet_bytes * 8) length = (count + 42) * samples_per_symbol if start + length > reader.size: result.update(HEADER_ACCEPTED=True, FEC_STARTED=False, FEC_RESULT="NOT_REACHED", CRC_STARTED=False, CRC_RESULT="NOT_REACHED", PACKET_OUTPUT=False, body_reason="TRUNCATED_FULL_IQ_WINDOW") results.append(result) continue full_values = reader.read(start, length) if variant == "S0": full_window = frontend.extract(full_values, count) body_soft = full_window.normalized_real[1280:] else: full_matched = frontend.filter(full_values) full_symbols = interpolate(full_matched, nominal + estimate.timing_samples + np.arange(count) * samples_per_symbol) body_soft = compensate(full_symbols, symbol_rate, estimate).real[1280:] decoded, packet_bytes = decode_body(body_soft) result.update(decoded) if result["PACKET_OUTPUT"] and write_packets: name = f"packet_{group}_{frame_number:02d}_{variant}.bin" (OUTPUT / name).write_bytes(packet_bytes) result["packet_file"] = name result["packet_sha256"] = hashlib.sha256(packet_bytes).hexdigest() observations[variant] = {"normalized": normalized, "symbols": symbols, "validation_residual": (symbols[VALIDATION] - estimate.gain * EXPECTED[VALIDATION] * np.exp(2j * np.pi * estimate.cfo_hz * (VALIDATION + .5) / symbol_rate))} results.append(result) return results, observations, statistics def candidate_mapping(baseline: dict, frames: list) -> list: """Связать независимые кандидаты с TX только после завершения baseline.""" mapping = [] chosen = {min(range(len(baseline["rows"])), key=lambda i: abs(baseline["rows"][i]["absolute_refined"] - frame["start"])): frame["frame"] for frame in frames} for index, row in enumerate(baseline["rows"]): nearest = min(frames, key=lambda frame: abs(frame["start"] - row["absolute_refined"])) delta = row["absolute_refined"] - nearest["start"] mapping.append({"candidate_id": row["candidate_id"], "coarse_start": row["base_sample"] + row["coarse"], "refined_start": row["absolute_refined"], "nearest_frame": nearest["frame"], "difference_from_diagnostic_start": delta, "true_start_frame": chosen.get(index), "inside_known_tx": abs(delta) < 92256, "reason": row["reason"]}) if len(chosen) != 16 or any(abs(row["difference_from_diagnostic_start"]) > 1 for row in mapping if row["true_start_frame"]): raise AssertionError("Не воспроизведены 16 настоящих начал") return mapping def summarize(rows: list) -> dict: """Все варианты раздельно; лучшего по CRC не выбираем.""" output = {} for group in sorted({row["group"] for row in rows}): output[group] = {} for variant in VARIANTS: selected = [row for row in rows if row["group"] == group and row["variant"] == variant] output[group][variant] = {"frames": len(selected), "header_accepted": sum(row["HEADER_ACCEPTED"] for row in selected), "fec_started": sum(row["FEC_STARTED"] for row in selected), "crc_pass": sum(row["CRC_RESULT"] == physical.CRC_PASS for row in selected), "diagnostic_available": sum(row.get("diagnostic_available", True) for row in selected), "train_errors": sum(row["train_errors"] or 0 for row in selected), "validation_errors": sum(row["validation_errors"] or 0 for row in selected), "header_voted_errors": (sum(row["header_voted_errors"] or 0 for row in selected) if any(row["header_voted_errors"] is not None for row in selected) else None), "frequency_boundaries": sum(row["frequency_boundary"] for row in selected), "timing_boundaries": sum(row["timing_boundary"] for row in selected)} return output def write_tables(rows: list) -> None: """Машинная полная таблица и плоский CSV с состояниями строгого декодера.""" save("frame_results.json", rows) fields = [key for key in rows[0] if key != "decoder_analysis"] fields += ["packet_file", "packet_sha256", "candidate_id"] fields = list(dict.fromkeys(fields)) with (OUTPUT / "frame_results.csv").open("w", newline="", encoding="utf-8-sig") as stream: writer = csv.DictWriter(stream, fields, extrasaction="ignore") writer.writeheader() writer.writerows(rows) def run_diagnosis(baseline: dict) -> dict: """Фиксированный набор сохранённых кадров и контролей; без аппаратуры.""" audit = energy.read_json(ROOT / "data/processed/lab055/energy_audit_v2.json") mapping = candidate_mapping(baseline, audit["experiments"]["Lab054"]["rx"]["frames"]) save("candidate_mapping.json", mapping) save("other_candidates.json", [row for row, mapped in zip(baseline["rows"], mapping) if not mapped["true_start_frame"]]) rx = energy.Cs8File(ROOT / "data/raw/lab054/rx_stream.cs8") plans = historical.plans() rows, diagnostics, observations = [], [], {} for mapped in mapping: if not mapped["true_start_frame"]: continue number = mapped["true_start_frame"] selected, observed, statistics = analyze_frame(rx, mapped["refined_start"], 500_000, "Lab054", number, plans[number - 1].plan.phy_header_bits, baseline["rows"][mapped["candidate_id"] - 1]["probe"]) for row in selected: row["candidate_id"] = mapped["candidate_id"] rows.extend(selected) diagnostics.append({"group": "Lab054", "frame": number, **statistics}) for variant, values in observed.items(): for kind, array in values.items(): observations[f"frame{number:02d}_{variant}_{kind}"] = array print(f"Lab054 frame {number}/16", flush=True) tx = energy.Cs8File(ROOT / "data/raw/lab054/tx_stream.cs8") for number, plan in enumerate(plans, 1): selected, _, _ = analyze_frame(tx, (number - 1) * 92256, 500_000, "TX054", number, plan.plan.phy_header_bits) rows.extend(selected) old_directory = Path(audit["experiments"]["Lab047"]["directory"]) old = energy.read_json(old_directory / "independent_reprocess_result_corrected.json") old_rx = energy.Cs8File(old_directory / "rx_stream.cs8") accepted = [outcome for outcome in old["strict"]["outcomes"] if outcome["accepted"]] for number, outcome in enumerate(accepted, 1): start = outcome["refined_start_sample"] # Все исторические кадры имеют 268 байт; эталон используется только в error_metrics. expected_header = physical.encode_phy_header(268) selected, _, statistics = analyze_frame(old_rx, start, 20_000, "RX047", number, expected_header) rows.extend(selected) diagnostics.append({"group": "RX047", "frame": number, **statistics}) print(f"Lab047 frame {number}/36", flush=True) for number in range(16): selected, _, _ = analyze_frame(rx, 8_000_000 + number * 100_000, 500_000, "BACKGROUND054", number + 1, None) rows.extend(selected) write_tables(rows) save("squared_cfo_statistics.json", diagnostics) np.savez_compressed(OUTPUT / "symbol_observations.npz", **observations) summary = {"rf_tx": 0, "historical_lab054": "NOT_A, 0/16", "baseline_reproduced": baseline["passed"], "train": [32, 608], "validation": [672, 1248], "known_start_diagnostics": True, "groups": summarize(rows)} save("summary.json", summary) return summary class ArrayFile: """Синтетический контроль с тем же интерфейсом, без файла и оборудования.""" def __init__(self, values: np.ndarray): self.values = values self.size = len(values) def read(self, start: int, count: int) -> np.ndarray: if start < 0 or start + count > self.size: raise ValueError("Синтетическое окно вне массива") return self.values[start:start + count] def synthetic_waveform(symbol_rate: int, *, delay_symbols: float = .171875, cfo_hz: float = 0, phase_rad: float = .7, es_n0_db: float | None = None, seed: int = 5701) -> tuple[np.ndarray, dict]: """Физические параметры задаёт генератор; оцениватель их не получает. При сравнении скоростей равен Es/N0 (по средней цифровой энергии), дисперсия комплексного белого шума масштабируется с SPS. """ mode = historical.phy.PhyMode(symbol_rate) waveform = historical.phy.build_parameterized_tx_complex(historical.plans()[0].plan, mode) indexes = np.arange(len(waveform)) baseband = waveform * np.exp(-2j * np.pi * 50_000 * indexes / FS) delay_samples = delay_symbols * mode.samples_per_symbol padding = int(np.ceil(abs(delay_samples))) + 20 delayed = interpolate(np.pad(baseband, padding), padding + indexes - delay_samples) values = delayed * np.exp(1j * (phase_rad + 2 * np.pi * (50_000 + cfo_hz) * indexes / FS)) signal_power = float(np.mean(abs(values) ** 2)) variance = 0.0 if es_n0_db is not None: variance = signal_power * mode.samples_per_symbol / 10 ** (es_n0_db / 10) random_generator = np.random.default_rng(seed) noise = (random_generator.normal(size=len(values)) + 1j * random_generator.normal(size=len(values))) values = values + np.sqrt(variance / 2) * noise return values.astype(np.complex64), {"symbol_rate": symbol_rate, "delay_samples": delay_samples, "delay_symbols": delay_symbols, "cfo_hz": cfo_hz, "phase_rad": phase_rad, "phase_epoch": "raw n=0, before RX filtering", "es_n0_db": es_n0_db, "complex_noise_variance": variance, "signal_power": signal_power, "seed": seed, "comparison_condition": "equal digital Es/N0; noise variance scales with SPS"} def synthetic_controls() -> dict: """Небольшие фиксированные контроли фазы, CFO, дробной задержки и шума.""" symbol_checks, waveform_checks = [], [] for symbol_rate in (20_000, 500_000): times = (np.arange(1280) + .5) / symbol_rate true_cfo = 37.5 * symbol_rate / 500_000 true_phase = .7 symbols = EXPECTED * np.exp(1j * (true_phase + 2 * np.pi * true_cfo * times)) frequency, gain, score, boundary = frequency_search(symbols[TRAIN], symbol_rate) stock = squared_cfo_statistics(symbols, symbol_rate) symbol_checks.append({"symbol_rate": symbol_rate, "true_cfo": true_cfo, "estimated_cfo": frequency, "true_phase": true_phase, "estimated_phase": float(np.angle(gain)), "stock_cfo": stock["cfo_hz"], "unambiguous_abs_hz": stock["unambiguous_abs_hz"], "passed": abs(frequency - true_cfo) < .01 and abs(np.angle(gain) - true_phase) < .001}) outside = symbol_rate / 2048 * 1.2 aliased = squared_cfo_statistics(EXPECTED * np.exp(2j * np.pi * outside * times), symbol_rate) symbol_checks.append({"symbol_rate": symbol_rate, "true_cfo": outside, "estimated_cfo": aliased["cfo_hz"], "outside_stock_unambiguous_range": True, "expected_alias_hz": outside - symbol_rate / 1024, "passed": np.isclose(aliased["cfo_hz"], outside - symbol_rate / 1024)}) for case, noise_db, residual_cfo in (("fractional_clean", None, 0.0), ("fractional_noise", 12.0, true_cfo)): values, truth = synthetic_waveform(symbol_rate, es_n0_db=noise_db, cfo_hz=residual_cfo, seed=5701) frontend = Frontend(symbol_rate) probe = values[:(PROBE_SYMBOLS + 42) * frontend.samples_per_symbol] stock_window = frontend.extract(probe, PROBE_SYMBOLS) estimation_matched = frontend.estimation_filter(probe) output_matched = frontend.filter(probe) for variant in VARIANTS[1:]: estimate = estimate_variant(estimation_matched, frontend.nominal, symbol_rate, variant, stock_window.timing_offset_samples, stock_window.carrier["cfo_hz"]) symbols = interpolate(output_matched, frontend.nominal + estimate.timing_samples + np.arange(PROBE_SYMBOLS) * frontend.samples_per_symbol) normalized = compensate(symbols, symbol_rate, estimate) waveform_checks.append({"case": case, "variant": variant, "truth": truth, "estimate": asdict(estimate), "timing_error_symbols": estimate.timing_samples / frontend.samples_per_symbol - truth["delay_symbols"], "cfo_error_hz": estimate.cfo_hz - residual_cfo, "train_errors": int(np.count_nonzero((normalized.real[TRAIN] >= 0) != PREAMBLE[TRAIN])), "validation_errors": int(np.count_nonzero((normalized.real[VALIDATION] >= 0) != PREAMBLE[VALIDATION]))}) # Полный контроль утечки файла-эталона, без подмены исходных файлов проекта. values, _ = synthetic_waveform(500_000, delay_symbols=0, cfo_hz=0, es_n0_db=None) reader = ArrayFile(values) expected = historical.plans()[0].plan.phy_header_bits first, _, _ = analyze_frame(reader, 0, 500_000, "LEAKAGE_CONTROL", 1, expected, write_packets=False) changed, _, _ = analyze_frame(reader, 0, 500_000, "LEAKAGE_CONTROL", 1, 1 - expected, write_packets=False) parameter_keys = ("timing_samples", "cfo_hz", "phase_rad", "gain_abs", "HEADER_ACCEPTED", "CRC_RESULT") reference_invariance = all(all(a[key] == b[key] for key in parameter_keys) for a, b in zip(first, changed)) raw = energy.Cs8File(ROOT / "data/raw/lab054/rx_stream.cs8") values = raw.read(4161352, (PROBE_SYMBOLS + 42) * 16) altered = values.copy() frontend = Frontend(500_000) low, high = frontend.train_support() altered[:low] += 10 + 7j altered[high:] += 10 + 7j original = estimate_variant(frontend.estimation_filter(values), frontend.nominal, 500_000, "D3", 0, 0) perturbation = estimate_variant(frontend.estimation_filter(altered), frontend.nominal, 500_000, "D3", 99, 99) clean_delay_passed = all(abs(row["timing_error_symbols"]) <= 1/32 and row["validation_errors"] == 0 for row in waveform_checks if row["variant"] == "D3" and row["case"] == "fractional_clean") result = {"symbol_checks": symbol_checks, "waveform_checks": waveform_checks, "clean_fractional_delay_passed": clean_delay_passed, "expected_bits_invariance": reference_invariance, "raw_outside_train_invariance": original == perturbation, "raw_train_support_500k": [low, high], "passed": all(row["passed"] for row in symbol_checks) and clean_delay_passed and reference_invariance and original == perturbation} save("synthetic_controls.json", result) if not result["passed"]: raise AssertionError("Контроли синхронизации/утечки не прошли") return result def make_plots() -> None: """Созвездия, фазы, расположение ошибок и остаток всех 16 кадров.""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt rows = energy.read_json(OUTPUT / "frame_results.json") rows = [row for row in rows if row["group"] == "Lab054"] with np.load(OUTPUT / "symbol_observations.npz") as arrays: colors = dict(zip(VARIANTS, ("#666666", "#a76b25", "#14835b", "#2465bc", "#9135a3"))) for variant in VARIANTS: figure, axes = plt.subplots(4, 4, figsize=(14, 12), constrained_layout=True) for number, axis in enumerate(axes.flat, 1): symbols = arrays[f"frame{number:02d}_{variant}_normalized"][VALIDATION] axis.scatter(symbols.real, symbols.imag, s=3, alpha=.4, color=colors[variant]) axis.scatter([-1, 1], [0, 0], marker="x", color="black") axis.set(title=f"Кадр {number}", xlabel="I / |h|", ylabel="Q / |h|") axis.grid(alpha=.2) figure.suptitle(f"Lab054, {variant}: созвездие VALIDATION, решения после штатного RRC") figure.savefig(OUTPUT / f"constellation_{variant}.png", dpi=130) plt.close(figure) figure, axes = plt.subplots(4, 4, figsize=(15, 12), constrained_layout=True) for number, axis in enumerate(axes.flat, 1): for variant in VARIANTS: corrected = arrays[f"frame{number:02d}_{variant}_normalized"][:1280] * EXPECTED # Круговое среднее фазы, без переоценки компенсации. unit = corrected / np.maximum(abs(corrected), 1e-30) phase = np.angle(unit.reshape(40, 32).mean(axis=1)) axis.plot((np.arange(40) * 32 + 16) / 500, phase, color=colors[variant], label=variant, linewidth=.8) axis.axvspan(32/500, 608/500, color="green", alpha=.06) axis.axvspan(672/500, 1248/500, color="blue", alpha=.06) axis.set(title=f"Кадр {number}", xlabel="Время преамбулы, мс", ylabel="Ошибка фазы, рад", ylim=(-np.pi, np.pi)) axes.flat[0].legend(fontsize=8, ncol=3) figure.suptitle("Фаза относительно PRBS7; круговое среднее каждых 32 символов") figure.savefig(OUTPUT / "phase_errors.png", dpi=130) plt.close(figure) errors, residuals, labels = [], [], [] for number in range(1, 17): for variant in VARIANTS: symbols = arrays[f"frame{number:02d}_{variant}_normalized"][:1280] errors.append((symbols.real >= 0) != PREAMBLE) residuals.append(abs(arrays[f"frame{number:02d}_{variant}_validation_residual"]) ** 2) labels.append(f"{number:02d}/{variant}") figure, axes = plt.subplots(1, 2, figsize=(16, 15), constrained_layout=True) axes[0].imshow(errors, aspect="auto", interpolation="nearest", cmap="Greys", vmin=0, vmax=1) plot = axes[1].imshow(10*np.log10(np.maximum(residuals, 1e-12)), aspect="auto", interpolation="nearest", cmap="viridis") for axis in axes: axis.set_yticks(np.arange(80), labels, fontsize=6) axis.set_ylabel("Кадр / вариант") axes[0].set(title="Ошибочные решения преамбулы", xlabel="Номер символа преамбулы") axes[1].set(title="Мощность остатка VALIDATION, дБ относительно 1 код²", xlabel="Индекс внутри VALIDATION") figure.colorbar(plot, ax=axes[1], shrink=.5) figure.savefig(OUTPUT / "errors_and_residual.png", dpi=130) plt.close(figure) figure, axes = plt.subplots(2, 2, figsize=(13, 8), constrained_layout=True) for variant in VARIANTS: selected = sorted((row for row in rows if row["variant"] == variant), key=lambda row: row["frame"]) for axis, key in zip(axes.flat, ("validation_errors", "header_voted_errors", "cfo_hz", "timing_symbols")): axis.plot(range(1, 17), [row[key] for row in selected], marker=".", label=variant, color=colors[variant]) axis.set_xlabel("Номер кадра") axis.grid(alpha=.2) for axis, title in zip(axes.flat, ("Ошибки VALIDATION из 576", "Ошибки заголовка из 48", "Оценка CFO, Гц", "Timing, доля символа")): axis.set_title(title) axes.flat[0].legend(ncol=5) figure.savefig(OUTPUT / "comparison.png", dpi=140) plt.close(figure) def verify_integrity() -> dict: """Проверить исходники и старые артефакты; журнал разрешает только дописывание.""" initial = energy.read_json(OUTPUT / "initial_state.json") changed_sources, changed_data = [], [] for name, expected in initial["source_files"].items(): if energy.sha256(ROOT / name) != expected["sha256"]: changed_sources.append(name) for name, expected in initial["historical_files"].items(): path = Path(name) if path.name in ("PROJECT_LOG.md", "LABS_INDEX.md"): actual = hashlib.sha256(path.read_bytes()[:expected["bytes"]]).hexdigest() else: actual = energy.sha256(path) if actual != expected["sha256"]: changed_data.append(name) loaded = {} for module in list(sys.modules.values()): filename = getattr(module, "__file__", None) if filename: path = Path(filename).resolve() if path.is_relative_to(ROOT) and path.suffix == ".py": loaded[path.relative_to(ROOT).as_posix()] = energy.sha256(path) result = {"source_changes": changed_sources, "historical_changes": changed_data, "loaded_processing_sources_sha256": loaded, "loaded_versions": {name: {"version": getattr(sys.modules.get(name), "__version__", None), "file": getattr(sys.modules.get(name), "__file__", None)} for name in ("numpy", "scipy", "matplotlib", "numba")}, "passed": not changed_sources and not changed_data} save("integrity.json", result) if not result["passed"]: raise AssertionError("Изменились исходные файлы вне Lab057") return result def run_functional_tests() -> None: """Быстрые свойства без IQ-файлов и оборудования.""" assert len(TRAIN) == len(VALIDATION) == 576 assert VALIDATION[0] - TRAIN[-1] > 20 assert set(TRAIN).isdisjoint(VALIDATION) times = (np.arange(1280) + .5) / 500_000 symbols = EXPECTED * np.exp(1j * (.7 + 2 * np.pi * 31.25 * times)) frequency, gain, score, boundary = frequency_search(symbols[TRAIN], 500_000) assert abs(frequency - 31.25) < .01 and score > .99999 and not boundary assert abs(np.angle(gain) - .7) < .001 def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--baseline-only", action="store_true") arguments = parser.parse_args() run_functional_tests() result = reproduce_baseline() print("BASELINE", result["passed"], result["reasons"], "RF TX=0", flush=True) if not arguments.baseline_only: summary = run_diagnosis(result) summary["controls_passed"] = synthetic_controls()["passed"] make_plots() summary["integrity_passed"] = verify_integrity()["passed"] save("summary.json", summary) print(json.dumps(plain(summary), ensure_ascii=False, indent=2)) if __name__ == "__main__": main()