"""
Lab023. Защищённая компенсация частотной ошибки.

Сравниваются три стратегии:

1. PHASE_ONLY
   Компенсируется только постоянный фазовый поворот.

2. ALWAYS_CFO
   Оценённое частотное рассогласование применяется всегда.

3. GUARDED_CFO
   Частотная коррекция применяется только тогда, когда:

   - модуль оценённого CFO превышает мёртвую зону;
   - межсимвольная фазовая оценка достаточно достоверна.

Эксперимент выполняется на полном 320-битном радиокадре:

    PREAMBLE
    + RADIO SYNC
    + LENGTH
    + SDR Rover Link packet

В каждой попытке добавляются:

- случайная начальная фаза;
- случайный CFO;
- AWGN-шум.

Символьная синхронизация в этой лабораторной считается
уже выполненной. Это позволяет исследовать именно логику
управления CFO-компенсацией.
"""

from csv import DictWriter
from pathlib import Path
import struct

import matplotlib.pyplot as plt
import numpy as np

from protocol.packet import (
    CRCError,
    MESSAGE_TYPE_TEXT,
    PacketError,
    build_packet,
    parse_packet,
)


# ============================================================
# Параметры радиокадра
# ============================================================

RADIO_SYNC_WORD = 0xD391

PREAMBLE_BIT_COUNT = 64

SYMBOL_RATE = 20_000

EXPECTED_MESSAGE = "ПРИВЕТ SDR"
EXPECTED_SEQUENCE_NUMBER = 18


# ============================================================
# Параметры защищённой CFO-коррекции
# ============================================================

# Если модуль оценки меньше этого значения,
# приёмник считает CFO практически нулевым.
CFO_DEAD_ZONE_HZ = 15.0

# Достоверность фазового приращения:
#
# 0.0 — полностью случайная фаза;
# 1.0 — идеально постоянное межсимвольное вращение.
MINIMUM_PHASE_CONSISTENCY = 0.55
# Уточняющий поиск около грубой оценки CFO.
#
# Грубая оценка даёт приблизительный центр,
# после чего перебираются частоты вокруг него.
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0

CFO_REFINEMENT_STEP_HZ = 1.0

# ============================================================
# Параметры эксперимента
# ============================================================

EB_N0_VALUES_DB = [
    6.0,
    7.0,
    8.0,
    10.0,
]

# В каждой попытке CFO выбирается случайно:
#
#     -MAX_CFO ... +MAX_CFO
MAX_ABS_CFO_VALUES_HZ = [
    0.0,
    250.0,
    1000.0,
]

TRIALS_PER_POINT = 200

RANDOM_SEED = 2026


# ============================================================
# Выходные файлы
# ============================================================

OUTPUT_DIRECTORY = Path(
    "data/processed/lab023"
)

OUTPUT_DIRECTORY.mkdir(
    parents=True,
    exist_ok=True,
)

CSV_PATH = (
    OUTPUT_DIRECTORY
    / "lab023_guarded_cfo_results.csv"
)

GRAPH_PATH = (
    OUTPUT_DIRECTORY
    / "lab023_guarded_cfo.png"
)

REPORT_PATH = (
    OUTPUT_DIRECTORY
    / "lab023_guarded_cfo_report.txt"
)


# ============================================================
# Статусы декодирования
# ============================================================

STATUS_SUCCESS = "SUCCESS"
STATUS_HEADER_ERROR = "HEADER ERROR"
STATUS_CRC_ERROR = "CRC ERROR"
STATUS_PACKET_ERROR = "PACKET ERROR"


# ============================================================
# Преобразование bytes и bits
# ============================================================

def bytes_to_bits(
    data: bytes,
) -> np.ndarray:
    """
    Преобразовать bytes в одномерный массив битов.
    """

    if not isinstance(data, bytes):
        raise TypeError(
            "data должен иметь тип bytes"
        )

    return np.unpackbits(
        np.frombuffer(
            data,
            dtype=np.uint8,
        )
    )


def bits_to_bytes(
    bits: np.ndarray,
) -> bytes:
    """
    Упаковать массив битов обратно в bytes.
    """

    bits = np.asarray(
        bits,
        dtype=np.uint8,
    )

    if bits.ndim != 1:
        raise ValueError(
            "bits должен быть одномерным массивом"
        )

    if len(bits) % 8 != 0:
        raise ValueError(
            "Количество битов должно быть кратно восьми"
        )

    if not np.all(
        (bits == 0) | (bits == 1)
    ):
        raise ValueError(
            "bits должен содержать только 0 и 1"
        )

    return np.packbits(
        bits
    ).tobytes()


# ============================================================
# BPSK
# ============================================================

def bpsk_modulate(
    bits: np.ndarray,
) -> np.ndarray:
    """
    Преобразовать биты в BPSK-символы.

        0 → -1
        1 → +1
    """

    bits = np.asarray(
        bits,
        dtype=np.uint8,
    )

    symbols = (
        2.0
        * bits.astype(np.float64)
        - 1.0
    )

    return symbols.astype(
        np.complex128
    )


def bpsk_demodulate(
    symbols: np.ndarray,
) -> np.ndarray:
    """
    Демодулировать BPSK по знаку компоненты I.
    """

    return (
        symbols.real >= 0.0
    ).astype(np.uint8)


# ============================================================
# Формирование полного радиокадра
# ============================================================

def build_radio_frame(
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Сформировать:

    - биты полного радиокадра;
    - BPSK-символы полного радиокадра;
    - известные символы PREAMBLE + RADIO SYNC.
    """

    payload = EXPECTED_MESSAGE.encode(
        "utf-8"
    )

    protocol_packet = build_packet(
        payload=payload,
        message_type=MESSAGE_TYPE_TEXT,
        sequence_number=EXPECTED_SEQUENCE_NUMBER,
    )

    if len(protocol_packet) > 65535:
        raise ValueError(
            "Внутренний пакет слишком велик"
        )

    radio_header = struct.pack(
        ">HH",
        RADIO_SYNC_WORD,
        len(protocol_packet),
    )

    preamble_bits = np.tile(
        np.array(
            [1, 0],
            dtype=np.uint8,
        ),
        PREAMBLE_BIT_COUNT // 2,
    )

    radio_header_bits = bytes_to_bits(
        radio_header
    )

    protocol_packet_bits = bytes_to_bits(
        protocol_packet
    )

    frame_bits = np.concatenate(
        [
            preamble_bits,
            radio_header_bits,
            protocol_packet_bits,
        ]
    )

    frame_symbols = bpsk_modulate(
        frame_bits
    )

    marker_bit_count = (
        PREAMBLE_BIT_COUNT
        + 16
    )

    marker_symbols = frame_symbols[
        :marker_bit_count
    ]

    return (
        frame_bits,
        frame_symbols,
        marker_symbols,
    )


# ============================================================
# Искажения канала
# ============================================================

def apply_carrier_impairments(
    symbols: np.ndarray,
    frequency_offset_hz: float,
    initial_phase_radians: float,
) -> np.ndarray:
    """
    Добавить начальный фазовый поворот и CFO.

    Между соседними символами фаза изменяется на:

        2π · CFO / SYMBOL_RATE
    """

    symbol_indexes = np.arange(
        len(symbols),
        dtype=np.float64,
    )

    phase_increment = (
        2.0
        * np.pi
        * frequency_offset_hz
        / SYMBOL_RATE
    )

    phase_values = (
        initial_phase_radians
        + phase_increment
        * symbol_indexes
    )

    return (
        symbols
        * np.exp(
            1j * phase_values
        )
    )


def add_awgn(
    symbols: np.ndarray,
    eb_n0_db: float,
    random_generator: np.random.Generator,
) -> np.ndarray:
    """
    Добавить комплексный AWGN.

    Энергия одного BPSK-символа равна единице.
    Один символ переносит один бит.
    """

    eb_n0_linear = 10.0 ** (
        eb_n0_db / 10.0
    )

    component_sigma = np.sqrt(
        1.0
        / (
            2.0
            * eb_n0_linear
        )
    )

    noise = component_sigma * (
        random_generator.standard_normal(
            len(symbols)
        )
        + 1j
        * random_generator.standard_normal(
            len(symbols)
        )
    )

    return symbols + noise


# ============================================================
# Оценка фазы и CFO по известному маркеру
# ============================================================

def estimate_carrier_parameters(
    received_symbols: np.ndarray,
    marker_symbols: np.ndarray,
) -> dict:
    """
    Оценить фазу и частотное рассогласование по маркеру.

    Алгоритм:

    1. Удалить известные BPSK-знаки маркера.
    2. Получить грубую оценку CFO по соседним символам.
    3. Выполнить уточняющий частотный поиск.
    4. Оценить начальную фазу после компенсации CFO.
    5. Рассчитать достоверность оценки.
    """

    marker_length = len(
        marker_symbols
    )

    received_marker = received_symbols[
        :marker_length
    ]

    if len(received_marker) != marker_length:
        raise ValueError(
            "Недостаточно символов маркера"
        )

    # Известные BPSK-знаки равны -1 или +1.
    # Умножение удаляет переданную манипуляцию,
    # оставляя фазу канала и шум.
    despread_marker = (
        received_marker
        * marker_symbols
    )

    marker_indexes = np.arange(
        marker_length,
        dtype=np.float64,
    )

    marker_magnitude_sum = float(
        np.sum(
            np.abs(
                despread_marker
            )
        )
    )

    # ========================================================
    # Постоянная фаза без CFO-компенсации
    # ========================================================

    constant_coherent_sum = np.sum(
        despread_marker
    )

    constant_phase = float(
        np.angle(
            constant_coherent_sum
        )
    )

    constant_coherence = float(
        np.abs(
            constant_coherent_sum
        )
        / (
            marker_magnitude_sum
            + 1e-12
        )
    )

    # ========================================================
    # Грубая оценка CFO
    # ========================================================

    adjacent_products = (
        despread_marker[1:]
        * np.conj(
            despread_marker[:-1]
        )
    )

    adjacent_sum = np.sum(
        adjacent_products
    )

    coarse_phase_increment = float(
        np.angle(
            adjacent_sum
        )
    )

    coarse_frequency_hz = (
        coarse_phase_increment
        * SYMBOL_RATE
        / (2.0 * np.pi)
    )

    phase_consistency = float(
        np.abs(
            adjacent_sum
        )
        / (
            np.sum(
                np.abs(
                    adjacent_products
                )
            )
            + 1e-12
        )
    )

    # ========================================================
    # Уточняющий поиск CFO
    # ========================================================

    frequency_candidates_hz = np.arange(
        (
            coarse_frequency_hz
            - CFO_REFINEMENT_HALF_WIDTH_HZ
        ),
        (
            coarse_frequency_hz
            + CFO_REFINEMENT_HALF_WIDTH_HZ
            + CFO_REFINEMENT_STEP_HZ / 2.0
        ),
        CFO_REFINEMENT_STEP_HZ,
        dtype=np.float64,
    )

    phase_increment_candidates = (
        2.0
        * np.pi
        * frequency_candidates_hz
        / SYMBOL_RATE
    )

    candidate_compensation = np.exp(
        -1j
        * phase_increment_candidates[
            :, np.newaxis
        ]
        * marker_indexes[
            np.newaxis, :
        ]
    )

    coherent_sums = np.sum(
        despread_marker[
            np.newaxis, :
        ]
        * candidate_compensation,
        axis=1,
    )

    best_candidate_index = int(
        np.argmax(
            np.abs(
                coherent_sums
            )
        )
    )

    estimated_frequency_hz = float(
        frequency_candidates_hz[
            best_candidate_index
        ]
    )

    phase_increment = float(
        phase_increment_candidates[
            best_candidate_index
        ]
    )

    best_coherent_sum = (
        coherent_sums[
            best_candidate_index
        ]
    )

    initial_phase_after_cfo = float(
        np.angle(
            best_coherent_sum
        )
    )

    cfo_coherence = float(
        np.abs(
            best_coherent_sum
        )
        / (
            marker_magnitude_sum
            + 1e-12
        )
    )

    coherence_gain = (
        cfo_coherence
        - constant_coherence
    )

    return {
        "constant_phase": constant_phase,
        "constant_coherence": constant_coherence,
        "phase_increment": phase_increment,
        "estimated_frequency_hz": (
            estimated_frequency_hz
        ),
        "initial_phase_after_cfo": (
            initial_phase_after_cfo
        ),
        "phase_consistency": phase_consistency,
        "cfo_coherence": cfo_coherence,
        "coherence_gain": coherence_gain,
    }

# ============================================================
# Коррекция несущей
# ============================================================

def correct_constant_phase(
    received_symbols: np.ndarray,
    phase_radians: float,
) -> np.ndarray:
    """
    Компенсировать только постоянную фазу.
    """

    return (
        received_symbols
        * np.exp(
            -1j * phase_radians
        )
    )


def correct_phase_and_frequency(
    received_symbols: np.ndarray,
    initial_phase_radians: float,
    phase_increment: float,
) -> np.ndarray:
    """
    Компенсировать постоянную фазу и CFO.
    """

    symbol_indexes = np.arange(
        len(received_symbols),
        dtype=np.float64,
    )

    phase_model = (
        initial_phase_radians
        + phase_increment
        * symbol_indexes
    )

    return (
        received_symbols
        * np.exp(
            -1j * phase_model
        )
    )


def should_apply_cfo_correction(
    estimated_frequency_hz: float,
    phase_consistency: float,
    coherence_gain: float,
) -> bool:
    """
    Разрешить CFO-коррекцию только при наличии
    достаточных оснований.

    Требования:

    1. Оценка находится вне мёртвой зоны.
    2. Межсимвольное вращение достаточно согласованно.
    3. Компенсация CFO действительно повышает
       когерентность известного маркера.
    """

    MINIMUM_COHERENCE_GAIN = 0.02

    return (
        abs(
            estimated_frequency_hz
        )
        >= CFO_DEAD_ZONE_HZ
        and phase_consistency
        >= MINIMUM_PHASE_CONSISTENCY
        and coherence_gain
        >= MINIMUM_COHERENCE_GAIN
    )


# ============================================================
# Разбор радиокадра
# ============================================================

def decode_radio_frame(
    corrected_symbols: np.ndarray,
) -> str:
    """
    Демодулировать полный радиокадр.

    Возвращает статус приёма.
    """

    received_bits = bpsk_demodulate(
        corrected_symbols
    )

    radio_header_start = (
        PREAMBLE_BIT_COUNT
    )

    radio_header_end = (
        radio_header_start
        + 32
    )

    if len(received_bits) < radio_header_end:
        return STATUS_HEADER_ERROR

    try:
        radio_header = bits_to_bytes(
            received_bits[
                radio_header_start:
                radio_header_end
            ]
        )

        (
            received_sync,
            protocol_packet_length,
        ) = struct.unpack(
            ">HH",
            radio_header,
        )

    except (ValueError, struct.error):
        return STATUS_HEADER_ERROR

    if received_sync != RADIO_SYNC_WORD:
        return STATUS_HEADER_ERROR

    if not 1 <= protocol_packet_length <= 4096:
        return STATUS_HEADER_ERROR

    protocol_packet_start = (
        radio_header_end
    )

    protocol_packet_end = (
        protocol_packet_start
        + protocol_packet_length * 8
    )

    if len(received_bits) < protocol_packet_end:
        return STATUS_HEADER_ERROR

    try:
        protocol_packet = bits_to_bytes(
            received_bits[
                protocol_packet_start:
                protocol_packet_end
            ]
        )

        parsed_packet = parse_packet(
            protocol_packet
        )

    except CRCError:
        return STATUS_CRC_ERROR

    except (PacketError, ValueError):
        return STATUS_PACKET_ERROR

    try:
        restored_message = (
            parsed_packet.payload.decode(
                "utf-8"
            )
        )

    except UnicodeDecodeError:
        return STATUS_PACKET_ERROR

    if (
        parsed_packet.message_type
        != MESSAGE_TYPE_TEXT
        or parsed_packet.sequence_number
        != EXPECTED_SEQUENCE_NUMBER
        or restored_message
        != EXPECTED_MESSAGE
    ):
        return STATUS_PACKET_ERROR

    return STATUS_SUCCESS


# ============================================================
# Подготовка исходного кадра
# ============================================================

(
    transmitted_frame_bits,
    transmitted_symbols,
    marker_symbols,
) = build_radio_frame()

print(
    "=== Lab023. Защищённая CFO-компенсация ==="
)

print("\nРазмер полного радиокадра:")

print(
    len(transmitted_frame_bits),
    "битов"
)

print("\nМёртвая зона CFO:")

print(
    f"±{CFO_DEAD_ZONE_HZ:.1f}",
    "Гц"
)

print("\nМинимальная достоверность фазовой оценки:")

print(
    f"{MINIMUM_PHASE_CONSISTENCY:.2f}"
)


# ============================================================
# Основной эксперимент
# ============================================================

results = []

for eb_n0_db in EB_N0_VALUES_DB:

    for max_abs_cfo_hz in (
        MAX_ABS_CFO_VALUES_HZ
    ):

        seed = (
            RANDOM_SEED
            + int(
                eb_n0_db * 1000
            )
            + int(
                max_abs_cfo_hz
            )
        )

        random_generator = np.random.default_rng(
            seed
        )

        success_phase_only = 0
        success_always_cfo = 0
        success_guarded_cfo = 0

        guarded_apply_count = 0

        frequency_errors = []
        phase_consistency_values = []

        for trial_index in range(
            TRIALS_PER_POINT
        ):

            if max_abs_cfo_hz == 0.0:
                true_frequency_hz = 0.0

            else:
                true_frequency_hz = float(
                    random_generator.uniform(
                        -max_abs_cfo_hz,
                        max_abs_cfo_hz,
                    )
                )

            initial_phase = float(
                random_generator.uniform(
                    -np.pi,
                    np.pi,
                )
            )

            impaired_symbols = (
                apply_carrier_impairments(
                    symbols=transmitted_symbols,
                    frequency_offset_hz=(
                        true_frequency_hz
                    ),
                    initial_phase_radians=(
                        initial_phase
                    ),
                )
            )

            received_symbols = add_awgn(
                symbols=impaired_symbols,
                eb_n0_db=eb_n0_db,
                random_generator=random_generator,
            )

            estimate = estimate_carrier_parameters(
                received_symbols=received_symbols,
                marker_symbols=marker_symbols,
            )

            estimated_frequency_hz = (
                estimate[
                    "estimated_frequency_hz"
                ]
            )

            phase_consistency = (
                estimate[
                    "phase_consistency"
                ]
            )

            frequency_errors.append(
                estimated_frequency_hz
                - true_frequency_hz
            )

            phase_consistency_values.append(
                phase_consistency
            )

            # =================================================
            # Стратегия 1. Только постоянная фаза
            # =================================================

            phase_only_symbols = (
                correct_constant_phase(
                    received_symbols=(
                        received_symbols
                    ),
                    phase_radians=(
                        estimate[
                            "constant_phase"
                        ]
                    ),
                )
            )

            status_phase_only = (
                decode_radio_frame(
                    phase_only_symbols
                )
            )

            if (
                status_phase_only
                == STATUS_SUCCESS
            ):
                success_phase_only += 1

            # =================================================
            # Стратегия 2. CFO применяется всегда
            # =================================================

            always_cfo_symbols = (
                correct_phase_and_frequency(
                    received_symbols=(
                        received_symbols
                    ),
                    initial_phase_radians=(
                        estimate[
                            "initial_phase_after_cfo"
                        ]
                    ),
                    phase_increment=(
                        estimate[
                            "phase_increment"
                        ]
                    ),
                )
            )

            status_always_cfo = (
                decode_radio_frame(
                    always_cfo_symbols
                )
            )

            if (
                status_always_cfo
                == STATUS_SUCCESS
            ):
                success_always_cfo += 1

            # =================================================
            # Стратегия 3. Защищённая CFO-коррекция
            # =================================================

            apply_cfo = (
                should_apply_cfo_correction(
                    estimated_frequency_hz=(
                        estimated_frequency_hz
                    ),
                    phase_consistency=(
                        phase_consistency
                    ),
                    coherence_gain=(
                        estimate[
                            "coherence_gain"
                        ]
                    ),
                )
            )

            if apply_cfo:
                guarded_apply_count += 1

                guarded_symbols = (
                    always_cfo_symbols
                )

            else:
                guarded_symbols = (
                    phase_only_symbols
                )

            status_guarded_cfo = (
                decode_radio_frame(
                    guarded_symbols
                )
            )

            if (
                status_guarded_cfo
                == STATUS_SUCCESS
            ):
                success_guarded_cfo += 1

        # =====================================================
        # Итог одной экспериментальной точки
        # =====================================================

        frequency_errors_array = np.asarray(
            frequency_errors,
            dtype=np.float64,
        )

        frequency_rmse_hz = float(
            np.sqrt(
                np.mean(
                    frequency_errors_array ** 2
                )
            )
        )

        mean_phase_consistency = float(
            np.mean(
                phase_consistency_values
            )
        )

        fer_phase_only = (
            1.0
            - success_phase_only
            / TRIALS_PER_POINT
        )

        fer_always_cfo = (
            1.0
            - success_always_cfo
            / TRIALS_PER_POINT
        )

        fer_guarded_cfo = (
            1.0
            - success_guarded_cfo
            / TRIALS_PER_POINT
        )

        guarded_apply_rate = (
            guarded_apply_count
            / TRIALS_PER_POINT
        )

        results.append(
            {
                "eb_n0_db": eb_n0_db,
                "max_abs_cfo_hz": (
                    max_abs_cfo_hz
                ),
                "trials": TRIALS_PER_POINT,
                "success_phase_only": (
                    success_phase_only
                ),
                "success_always_cfo": (
                    success_always_cfo
                ),
                "success_guarded_cfo": (
                    success_guarded_cfo
                ),
                "fer_phase_only": (
                    fer_phase_only
                ),
                "fer_always_cfo": (
                    fer_always_cfo
                ),
                "fer_guarded_cfo": (
                    fer_guarded_cfo
                ),
                "guarded_apply_count": (
                    guarded_apply_count
                ),
                "guarded_apply_rate": (
                    guarded_apply_rate
                ),
                "frequency_rmse_hz": (
                    frequency_rmse_hz
                ),
                "mean_phase_consistency": (
                    mean_phase_consistency
                ),
            }
        )


# ============================================================
# Вывод результатов
# ============================================================

print("\nПопыток на каждую точку:")

print(TRIALS_PER_POINT)

print("\nРезультаты:")

print(
    f"{'Eb/N0':>9}"
    f"{'CFO ±':>10}"
    f"{'Усп. фаза':>12}"
    f"{'Усп. всегда':>14}"
    f"{'Усп. guard':>13}"
    f"{'FER фаза':>11}"
    f"{'FER всегда':>12}"
    f"{'FER guard':>11}"
    f"{'Guard on':>10}"
    f"{'CFO RMSE':>11}"
)

print("-" * 114)

for result in results:

    print(
        f"{result['eb_n0_db']:>6.1f} дБ"
        f"{result['max_abs_cfo_hz']:>7.0f} Гц"
        f"{result['success_phase_only']:>12}"
        f"{result['success_always_cfo']:>14}"
        f"{result['success_guarded_cfo']:>13}"
        f"{result['fer_phase_only']:>11.3f}"
        f"{result['fer_always_cfo']:>12.3f}"
        f"{result['fer_guarded_cfo']:>11.3f}"
        f"{result['guarded_apply_rate'] * 100:>8.1f} %"
        f"{result['frequency_rmse_hz']:>8.2f} Гц"
    )


# ============================================================
# Сохранение CSV
# ============================================================

with CSV_PATH.open(
    "w",
    newline="",
    encoding="utf-8-sig",
) as csv_file:

    writer = DictWriter(
        csv_file,
        fieldnames=list(
            results[0].keys()
        ),
    )

    writer.writeheader()
    writer.writerows(results)


# ============================================================
# Подготовка графиков
# ============================================================

measurement_floor = (
    0.5
    / TRIALS_PER_POINT
)

figure, axes = plt.subplots(
    2,
    2,
    figsize=(13, 10),
)


# ------------------------------------------------------------
# 1. FER при истинном CFO = 0
# ------------------------------------------------------------

zero_cfo_results = [
    result
    for result in results
    if result["max_abs_cfo_hz"] == 0.0
]

zero_eb_values = [
    result["eb_n0_db"]
    for result in zero_cfo_results
]

axes[0, 0].semilogy(
    zero_eb_values,
    [
        max(
            result["fer_phase_only"],
            measurement_floor,
        )
        for result in zero_cfo_results
    ],
    marker="o",
    label="Только постоянная фаза",
)

axes[0, 0].semilogy(
    zero_eb_values,
    [
        max(
            result["fer_always_cfo"],
            measurement_floor,
        )
        for result in zero_cfo_results
    ],
    marker="s",
    label="CFO применяется всегда",
)

axes[0, 0].semilogy(
    zero_eb_values,
    [
        max(
            result["fer_guarded_cfo"],
            measurement_floor,
        )
        for result in zero_cfo_results
    ],
    marker="^",
    label="Защищённая CFO-коррекция",
)

axes[0, 0].set_xlabel(
    "Eb/N0, дБ"
)

axes[0, 0].set_ylabel(
    "FER"
)

axes[0, 0].set_title(
    "Истинный CFO = 0 Гц"
)

axes[0, 0].grid(
    True,
    which="both",
)

axes[0, 0].legend()


# ------------------------------------------------------------
# 2. FER при CFO ±1000 Гц
# ------------------------------------------------------------

large_cfo_results = [
    result
    for result in results
    if result["max_abs_cfo_hz"] == 1000.0
]

large_eb_values = [
    result["eb_n0_db"]
    for result in large_cfo_results
]

axes[0, 1].semilogy(
    large_eb_values,
    [
        max(
            result["fer_phase_only"],
            measurement_floor,
        )
        for result in large_cfo_results
    ],
    marker="o",
    label="Только постоянная фаза",
)

axes[0, 1].semilogy(
    large_eb_values,
    [
        max(
            result["fer_always_cfo"],
            measurement_floor,
        )
        for result in large_cfo_results
    ],
    marker="s",
    label="CFO применяется всегда",
)

axes[0, 1].semilogy(
    large_eb_values,
    [
        max(
            result["fer_guarded_cfo"],
            measurement_floor,
        )
        for result in large_cfo_results
    ],
    marker="^",
    label="Защищённая CFO-коррекция",
)

axes[0, 1].set_xlabel(
    "Eb/N0, дБ"
)

axes[0, 1].set_ylabel(
    "FER"
)

axes[0, 1].set_title(
    "Случайный CFO в диапазоне ±1000 Гц"
)

axes[0, 1].grid(
    True,
    which="both",
)

axes[0, 1].legend()


# ------------------------------------------------------------
# 3. Доля включения CFO-коррекции
# ------------------------------------------------------------

for max_abs_cfo_hz in (
    MAX_ABS_CFO_VALUES_HZ
):

    selected_results = [
        result
        for result in results
        if (
            result["max_abs_cfo_hz"]
            == max_abs_cfo_hz
        )
    ]

    axes[1, 0].plot(
        [
            result["eb_n0_db"]
            for result in selected_results
        ],
        [
            result["guarded_apply_rate"]
            for result in selected_results
        ],
        marker="o",
        label=(
            f"CFO ±{max_abs_cfo_hz:.0f} Гц"
        ),
    )

axes[1, 0].set_xlabel(
    "Eb/N0, дБ"
)

axes[1, 0].set_ylabel(
    "Доля включения CFO-коррекции"
)

axes[1, 0].set_ylim(
    -0.03,
    1.03,
)

axes[1, 0].set_title(
    "Решения защищённого корректора"
)

axes[1, 0].grid(
    True
)

axes[1, 0].legend()


# ------------------------------------------------------------
# 4. Ошибка оценки CFO
# ------------------------------------------------------------

for max_abs_cfo_hz in (
    MAX_ABS_CFO_VALUES_HZ
):

    selected_results = [
        result
        for result in results
        if (
            result["max_abs_cfo_hz"]
            == max_abs_cfo_hz
        )
    ]

    axes[1, 1].plot(
        [
            result["eb_n0_db"]
            for result in selected_results
        ],
        [
            result["frequency_rmse_hz"]
            for result in selected_results
        ],
        marker="o",
        label=(
            f"CFO ±{max_abs_cfo_hz:.0f} Гц"
        ),
    )

axes[1, 1].set_xlabel(
    "Eb/N0, дБ"
)

axes[1, 1].set_ylabel(
    "CFO RMSE, Гц"
)

axes[1, 1].set_title(
    "Точность оценки частотной ошибки"
)

axes[1, 1].grid(
    True
)

axes[1, 1].legend()


figure.tight_layout()

figure.savefig(
    GRAPH_PATH,
    dpi=160,
)

plt.close(
    figure
)


# ============================================================
# Текстовый отчёт
# ============================================================

report_lines = [
    "Lab023. Guarded CFO correction",
    "",
    (
        "Trials per point: "
        f"{TRIALS_PER_POINT}"
    ),
    (
        "CFO dead zone: "
        f"{CFO_DEAD_ZONE_HZ:.2f} Hz"
    ),
    (
        "Minimum phase consistency: "
        f"{MINIMUM_PHASE_CONSISTENCY:.3f}"
    ),
    "",
]

for result in results:

    report_lines.extend(
        [
            (
                "Eb/N0: "
                f"{result['eb_n0_db']:.1f} dB"
            ),
            (
                "Maximum absolute CFO: "
                f"{result['max_abs_cfo_hz']:.1f} Hz"
            ),
            (
                "FER phase only: "
                f"{result['fer_phase_only']:.6f}"
            ),
            (
                "FER always CFO: "
                f"{result['fer_always_cfo']:.6f}"
            ),
            (
                "FER guarded CFO: "
                f"{result['fer_guarded_cfo']:.6f}"
            ),
            (
                "Guard apply rate: "
                f"{result['guarded_apply_rate']:.6f}"
            ),
            (
                "CFO RMSE: "
                f"{result['frequency_rmse_hz']:.3f} Hz"
            ),
            "",
        ]
    )

REPORT_PATH.write_text(
    "\n".join(
        report_lines
    ),
    encoding="utf-8",
)


# ============================================================
# Автоматические проверки
# ============================================================

result_10_db_zero_cfo = next(
    result
    for result in results
    if (
        result["eb_n0_db"] == 10.0
        and result["max_abs_cfo_hz"] == 0.0
    )
)

result_10_db_large_cfo = next(
    result
    for result in results
    if (
        result["eb_n0_db"] == 10.0
        and result["max_abs_cfo_hz"] == 1000.0
    )
)

assert len(transmitted_frame_bits) == 320

# При реальном CFO = 0 защищённый режим
# не должен постоянно включать частотную коррекцию.
assert (
    result_10_db_zero_cfo[
        "guarded_apply_rate"
    ]
    < 0.25
)

# При большом CFO защищённый корректор должен
# быть намного лучше режима только с постоянной фазой.
assert (
    result_10_db_large_cfo[
        "success_guarded_cfo"
    ]
    > result_10_db_large_cfo[
        "success_phase_only"
    ]
)

# При хорошем SNR защищённый режим должен сохранять
# практически все кадры.
assert (
    result_10_db_large_cfo[
        "success_guarded_cfo"
    ]
    >= int(
        TRIALS_PER_POINT * 0.90
    )
)

assert CSV_PATH.exists()
assert GRAPH_PATH.exists()
assert REPORT_PATH.exists()


print("\nCSV:")

print(CSV_PATH)

print("\nГрафик:")

print(GRAPH_PATH)

print("\nОтчёт:")

print(REPORT_PATH)

print(
    "\nПроверка пройдена: "
    "мёртвая зона исключает лишнюю CFO-коррекцию, "
    "но сохраняет компенсацию значительного рассогласования."
)