"""
Lab020. Frame Error Rate полного BPSK-радиокадра.

Программа многократно передаёт IQ-радиокадр из Lab018
при разных значениях Eb/N0.

В каждой попытке случайным образом изменяются:

- задержка начала сигнала;
- постоянный фазовый поворот BPSK;
- реализация AWGN-шума.

Приёмник выполняет:

1. Согласованную RRC-фильтрацию.
2. Поиск PREAMBLE + RADIO SYNC.
3. Выбор фазы символьной дискретизации.
4. Оценку и компенсацию фазового поворота.
5. Демодуляцию BPSK.
6. Чтение радиозаголовка.
7. Извлечение внутреннего пакета.
8. Проверку структуры и CRC-32.
9. Восстановление исходного сообщения.

Результат:

FER — Frame Error Rate, доля радиокадров,
которые не удалось полностью восстановить.
"""

from csv import DictWriter
from math import erfc, expm1, log1p, sqrt
from pathlib import Path
import struct

import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import fftconvolve

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


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

RADIO_SYNC_WORD = 0xD391

PREAMBLE_BIT_COUNT = 64

SAMPLES_PER_SYMBOL = 32

RRC_ROLLOFF = 0.35
RRC_SPAN_SYMBOLS = 10

# Полный радиокадр:
#
# PREAMBLE        64 бита
# RADIO HEADER    32 бита
# INNER PACKET   224 бита
#
# Итого          320 бит
RADIO_FRAME_BIT_COUNT = 320


# ============================================================
# Контрольные данные
# ============================================================

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


# ============================================================
# Настройки эксперимента
# ============================================================

EB_N0_VALUES_DB = [
    4.0,
    5.0,
    6.0,
    7.0,
    8.0,
    10.0,
    12.0,
]

TRIALS_PER_EB_N0 = 100

RANDOM_SEED = 2026

# Максимальная случайная задержка начала кадра.
MAX_RANDOM_DELAY_SAMPLES = (
    2 * SAMPLES_PER_SYMBOL
)

# Если нормированная корреляция ниже этого значения,
# считаем, что маркер радиокадра не обнаружен.
DETECTION_THRESHOLD = 0.55

# Защита от повреждённого поля LENGTH.
MAX_PROTOCOL_PACKET_SIZE = 4096


# ============================================================
# Пути
# ============================================================

INPUT_IQ_PATH = Path(
    "data/processed/lab018/"
    "lab018_bpsk_tx_iq.npy"
)

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

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

CSV_PATH = (
    OUTPUT_DIRECTORY
    / "lab020_frame_error_results.csv"
)

GRAPH_PATH = (
    OUTPUT_DIRECTORY
    / "lab020_frame_error_rate.png"
)

REPORT_PATH = (
    OUTPUT_DIRECTORY
    / "lab020_frame_error_report.txt"
)


# ============================================================
# Статусы одной попытки
# ============================================================

STATUS_SUCCESS = "SUCCESS"
STATUS_DETECTION_FAIL = "DETECTION_FAIL"
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,
        )
    )


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

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)


# ============================================================
# Root Raised Cosine-фильтр
# ============================================================

def root_raised_cosine_taps(
    rolloff: float,
    samples_per_symbol: int,
    span_symbols: int,
) -> np.ndarray:
    """
    Рассчитать коэффициенты RRC-фильтра.

    Параметры совпадают с Lab018 и Lab019.
    """

    if not 0.0 < rolloff <= 1.0:
        raise ValueError(
            "rolloff должен находиться в диапазоне 0...1"
        )

    if samples_per_symbol <= 0:
        raise ValueError(
            "samples_per_symbol должен быть положительным"
        )

    if span_symbols <= 0:
        raise ValueError(
            "span_symbols должен быть положительным"
        )

    if span_symbols % 2 != 0:
        raise ValueError(
            "span_symbols должен быть чётным"
        )

    half_sample_count = (
        span_symbols
        * samples_per_symbol
        // 2
    )

    sample_indexes = np.arange(
        -half_sample_count,
        half_sample_count + 1,
        dtype=np.float64,
    )

    time_values = (
        sample_indexes
        / samples_per_symbol
    )

    taps = np.zeros_like(
        time_values
    )

    beta = rolloff

    for index, time_value in enumerate(
        time_values
    ):

        if np.isclose(
            time_value,
            0.0,
        ):
            taps[index] = (
                1.0
                - beta
                + 4.0 * beta / np.pi
            )

            continue

        if np.isclose(
            abs(time_value),
            1.0 / (4.0 * beta),
        ):
            taps[index] = (
                beta
                / np.sqrt(2.0)
                * (
                    (
                        1.0
                        + 2.0 / np.pi
                    )
                    * np.sin(
                        np.pi
                        / (4.0 * beta)
                    )
                    + (
                        1.0
                        - 2.0 / np.pi
                    )
                    * np.cos(
                        np.pi
                        / (4.0 * beta)
                    )
                )
            )

            continue

        numerator = (
            np.sin(
                np.pi
                * time_value
                * (1.0 - beta)
            )
            + (
                4.0
                * beta
                * time_value
                * np.cos(
                    np.pi
                    * time_value
                    * (1.0 + beta)
                )
            )
        )

        denominator = (
            np.pi
            * time_value
            * (
                1.0
                - (
                    4.0
                    * beta
                    * time_value
                ) ** 2
            )
        )

        taps[index] = (
            numerator
            / denominator
        )

    taps /= np.sqrt(
        np.sum(
            taps ** 2
        )
    )

    return taps


# ============================================================
# Маркер PREAMBLE + RADIO SYNC
# ============================================================

def build_frame_marker(
) -> tuple[np.ndarray, np.ndarray]:
    """
    Сформировать биты и BPSK-символы маркера.
    """

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

    sync_bits = bytes_to_bits(
        struct.pack(
            ">H",
            RADIO_SYNC_WORD,
        )
    )

    marker_bits = np.concatenate(
        [
            preamble_bits,
            sync_bits,
        ]
    )

    marker_symbols = bpsk_modulate(
        marker_bits
    )

    return marker_bits, marker_symbols


# ============================================================
# Теоретический BER и идеальная FER
# ============================================================

def theoretical_bpsk_ber(
    eb_n0_db: float,
) -> float:
    """
    Теоретический BER когерентной BPSK в AWGN.
    """

    eb_n0_linear = 10.0 ** (
        eb_n0_db / 10.0
    )

    return 0.5 * erfc(
        sqrt(eb_n0_linear)
    )


def ber_to_frame_error_rate(
    ber: float,
    frame_bit_count: int,
) -> float:
    """
    Оценить FER при независимых битовых ошибках.

        FER = 1 - (1 - BER) ** N
    """

    if ber == 0.0:
        return 0.0

    return -expm1(
        frame_bit_count
        * log1p(-ber)
    )


# ============================================================
# Добавление AWGN по заданному Eb/N0
# ============================================================

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

    signal_energy_per_bit рассчитывается по исходному
    IQ-радиокадру как:

        сумма |IQ|² / количество передаваемых битов

    Полная комплексная дисперсия шума:

        noise_variance = Eb / (Eb/N0)
    """

    eb_n0_linear = 10.0 ** (
        eb_n0_db / 10.0
    )

    complex_noise_variance = (
        signal_energy_per_bit
        / eb_n0_linear
    )

    component_sigma = sqrt(
        complex_noise_variance / 2.0
    )

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

    return clean_iq + noise


# ============================================================
# Корреляционный поиск радиокадра
# ============================================================

def find_radio_frame(
    matched_iq: np.ndarray,
    marker_symbols: np.ndarray,
    samples_per_symbol: int,
) -> dict:
    """
    Перебрать все возможные фазы символьной дискретизации
    и найти максимальную нормированную корреляцию.
    """

    marker_energy = float(
        np.sum(
            np.abs(marker_symbols) ** 2
        )
    )

    best_result = None

    for sample_phase in range(
        samples_per_symbol
    ):

        symbol_samples = matched_iq[
            sample_phase::samples_per_symbol
        ]

        if len(symbol_samples) < len(
            marker_symbols
        ):
            continue

        correlation = np.correlate(
            symbol_samples,
            marker_symbols,
            mode="valid",
        )

        window_energy = np.convolve(
            np.abs(symbol_samples) ** 2,
            np.ones(
                len(marker_symbols)
            ),
            mode="valid",
        )

        normalized_correlation = (
            np.abs(correlation)
            / (
                np.sqrt(
                    window_energy
                    * marker_energy
                )
                + 1e-12
            )
        )

        start_symbol_index = int(
            np.argmax(
                normalized_correlation
            )
        )

        score = float(
            normalized_correlation[
                start_symbol_index
            ]
        )

        if (
            best_result is None
            or score > best_result["score"]
        ):
            best_result = {
                "score": score,
                "sample_phase": sample_phase,
                "start_symbol_index": (
                    start_symbol_index
                ),
                "symbol_samples": (
                    symbol_samples
                ),
                "complex_correlation": (
                    correlation[
                        start_symbol_index
                    ]
                ),
            }

    if best_result is None:
        raise RuntimeError(
            "Корреляционный поиск не дал результата"
        )

    return best_result


# ============================================================
# Приём одной реализации радиокадра
# ============================================================

def receive_one_frame(
    received_iq: np.ndarray,
    rrc_taps: np.ndarray,
    marker_bits: np.ndarray,
    marker_symbols: np.ndarray,
) -> dict:
    """
    Выполнить полный приём одного радиокадра.

    Возвращает словарь со статусом и диагностикой.
    """

    matched_iq = fftconvolve(
        received_iq,
        rrc_taps,
        mode="full",
    )

    search_result = find_radio_frame(
        matched_iq=matched_iq,
        marker_symbols=marker_symbols,
        samples_per_symbol=SAMPLES_PER_SYMBOL,
    )

    correlation_score = search_result[
        "score"
    ]

    if correlation_score < DETECTION_THRESHOLD:
        return {
            "status": STATUS_DETECTION_FAIL,
            "correlation_score": correlation_score,
            "marker_bit_errors": None,
        }

    symbol_samples = search_result[
        "symbol_samples"
    ]

    frame_start_symbol = search_result[
        "start_symbol_index"
    ]

    estimated_phase = np.angle(
        search_result[
            "complex_correlation"
        ]
    )

    corrected_symbols = (
        symbol_samples
        * np.exp(
            -1j * estimated_phase
        )
    )

    received_bits = bpsk_demodulate(
        corrected_symbols
    )

    available_bits = received_bits[
        frame_start_symbol:
    ]

    marker_bit_count = len(
        marker_bits
    )

    if len(available_bits) < marker_bit_count:
        return {
            "status": STATUS_HEADER_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": None,
        }

    received_marker_bits = available_bits[
        :marker_bit_count
    ]

    marker_bit_errors = int(
        np.count_nonzero(
            received_marker_bits
            != marker_bits
        )
    )

    radio_header_start = (
        PREAMBLE_BIT_COUNT
    )

    radio_header_end = (
        radio_header_start + 32
    )

    if len(available_bits) < radio_header_end:
        return {
            "status": STATUS_HEADER_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

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

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

    except (ValueError, struct.error):
        return {
            "status": STATUS_HEADER_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    if received_radio_sync != RADIO_SYNC_WORD:
        return {
            "status": STATUS_HEADER_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    if not (
        1
        <= protocol_packet_length
        <= MAX_PROTOCOL_PACKET_SIZE
    ):
        return {
            "status": STATUS_HEADER_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    protocol_packet_start = (
        radio_header_end
    )

    protocol_packet_end = (
        protocol_packet_start
        + protocol_packet_length * 8
    )

    if len(available_bits) < protocol_packet_end:
        return {
            "status": STATUS_HEADER_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

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

    except ValueError:
        return {
            "status": STATUS_PACKET_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    try:
        parsed_packet = parse_packet(
            protocol_packet
        )

    except CRCError:
        return {
            "status": STATUS_CRC_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    except PacketError:
        return {
            "status": STATUS_PACKET_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

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

    except UnicodeDecodeError:
        return {
            "status": STATUS_PACKET_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    if (
        parsed_packet.message_type
        != MESSAGE_TYPE_TEXT
        or parsed_packet.sequence_number
        != EXPECTED_SEQUENCE_NUMBER
        or restored_message
        != EXPECTED_MESSAGE
    ):
        return {
            "status": STATUS_PACKET_ERROR,
            "correlation_score": correlation_score,
            "marker_bit_errors": marker_bit_errors,
        }

    return {
        "status": STATUS_SUCCESS,
        "correlation_score": correlation_score,
        "marker_bit_errors": marker_bit_errors,
    }


# ============================================================
# Загрузка IQ из Lab018
# ============================================================

if not INPUT_IQ_PATH.exists():
    raise FileNotFoundError(
        f"Не найден файл: {INPUT_IQ_PATH}. "
        "Сначала необходимо выполнить Lab018."
    )

transmitted_iq = np.load(
    INPUT_IQ_PATH
)

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

if not np.iscomplexobj(
    transmitted_iq
):
    raise ValueError(
        "Входной массив должен быть комплексным"
    )

transmitted_iq = transmitted_iq.astype(
    np.complex128
)


# ============================================================
# Подготовка приёмника
# ============================================================

rrc_taps = root_raised_cosine_taps(
    rolloff=RRC_ROLLOFF,
    samples_per_symbol=SAMPLES_PER_SYMBOL,
    span_symbols=RRC_SPAN_SYMBOLS,
)

marker_bits, marker_symbols = (
    build_frame_marker()
)

signal_energy_per_bit = (
    np.sum(
        np.abs(transmitted_iq) ** 2
    )
    / RADIO_FRAME_BIT_COUNT
)


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

results = []

for eb_n0_index, eb_n0_db in enumerate(
    EB_N0_VALUES_DB
):

    counters = {
        STATUS_SUCCESS: 0,
        STATUS_DETECTION_FAIL: 0,
        STATUS_HEADER_ERROR: 0,
        STATUS_CRC_ERROR: 0,
        STATUS_PACKET_ERROR: 0,
    }

    correlation_scores = []
    marker_error_counts = []

    random_generator = np.random.default_rng(
        RANDOM_SEED
        + 1000 * eb_n0_index
    )

    for trial_index in range(
        TRIALS_PER_EB_N0
    ):

        random_delay = int(
            random_generator.integers(
                0,
                MAX_RANDOM_DELAY_SAMPLES + 1,
            )
        )

        random_phase = (
            random_generator.uniform(
                -np.pi,
                np.pi,
            )
        )

        phase_rotated_iq = (
            transmitted_iq
            * np.exp(
                1j * random_phase
            )
        )

        # Добавляем нули до и после кадра.
        # После внесения AWGN эти участки превращаются
        # в обычный шумовой фон.
        clean_received_iq = np.concatenate(
            [
                np.zeros(
                    random_delay,
                    dtype=np.complex128,
                ),
                phase_rotated_iq,
                np.zeros(
                    2 * SAMPLES_PER_SYMBOL,
                    dtype=np.complex128,
                ),
            ]
        )

        noisy_received_iq = add_awgn_for_eb_n0(
            clean_iq=clean_received_iq,
            signal_energy_per_bit=(
                signal_energy_per_bit
            ),
            eb_n0_db=eb_n0_db,
            random_generator=random_generator,
        )

        attempt_result = receive_one_frame(
            received_iq=noisy_received_iq,
            rrc_taps=rrc_taps,
            marker_bits=marker_bits,
            marker_symbols=marker_symbols,
        )

        status = attempt_result[
            "status"
        ]

        counters[status] += 1

        correlation_scores.append(
            attempt_result[
                "correlation_score"
            ]
        )

        marker_bit_errors = attempt_result[
            "marker_bit_errors"
        ]

        if marker_bit_errors is not None:
            marker_error_counts.append(
                marker_bit_errors
            )

    success_count = counters[
        STATUS_SUCCESS
    ]

    frame_error_count = (
        TRIALS_PER_EB_N0
        - success_count
    )

    experimental_fer = (
        frame_error_count
        / TRIALS_PER_EB_N0
    )

    theoretical_ber = theoretical_bpsk_ber(
        eb_n0_db
    )

    ideal_fer = ber_to_frame_error_rate(
        ber=theoretical_ber,
        frame_bit_count=(
            RADIO_FRAME_BIT_COUNT
        ),
    )

    mean_correlation = float(
        np.mean(
            correlation_scores
        )
    )

    if marker_error_counts:
        mean_marker_bit_errors = float(
            np.mean(
                marker_error_counts
            )
        )
    else:
        mean_marker_bit_errors = float(
            "nan"
        )

    results.append(
        {
            "eb_n0_db": eb_n0_db,
            "trials": TRIALS_PER_EB_N0,
            "success_count": success_count,
            "detection_fail_count": counters[
                STATUS_DETECTION_FAIL
            ],
            "header_error_count": counters[
                STATUS_HEADER_ERROR
            ],
            "crc_error_count": counters[
                STATUS_CRC_ERROR
            ],
            "packet_error_count": counters[
                STATUS_PACKET_ERROR
            ],
            "experimental_fer": experimental_fer,
            "theoretical_ber": theoretical_ber,
            "ideal_fer": ideal_fer,
            "mean_correlation": mean_correlation,
            "mean_marker_bit_errors": (
                mean_marker_bit_errors
            ),
        }
    )


# ============================================================
# Вывод таблицы
# ============================================================

print(
    "=== Lab020. FER полного BPSK-радиокадра ==="
)

print("\nКоличество попыток на точку:")

print(TRIALS_PER_EB_N0)

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

print(
    f"{'Eb/N0':>9}"
    f"{'Успех':>9}"
    f"{'Нет марк.':>11}"
    f"{'Загол.':>9}"
    f"{'CRC':>7}"
    f"{'Пакет':>8}"
    f"{'FER':>10}"
    f"{'Идеал FER':>13}"
    f"{'Коррел.':>11}"
)

print("-" * 87)

for result in results:

    print(
        f"{result['eb_n0_db']:>6.1f} дБ"
        f"{result['success_count']:>9}"
        f"{result['detection_fail_count']:>11}"
        f"{result['header_error_count']:>9}"
        f"{result['crc_error_count']:>7}"
        f"{result['packet_error_count']:>8}"
        f"{result['experimental_fer']:>10.3f}"
        f"{result['ideal_fer']:>13.3f}"
        f"{result['mean_correlation']:>11.3f}"
    )


# ============================================================
# Сохранение 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)


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

eb_n0_values = np.array(
    [
        result["eb_n0_db"]
        for result in results
    ],
    dtype=np.float64,
)

experimental_fer_values = np.array(
    [
        result["experimental_fer"]
        for result in results
    ],
    dtype=np.float64,
)

ideal_fer_values = np.array(
    [
        result["ideal_fer"]
        for result in results
    ],
    dtype=np.float64,
)

measurement_floor = (
    0.5 / TRIALS_PER_EB_N0
)

experimental_fer_for_plot = np.maximum(
    experimental_fer_values,
    measurement_floor,
)


# ============================================================
# Графики
# ============================================================

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


# ------------------------------------------------------------
# 1. FER
# ------------------------------------------------------------

axes[0].semilogy(
    eb_n0_values,
    ideal_fer_values,
    marker="o",
    label=(
        "Идеальная FER по BER "
        "при идеальной синхронизации"
    ),
)

axes[0].semilogy(
    eb_n0_values,
    experimental_fer_for_plot,
    marker="s",
    linestyle="--",
    label="Экспериментальная FER приёмника",
)

axes[0].axhline(
    measurement_floor,
    linestyle=":",
    label=(
        "Предел измерения "
        f"{measurement_floor:.3f}"
    ),
)

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

axes[0].set_ylabel(
    "Frame Error Rate"
)

axes[0].set_title(
    "Устойчивость полного BPSK-радиокадра"
)

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

axes[0].legend()


# ------------------------------------------------------------
# 2. Классификация результатов
# ------------------------------------------------------------

success_rates = [
    result["success_count"]
    / TRIALS_PER_EB_N0
    for result in results
]

detection_fail_rates = [
    result["detection_fail_count"]
    / TRIALS_PER_EB_N0
    for result in results
]

header_error_rates = [
    result["header_error_count"]
    / TRIALS_PER_EB_N0
    for result in results
]

crc_error_rates = [
    result["crc_error_count"]
    / TRIALS_PER_EB_N0
    for result in results
]

packet_error_rates = [
    result["packet_error_count"]
    / TRIALS_PER_EB_N0
    for result in results
]

axes[1].plot(
    eb_n0_values,
    success_rates,
    marker="o",
    label="Успешно",
)

axes[1].plot(
    eb_n0_values,
    detection_fail_rates,
    marker="o",
    label="Маркер не обнаружен",
)

axes[1].plot(
    eb_n0_values,
    header_error_rates,
    marker="o",
    label="Ошибка радиозаголовка",
)

axes[1].plot(
    eb_n0_values,
    crc_error_rates,
    marker="o",
    label="Ошибка CRC",
)

axes[1].plot(
    eb_n0_values,
    packet_error_rates,
    marker="o",
    label="Ошибка структуры пакета",
)

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

axes[1].set_ylabel(
    "Доля попыток"
)

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

axes[1].set_title(
    "Причины потери радиокадров"
)

axes[1].grid(
    True
)

axes[1].legend()


figure.tight_layout()

figure.savefig(
    GRAPH_PATH,
    dpi=160,
)

plt.close(
    figure
)


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

report_lines = [
    "Lab020. Full BPSK frame error rate",
    "",
    (
        "Trials per Eb/N0: "
        f"{TRIALS_PER_EB_N0}"
    ),
    (
        "Detection threshold: "
        f"{DETECTION_THRESHOLD:.3f}"
    ),
    (
        "Frame bit count: "
        f"{RADIO_FRAME_BIT_COUNT}"
    ),
    "",
]

for result in results:

    report_lines.extend(
        [
            f"Eb/N0: {result['eb_n0_db']:.1f} dB",
            (
                "  Success: "
                f"{result['success_count']}"
            ),
            (
                "  Detection fail: "
                f"{result['detection_fail_count']}"
            ),
            (
                "  Header error: "
                f"{result['header_error_count']}"
            ),
            (
                "  CRC error: "
                f"{result['crc_error_count']}"
            ),
            (
                "  Packet error: "
                f"{result['packet_error_count']}"
            ),
            (
                "  Experimental FER: "
                f"{result['experimental_fer']:.6f}"
            ),
            (
                "  Ideal FER: "
                f"{result['ideal_fer']:.6f}"
            ),
            "",
        ]
    )

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


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

result_at_4_db = next(
    result
    for result in results
    if result["eb_n0_db"] == 4.0
)

result_at_12_db = next(
    result
    for result in results
    if result["eb_n0_db"] == 12.0
)

assert sum(
    [
        result[
            "success_count"
        ],
        result[
            "detection_fail_count"
        ],
        result[
            "header_error_count"
        ],
        result[
            "crc_error_count"
        ],
        result[
            "packet_error_count"
        ],
    ]
) == TRIALS_PER_EB_N0

assert (
    result_at_12_db["success_count"]
    > result_at_4_db["success_count"]
)

assert (
    result_at_12_db["experimental_fer"]
    < result_at_4_db["experimental_fer"]
)

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Проверка пройдена: "
    "измерена FER полного BPSK-радиокадра."
)