Split experiments from tests

The tests/ directory held 50 laboratory programs and no tests. They model
channels, run hundreds of repetitions and write CSV, PNG and reports;
calling that a test suite blocked introducing a real one, because any
pytest run would have collected the labs and re-executed every
experiment.

- move all 50 lab programs to experiments/ with git mv, preserving history
- rewrite the 38 cross-imports between labs from tests.labNNN to
  experiments.labNNN
- leave tests/ empty for actual fast checks of protocol/
- point quick_gate and the hook at the new layout and add experiments/ to
  the syntax sweep
- update the paths quoted in the Lab042 specification and the verifier
  agent definition

This also defuses the import-time work finding without touching 41 files:
the labs still create directories and write files on import, but nothing
imports them now except the gate, which does so deliberately.

Gate passes: syntax clean, protocol imports, 15 lab modules import, 2
functional suites run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
LittleSam129
2026-08-10 14:34:58 +03:00
parent c9569164e0
commit c486039053
55 changed files with 63 additions and 62 deletions

0
experiments/__init__.py Normal file
View File

View File

@@ -0,0 +1,54 @@
"""
Lab001. Преобразование текста в байты и биты.
Цель:
1. Взять обычную текстовую строку.
2. Преобразовать её в байты UTF-8.
3. Показать каждый байт как десятичное число.
4. Показать каждый байт как восемь бит.
5. Восстановить исходный текст.
"""
# Исходное сообщение
message = "HELLO SDR"
# Кодирование текста в байты.
# UTF-8 — способ представить символы числами.
encoded_message = message.encode("utf-8")
# Преобразование каждого байта в строку из восьми бит.
binary_message = " ".join(
f"{byte:08b}" for byte in encoded_message
)
# Обратное преобразование байтов в текст.
decoded_message = encoded_message.decode("utf-8")
print("Исходный текст:")
print(message)
print("\nОбъект bytes:")
print(encoded_message)
print("\nБайты в десятичном виде:")
print(list(encoded_message))
print("\nБайты в двоичном виде:")
print(binary_message)
print("\nПодробно по каждому символу:")
for character, byte_value in zip(message, encoded_message):
print(
f"{character!r:>4} "
f"→ число {byte_value:3d} "
f"→ биты {byte_value:08b}"
)
print("\nВосстановленный текст:")
print(decoded_message)
# Автоматическая проверка результата.
assert decoded_message == message
print("\nПроверка пройдена: исходный текст восстановлен без ошибок.")

View File

@@ -0,0 +1,61 @@
"""
Lab001, часть 2.
Русский текст, символы, байты и кодировка UTF-8.
Цель:
1. Увидеть разницу между количеством символов и количеством байтов.
2. Посмотреть, сколько байтов занимает каждая русская буква.
3. Восстановить исходный текст из байтов.
"""
# Исходное сообщение
message = "ПРИВЕТ SDR"
# Преобразование текста в байты UTF-8
encoded_message = message.encode("utf-8")
# Обратное преобразование байтов в текст
decoded_message = encoded_message.decode("utf-8")
print("Исходный текст:")
print(message)
print("\nКоличество символов:")
print(len(message))
print("\nКоличество байтов UTF-8:")
print(len(encoded_message))
print("\nОбъект bytes:")
print(encoded_message)
print("\nВсе байты в десятичном виде:")
print(list(encoded_message))
print("\nПодробно по каждому символу:")
for character in message:
character_bytes = character.encode("utf-8")
decimal_bytes = list(character_bytes)
binary_bytes = " ".join(
f"{byte:08b}" for byte in character_bytes
)
print(
f"{character!r:>4} "
f"→ байтов: {len(character_bytes)} "
f"→ числа: {decimal_bytes} "
f"→ биты: {binary_bytes}"
)
print("\nВосстановленный текст:")
print(decoded_message)
# Автоматические проверки
assert decoded_message == message
assert len(encoded_message) >= len(message)
print("\nПроверка пройдена: русский текст восстановлен без ошибок.")

View File

@@ -0,0 +1,153 @@
"""
Lab002, часть 2.
Повреждение одного бита пакета без контроля целостности.
Цель:
1. Сформировать цифровой пакет.
2. Намеренно изменить один бит полезной нагрузки.
3. Разобрать повреждённый пакет.
4. Убедиться, что без CRC повреждение не обнаруживается.
"""
import struct
# ============================================================
# Константы протокола
# ============================================================
SYNC_WORD = 0xAA55
PROTOCOL_VERSION = 1
MESSAGE_TYPE_TEXT = 1
HEADER_FORMAT = ">HBBHH"
# ============================================================
# Формирование исходного пакета
# ============================================================
message = "ПРИВЕТ SDR"
payload = message.encode("utf-8")
sequence_number = 1
message_type = MESSAGE_TYPE_TEXT
payload_length = len(payload)
header = struct.pack(
HEADER_FORMAT,
SYNC_WORD,
PROTOCOL_VERSION,
message_type,
sequence_number,
payload_length,
)
packet = header + payload
print("Исходное сообщение:")
print(message)
print("\nИсходный пакет:")
print(packet.hex(" "))
# ============================================================
# Имитация повреждения пакета
# ============================================================
# bytes нельзя изменять напрямую.
# Поэтому преобразуем пакет в изменяемый массив bytearray.
corrupted_packet = bytearray(packet)
header_size = struct.calcsize(HEADER_FORMAT)
# В строке "ПРИВЕТ SDR":
#
# "ПРИВЕТ" занимает 12 байтов UTF-8,
# пробел занимает 1 байт,
# буква S находится по индексу 13 внутри PAYLOAD.
payload_byte_index = 13
# Полный индекс внутри пакета:
packet_byte_index = header_size + payload_byte_index
original_byte = corrupted_packet[packet_byte_index]
# XOR с 0x01 изменяет младший бит:
#
# 0x53 = 01010011 = S
# 0x52 = 01010010 = R
corrupted_packet[packet_byte_index] ^= 0x01
corrupted_byte = corrupted_packet[packet_byte_index]
print("\nИзменяемый байт внутри пакета:")
print(
f"До повреждения: "
f"{original_byte:3d} "
f"= 0x{original_byte:02X} "
f"= {original_byte:08b}"
)
print(
f"После повреждения: "
f"{corrupted_byte:3d} "
f"= 0x{corrupted_byte:02X} "
f"= {corrupted_byte:08b}"
)
print("\nПовреждённый пакет:")
print(bytes(corrupted_packet).hex(" "))
# ============================================================
# Разбор повреждённого пакета
# ============================================================
received_header = corrupted_packet[:header_size]
received_payload = corrupted_packet[header_size:]
(
received_sync,
received_version,
received_type,
received_sequence,
received_length,
) = struct.unpack(HEADER_FORMAT, received_header)
# ============================================================
# Проверки структуры
# ============================================================
assert received_sync == SYNC_WORD
assert received_version == PROTOCOL_VERSION
assert received_type == MESSAGE_TYPE_TEXT
assert received_sequence == sequence_number
assert received_length == len(received_payload)
print("\nВсе проверки заголовка пройдены.")
restored_message = bytes(received_payload).decode("utf-8")
print("\nСообщение после повреждения:")
print(restored_message)
print("\nИсходное сообщение:")
print(message)
# ============================================================
# Проверка результата эксперимента
# ============================================================
assert restored_message != message
print("\nПовреждение данных произошло.")
print("Но заголовок и длина пакета остались правильными.")
print("Без CRC приёмник не смог определить ошибку.")

View File

@@ -0,0 +1,146 @@
"""
Lab002. Формирование и разбор первого цифрового пакета.
Пакет содержит:
- маркер начала;
- версию протокола;
- тип сообщения;
- порядковый номер;
- длину полезных данных;
- полезные данные.
"""
import struct
# ============================================================
# Константы протокола
# ============================================================
SYNC_WORD = 0xAA55
PROTOCOL_VERSION = 1
MESSAGE_TYPE_TEXT = 1
# ============================================================
# Исходные данные
# ============================================================
message = "ПРИВЕТ SDR"
payload = message.encode("utf-8")
sequence_number = 1
message_type = MESSAGE_TYPE_TEXT
payload_length = len(payload)
# ============================================================
# Формирование заголовка
# ============================================================
# Формат:
# > — порядок байтов от старшего к младшему, big-endian
# H — беззнаковое целое число размером 2 байта
# B — беззнаковое целое число размером 1 байт
# B — ещё одно число размером 1 байт
# H — порядковый номер размером 2 байта
# H — длина данных размером 2 байта
HEADER_FORMAT = ">HBBHH"
header = struct.pack(
HEADER_FORMAT,
SYNC_WORD,
PROTOCOL_VERSION,
message_type,
sequence_number,
payload_length,
)
packet = header + payload
# ============================================================
# Вывод сформированного пакета
# ============================================================
print("Исходное сообщение:")
print(message)
print("\nПолезная нагрузка PAYLOAD:")
print(payload)
print("\nДлина PAYLOAD:")
print(payload_length, "байт")
print("\nЗаголовок пакета:")
print(header)
print("\nПолный пакет:")
print(packet)
print("\nПакет в шестнадцатеричном виде:")
print(packet.hex(" "))
print("\nОбщая длина пакета:")
print(len(packet), "байт")
# ============================================================
# Разбор пакета на стороне приёмника
# ============================================================
header_size = struct.calcsize(HEADER_FORMAT)
received_header = packet[:header_size]
received_payload = packet[header_size:]
(
received_sync,
received_version,
received_type,
received_sequence,
received_length,
) = struct.unpack(HEADER_FORMAT, received_header)
print("\n--- Разбор принятого пакета ---")
print("SYNC:")
print(hex(received_sync))
print("\nВерсия протокола:")
print(received_version)
print("\nТип сообщения:")
print(received_type)
print("\nПорядковый номер:")
print(received_sequence)
print("\nДлина из заголовка:")
print(received_length, "байт")
print("\nФактически принято данных:")
print(len(received_payload), "байт")
# ============================================================
# Проверки
# ============================================================
assert received_sync == SYNC_WORD
assert received_version == PROTOCOL_VERSION
assert received_type == MESSAGE_TYPE_TEXT
assert received_sequence == sequence_number
assert received_length == len(received_payload)
restored_message = received_payload.decode("utf-8")
assert restored_message == message
print("\nВосстановленное сообщение:")
print(restored_message)
print("\nПроверка пройдена: пакет сформирован и разобран без ошибок.")

198
experiments/lab003_crc32.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Lab003. Обнаружение повреждения пакета с помощью CRC-32.
Цель:
1. Сформировать пакет с CRC-32.
2. Проверить исправный пакет.
3. Изменить один бит полезной нагрузки.
4. Убедиться, что CRC обнаруживает повреждение.
"""
import struct
import zlib
# ============================================================
# Константы протокола
# ============================================================
SYNC_WORD = 0xAA55
PROTOCOL_VERSION = 1
MESSAGE_TYPE_TEXT = 1
HEADER_FORMAT = ">HBBHH"
CRC_FORMAT = ">I"
CRC_SIZE = struct.calcsize(CRC_FORMAT)
# ============================================================
# Формирование исходного пакета
# ============================================================
message = "ПРИВЕТ SDR"
payload = message.encode("utf-8")
sequence_number = 1
payload_length = len(payload)
header = struct.pack(
HEADER_FORMAT,
SYNC_WORD,
PROTOCOL_VERSION,
MESSAGE_TYPE_TEXT,
sequence_number,
payload_length,
)
# CRC вычисляется по заголовку и полезной нагрузке.
packet_without_crc = header + payload
crc_value = zlib.crc32(packet_without_crc) & 0xFFFFFFFF
crc_bytes = struct.pack(
CRC_FORMAT,
crc_value,
)
packet = packet_without_crc + crc_bytes
print("Исходное сообщение:")
print(message)
print("\nCRC-32 исходного пакета:")
print(f"0x{crc_value:08X}")
print("\nПолный пакет:")
print(packet.hex(" "))
print("\nДлина полного пакета:")
print(len(packet), "байт")
# ============================================================
# Проверка исправного пакета
# ============================================================
received_data = packet[:-CRC_SIZE]
received_crc_bytes = packet[-CRC_SIZE:]
(received_crc,) = struct.unpack(
CRC_FORMAT,
received_crc_bytes,
)
calculated_crc = zlib.crc32(received_data) & 0xFFFFFFFF
print("\n--- Проверка исправного пакета ---")
print(f"CRC из пакета: 0x{received_crc:08X}")
print(f"CRC вычисленный: 0x{calculated_crc:08X}")
assert received_crc == calculated_crc
print("Результат: пакет не повреждён.")
# ============================================================
# Имитация повреждения одного бита
# ============================================================
corrupted_packet = bytearray(packet)
header_size = struct.calcsize(HEADER_FORMAT)
# Буква S находится по индексу 13 внутри PAYLOAD.
payload_byte_index = 13
packet_byte_index = header_size + payload_byte_index
original_byte = corrupted_packet[packet_byte_index]
# Меняем младший бит:
# S = 0x53
# R = 0x52
corrupted_packet[packet_byte_index] ^= 0x01
corrupted_byte = corrupted_packet[packet_byte_index]
print("\n--- Повреждение одного бита ---")
print(
f"До повреждения: "
f"0x{original_byte:02X} = {original_byte:08b}"
)
print(
f"После повреждения: "
f"0x{corrupted_byte:02X} = {corrupted_byte:08b}"
)
# ============================================================
# Проверка повреждённого пакета
# ============================================================
corrupted_data = bytes(
corrupted_packet[:-CRC_SIZE]
)
corrupted_crc_bytes = bytes(
corrupted_packet[-CRC_SIZE:]
)
(received_crc_after_corruption,) = struct.unpack(
CRC_FORMAT,
corrupted_crc_bytes,
)
calculated_crc_after_corruption = (
zlib.crc32(corrupted_data) & 0xFFFFFFFF
)
print("\n--- Проверка повреждённого пакета ---")
print(
f"CRC из пакета: "
f"0x{received_crc_after_corruption:08X}"
)
print(
f"CRC вычисленный: "
f"0x{calculated_crc_after_corruption:08X}"
)
if received_crc_after_corruption != calculated_crc_after_corruption:
print("\nРезультат: CRC обнаружил повреждение пакета.")
else:
print("\nРезультат: повреждение не обнаружено.")
# ============================================================
# Показываем повреждённое сообщение
# ============================================================
corrupted_payload = corrupted_data[header_size:]
corrupted_message = corrupted_payload.decode("utf-8")
print("\nСообщение внутри повреждённого пакета:")
print(corrupted_message)
# ============================================================
# Автоматические проверки
# ============================================================
assert corrupted_message == "ПРИВЕТ RDR"
assert (
received_crc_after_corruption
!= calculated_crc_after_corruption
)
print("\nПроверка пройдена: CRC-32 обнаружил изменение одного бита.")

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""
Created on Fri Jul 10 17:19:11 2026
@author: user
"""
"""
Lab004. Проверка модуля protocol.packet.
Проверяем:
1. Формирование пакета.
2. Разбор исправного пакета.
3. Восстановление текста.
4. Обнаружение повреждения CRC.
"""
from protocol.packet import (
CRCError,
HEADER_SIZE,
MESSAGE_TYPE_TEXT,
build_packet,
parse_packet,
)
# ============================================================
# Формирование пакета
# ============================================================
message = "ПРИВЕТ SDR"
payload = message.encode("utf-8")
sequence_number = 7
packet = build_packet(
payload=payload,
message_type=MESSAGE_TYPE_TEXT,
sequence_number=sequence_number,
)
print("Исходное сообщение:")
print(message)
print("\nСформированный пакет:")
print(packet.hex(" "))
print("\nРазмер пакета:")
print(len(packet), "байт")
# ============================================================
# Разбор исправного пакета
# ============================================================
parsed_packet = parse_packet(packet)
restored_message = parsed_packet.payload.decode("utf-8")
print("\n--- Разобранный пакет ---")
print("Версия:")
print(parsed_packet.version)
print("\nТип сообщения:")
print(parsed_packet.message_type)
print("\nПорядковый номер:")
print(parsed_packet.sequence_number)
print("\nПолезная нагрузка:")
print(parsed_packet.payload)
print("\nВосстановленное сообщение:")
print(restored_message)
assert parsed_packet.message_type == MESSAGE_TYPE_TEXT
assert parsed_packet.sequence_number == sequence_number
assert restored_message == message
print("\nИсправный пакет успешно разобран.")
# ============================================================
# Повреждение одного бита
# ============================================================
corrupted_packet = bytearray(packet)
# В строке "ПРИВЕТ SDR" буква S находится
# по индексу 13 внутри полезной нагрузки.
payload_byte_index = 13
packet_byte_index = (
HEADER_SIZE + payload_byte_index
)
corrupted_packet[packet_byte_index] ^= 0x01
print("\n--- Проверка повреждённого пакета ---")
try:
parse_packet(corrupted_packet)
except CRCError as error:
print("Повреждение обнаружено.")
print(error)
else:
raise AssertionError(
"Ошибка: повреждённый пакет был принят как исправный"
)
print("\nВсе проверки Lab004 успешно выполнены.")

View File

@@ -0,0 +1,254 @@
"""
Lab005. Передача пакетов через виртуальный радиоканал.
Эксперимент:
1. Формируем 100 цифровых пакетов.
2. Передаём их через виртуальный канал.
3. Канал может:
- полностью потерять пакет;
- изменить отдельные биты.
4. Приёмник проверяет структуру пакета и CRC-32.
5. Выводится статистика доставки.
"""
import random
from protocol.packet import (
CRCError,
MESSAGE_TYPE_TEXT,
PacketError,
build_packet,
parse_packet,
)
from tools.virtual_channel import transmit
# ============================================================
# Настройки эксперимента
# ============================================================
# Количество передаваемых пакетов.
PACKET_COUNT = 100
# Вероятность полной потери одного пакета.
#
# 0.10 означает 10 %.
DROP_PROBABILITY = 0.10
# Вероятность повреждения каждого отдельного бита.
#
# 0.002 означает:
# 0,2 % вероятности ошибки для каждого бита.
BIT_ERROR_PROBABILITY = 0.002
# Генератор случайных чисел.
#
# Число 2026 позволяет получать одинаковый результат
# при каждом запуске программы.
random_generator = random.Random(2026)
# ============================================================
# Счётчики результатов
# ============================================================
sent_packets = 0
# Пакеты, полностью исчезнувшие в канале.
dropped_packets = 0
# Доставленные, но повреждённые пакеты.
rejected_packets = 0
# Пакеты, правильно принятые и разобранные.
received_ok = 0
# Общее количество изменённых битов.
total_flipped_bits = 0
# ============================================================
# Передача серии пакетов
# ============================================================
for sequence_number in range(PACKET_COUNT):
# Создаём уникальное сообщение для каждого пакета.
message = f"ПАКЕТ {sequence_number}"
# Текст превращается в байты UTF-8.
payload = message.encode("utf-8")
# Формируем полный пакет с заголовком и CRC-32.
packet = build_packet(
payload=payload,
message_type=MESSAGE_TYPE_TEXT,
sequence_number=sequence_number,
)
sent_packets += 1
# Передаём пакет через виртуальный канал.
received_packet, flipped_bits = transmit(
packet=packet,
drop_probability=DROP_PROBABILITY,
bit_error_probability=BIT_ERROR_PROBABILITY,
random_generator=random_generator,
)
total_flipped_bits += flipped_bits
# None означает, что пакет полностью потерян.
if received_packet is None:
dropped_packets += 1
continue
# Пытаемся разобрать принятый пакет.
try:
parsed_packet = parse_packet(received_packet)
except (CRCError, PacketError):
# CRCError:
# контрольная сумма не совпала.
#
# PacketError:
# повреждены SYNC, версия, длина
# или другая часть структуры.
rejected_packets += 1
continue
# Восстанавливаем текст из полезной нагрузки.
restored_message = parsed_packet.payload.decode("utf-8")
# Проверяем, что получен именно тот пакет,
# который был передан.
assert parsed_packet.sequence_number == sequence_number
assert restored_message == message
received_ok += 1
# ============================================================
# Вывод настроек
# ============================================================
print("=== Lab005. Виртуальный канал ===")
print("\nНастройки эксперимента:")
print(
"Количество пакетов:",
PACKET_COUNT,
)
print(
"Вероятность полной потери пакета:",
DROP_PROBABILITY,
)
print(
"Вероятность ошибки отдельного бита:",
BIT_ERROR_PROBABILITY,
)
# ============================================================
# Вывод результатов
# ============================================================
print("\nРезультаты:")
print(
"Передано пакетов:",
sent_packets,
)
print(
"Потеряно полностью:",
dropped_packets,
)
print(
"Отклонено из-за повреждений:",
rejected_packets,
)
print(
"Принято правильно:",
received_ok,
)
print(
"Всего изменено битов:",
total_flipped_bits,
)
# ============================================================
# Расчёт процентов
# ============================================================
delivery_rate = (
received_ok
/ sent_packets
* 100
)
drop_rate = (
dropped_packets
/ sent_packets
* 100
)
rejection_rate = (
rejected_packets
/ sent_packets
* 100
)
packet_failure_rate = (
dropped_packets
+ rejected_packets
) / sent_packets * 100
print("\nСтатистика:")
print(
f"Успешно доставлено: "
f"{delivery_rate:.1f} %"
)
print(
f"Потеряно полностью: "
f"{drop_rate:.1f} %"
)
print(
f"Отклонено приёмником: "
f"{rejection_rate:.1f} %"
)
print(
f"Общая доля недоставленных пакетов: "
f"{packet_failure_rate:.1f} %"
)
# ============================================================
# Контроль правильности
# ============================================================
accounted_packets = (
received_ok
+ dropped_packets
+ rejected_packets
)
assert accounted_packets == sent_packets
print(
"\nПроверка пройдена: "
"все переданные пакеты учтены."
)

View File

@@ -0,0 +1,325 @@
"""
Lab006. Надёжная передача пакетов методом Stop-and-Wait ARQ.
Передатчик:
1. Отправляет пакет.
2. Ожидает подтверждение ACK.
3. При отсутствии правильного ACK повторяет передачу.
4. После исчерпания попыток считает сообщение недоставленным.
Приёмник:
1. Проверяет пакет.
2. Отбрасывает повреждённые пакеты.
3. Отправляет ACK для правильного пакета.
4. Не передаёт приложению повторные копии одного сообщения.
"""
import random
from protocol.packet import (
CRCError,
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_TEXT,
PacketError,
build_packet,
parse_packet,
)
from tools.virtual_channel import transmit
# ============================================================
# Настройки эксперимента
# ============================================================
MESSAGE_COUNT = 100
# Максимальное количество передач одного сообщения.
MAX_ATTEMPTS = 5
# Параметры канала от передатчика к приёмнику.
DATA_DROP_PROBABILITY = 0.10
DATA_BIT_ERROR_PROBABILITY = 0.002
# Параметры обратного канала для ACK.
ACK_DROP_PROBABILITY = 0.10
ACK_BIT_ERROR_PROBABILITY = 0.002
# Фиксированное число делает эксперимент повторяемым.
random_generator = random.Random(2026)
# ============================================================
# Счётчики
# ============================================================
delivered_messages = 0
failed_messages = 0
data_transmissions = 0
ack_transmissions = 0
duplicate_packets = 0
invalid_data_packets = 0
invalid_ack_packets = 0
dropped_data_packets = 0
dropped_ack_packets = 0
# Номера пакетов, уже переданных приложению приёмника.
received_sequences = set()
# ============================================================
# Передача сообщений
# ============================================================
for sequence_number in range(MESSAGE_COUNT):
message = f"ПАКЕТ {sequence_number}"
payload = message.encode("utf-8")
data_packet = build_packet(
payload=payload,
message_type=MESSAGE_TYPE_TEXT,
sequence_number=sequence_number,
)
message_confirmed = False
# Повторяем передачу до MAX_ATTEMPTS раз.
for attempt_number in range(1, MAX_ATTEMPTS + 1):
data_transmissions += 1
received_data_packet, _ = transmit(
packet=data_packet,
drop_probability=DATA_DROP_PROBABILITY,
bit_error_probability=DATA_BIT_ERROR_PROBABILITY,
random_generator=random_generator,
)
# Пакет полностью потерялся.
if received_data_packet is None:
dropped_data_packets += 1
continue
# Приёмник проверяет пакет.
try:
parsed_data = parse_packet(received_data_packet)
except (CRCError, PacketError):
invalid_data_packets += 1
continue
# Проверка типа сообщения.
if parsed_data.message_type != MESSAGE_TYPE_TEXT:
invalid_data_packets += 1
continue
# Проверяем, не получали ли мы этот пакет ранее.
if parsed_data.sequence_number in received_sequences:
duplicate_packets += 1
else:
restored_message = parsed_data.payload.decode("utf-8")
assert restored_message == message
received_sequences.add(
parsed_data.sequence_number
)
# Формируем подтверждение.
#
# ACK имеет тот же sequence_number,
# что и подтверждаемый пакет.
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=parsed_data.sequence_number,
)
ack_transmissions += 1
received_ack_packet, _ = transmit(
packet=ack_packet,
drop_probability=ACK_DROP_PROBABILITY,
bit_error_probability=ACK_BIT_ERROR_PROBABILITY,
random_generator=random_generator,
)
# ACK полностью потерялся.
if received_ack_packet is None:
dropped_ack_packets += 1
continue
# Передатчик проверяет ACK.
try:
parsed_ack = parse_packet(received_ack_packet)
except (CRCError, PacketError):
invalid_ack_packets += 1
continue
# ACK должен иметь правильный тип и номер.
if (
parsed_ack.message_type != MESSAGE_TYPE_ACK
or parsed_ack.sequence_number != sequence_number
):
invalid_ack_packets += 1
continue
# Подтверждение получено.
message_confirmed = True
break
if message_confirmed:
delivered_messages += 1
else:
failed_messages += 1
# ============================================================
# Расчёт статистики
# ============================================================
delivery_rate = (
delivered_messages
/ MESSAGE_COUNT
* 100
)
failure_rate = (
failed_messages
/ MESSAGE_COUNT
* 100
)
average_data_transmissions = (
data_transmissions
/ MESSAGE_COUNT
)
# ============================================================
# Вывод результата
# ============================================================
print("=== Lab006. Stop-and-Wait ARQ ===")
print("\nНастройки:")
print("Количество сообщений:", MESSAGE_COUNT)
print("Максимум попыток:", MAX_ATTEMPTS)
print(
"Вероятность потери DATA:",
DATA_DROP_PROBABILITY,
)
print(
"Вероятность битовой ошибки DATA:",
DATA_BIT_ERROR_PROBABILITY,
)
print(
"Вероятность потери ACK:",
ACK_DROP_PROBABILITY,
)
print(
"Вероятность битовой ошибки ACK:",
ACK_BIT_ERROR_PROBABILITY,
)
print("\nОсновные результаты:")
print(
"Успешно подтверждено сообщений:",
delivered_messages,
)
print(
"Не подтверждено сообщений:",
failed_messages,
)
print(
f"Доля успешной доставки: "
f"{delivery_rate:.1f} %"
)
print(
f"Доля недоставленных сообщений: "
f"{failure_rate:.1f} %"
)
print("\nПередачи:")
print(
"Всего передач DATA:",
data_transmissions,
)
print(
"Всего передач ACK:",
ack_transmissions,
)
print(
f"Среднее число передач DATA "
f"на одно сообщение: "
f"{average_data_transmissions:.2f}"
)
print("\nОшибки канала:")
print(
"Полностью потеряно DATA:",
dropped_data_packets,
)
print(
"Повреждено DATA:",
invalid_data_packets,
)
print(
"Полностью потеряно ACK:",
dropped_ack_packets,
)
print(
"Повреждено ACK:",
invalid_ack_packets,
)
print(
"Получено повторных DATA-пакетов:",
duplicate_packets,
)
# ============================================================
# Автоматические проверки
# ============================================================
assert (
delivered_messages
+ failed_messages
== MESSAGE_COUNT
)
assert delivered_messages <= len(received_sequences)
assert data_transmissions >= MESSAGE_COUNT
print(
"\nПроверка пройдена: "
"все сообщения учтены."
)

View File

@@ -0,0 +1,270 @@
"""
Lab007. Разбиение JPEG на фрагменты и обратная сборка.
Этапы:
1. Создание тестового JPEG.
2. Чтение файла как последовательности байтов.
3. Разбиение изображения на фрагменты.
4. Формирование цифрового пакета для каждого фрагмента.
5. Перемешивание порядка доставки пакетов.
6. Проверка CRC и разбор пакетов.
7. Обратная сборка изображения.
8. Сравнение контрольных сумм файлов.
"""
from hashlib import sha256
from pathlib import Path
import random
from PIL import Image
from protocol.image_fragments import (
decode_image_fragment,
encode_image_fragment,
reassemble_image,
split_image_bytes,
)
from protocol.packet import (
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
parse_packet,
)
# ============================================================
# Настройки
# ============================================================
IMAGE_WIDTH = 320
IMAGE_HEIGHT = 240
IMAGE_ID = 20260713
FRAGMENT_DATA_SIZE = 512
random_generator = random.Random(2026)
# ============================================================
# Пути к файлам
# ============================================================
source_directory = Path("data/raw")
received_directory = Path("data/received")
source_directory.mkdir(
parents=True,
exist_ok=True,
)
received_directory.mkdir(
parents=True,
exist_ok=True,
)
source_path = (
source_directory
/ "lab007_source.jpg"
)
received_path = (
received_directory
/ "lab007_reassembled.jpg"
)
# ============================================================
# Создание тестового JPEG
# ============================================================
image = Image.new(
"RGB",
(IMAGE_WIDTH, IMAGE_HEIGHT),
)
pixels = image.load()
for y in range(IMAGE_HEIGHT):
for x in range(IMAGE_WIDTH):
red = (
x * 255
// (IMAGE_WIDTH - 1)
)
green = (
y * 255
// (IMAGE_HEIGHT - 1)
)
blue = (
(x + y) * 255
// (
IMAGE_WIDTH
+ IMAGE_HEIGHT
- 2
)
)
pixels[x, y] = (
red,
green,
blue,
)
image.save(
source_path,
format="JPEG",
quality=60,
optimize=True,
)
# ============================================================
# Чтение изображения
# ============================================================
source_bytes = source_path.read_bytes()
source_hash = sha256(
source_bytes
).hexdigest()
# ============================================================
# Разбиение изображения на фрагменты
# ============================================================
fragments = split_image_bytes(
image_bytes=source_bytes,
image_id=IMAGE_ID,
fragment_data_size=FRAGMENT_DATA_SIZE,
)
# ============================================================
# Формирование цифровых пакетов
# ============================================================
packets = []
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
packet = build_packet(
payload=fragment_payload,
message_type=MESSAGE_TYPE_IMAGE_FRAGMENT,
sequence_number=fragment.fragment_index,
)
packets.append(packet)
# ============================================================
# Имитация доставки в произвольном порядке
# ============================================================
random_generator.shuffle(packets)
received_fragments = []
for packet in packets:
parsed_packet = parse_packet(packet)
assert (
parsed_packet.message_type
== MESSAGE_TYPE_IMAGE_FRAGMENT
)
fragment = decode_image_fragment(
parsed_packet.payload
)
assert (
parsed_packet.sequence_number
== fragment.fragment_index
)
received_fragments.append(fragment)
# ============================================================
# Сборка изображения
# ============================================================
received_bytes = reassemble_image(
received_fragments
)
received_path.write_bytes(
received_bytes
)
received_hash = sha256(
received_bytes
).hexdigest()
# ============================================================
# Проверки
# ============================================================
assert received_bytes == source_bytes
assert received_hash == source_hash
with Image.open(received_path) as received_image:
received_image.verify()
# ============================================================
# Результаты
# ============================================================
print("=== Lab007. Фрагментация изображения ===")
print("\nИсходный файл:")
print(source_path)
print("\nВосстановленный файл:")
print(received_path)
print("\nРазмер изображения:")
print(
IMAGE_WIDTH,
"x",
IMAGE_HEIGHT,
"пикселей",
)
print("\nРазмер JPEG:")
print(
len(source_bytes),
"байт",
)
print("\nРазмер данных одного фрагмента:")
print(
FRAGMENT_DATA_SIZE,
"байт",
)
print("\nКоличество фрагментов:")
print(
len(fragments)
)
print("\nSHA-256 исходного файла:")
print(source_hash)
print("\nSHA-256 восстановленного файла:")
print(received_hash)
print(
"\nПроверка пройдена: "
"JPEG восстановлен байт в байт."
)

View File

@@ -0,0 +1,428 @@
"""
Lab008. Передача JPEG через виртуальный канал с ARQ.
Этапы:
1. Чтение тестового JPEG из Lab007.
2. Разбиение файла на фрагменты.
3. Передача каждого фрагмента через виртуальный канал.
4. Проверка CRC на приёмнике.
5. Передача ACK по обратному каналу.
6. Повторная передача при потере или повреждении.
7. Сборка принятого JPEG.
8. Сравнение SHA-256 исходного и принятого файлов.
"""
from hashlib import sha256
from pathlib import Path
import random
from PIL import Image
from protocol.image_fragments import (
ImageFragmentError,
decode_image_fragment,
encode_image_fragment,
reassemble_image,
split_image_bytes,
)
from protocol.packet import (
CRCError,
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
PacketError,
build_packet,
parse_packet,
)
from tools.virtual_channel import transmit
# ============================================================
# Настройки эксперимента
# ============================================================
IMAGE_ID = 20260713
FRAGMENT_DATA_SIZE = 512
# Максимальное количество передач одного фрагмента.
MAX_ATTEMPTS = 12
# Прямой канал: передатчик → приёмник.
DATA_DROP_PROBABILITY = 0.10
DATA_BIT_ERROR_PROBABILITY = 0.0002
# Обратный канал: приёмник → передатчик.
ACK_DROP_PROBABILITY = 0.10
ACK_BIT_ERROR_PROBABILITY = 0.0002
# Фиксированное значение делает результат воспроизводимым.
random_generator = random.Random(2026)
# ============================================================
# Пути к файлам
# ============================================================
source_path = Path(
"data/raw/lab007_source.jpg"
)
received_path = Path(
"data/received/lab008_received.jpg"
)
if not source_path.exists():
raise FileNotFoundError(
"Не найден файл data/raw/lab007_source.jpg. "
"Сначала необходимо выполнить Lab007."
)
received_path.parent.mkdir(
parents=True,
exist_ok=True,
)
# ============================================================
# Чтение и разбиение изображения
# ============================================================
source_bytes = source_path.read_bytes()
source_hash = sha256(
source_bytes
).hexdigest()
source_fragments = split_image_bytes(
image_bytes=source_bytes,
image_id=IMAGE_ID,
fragment_data_size=FRAGMENT_DATA_SIZE,
)
# ============================================================
# Счётчики
# ============================================================
data_transmissions = 0
ack_transmissions = 0
dropped_data_packets = 0
invalid_data_packets = 0
dropped_ack_packets = 0
invalid_ack_packets = 0
duplicate_data_packets = 0
flipped_data_bits = 0
flipped_ack_bits = 0
confirmed_fragments = 0
failed_fragment_indexes = []
# Принятые фрагменты хранятся по их номеру.
received_fragments = {}
# Число попыток передачи каждого фрагмента.
attempts_per_fragment = {}
# ============================================================
# Передача всех фрагментов
# ============================================================
for source_fragment in source_fragments:
fragment_index = source_fragment.fragment_index
fragment_payload = encode_image_fragment(
source_fragment
)
data_packet = build_packet(
payload=fragment_payload,
message_type=MESSAGE_TYPE_IMAGE_FRAGMENT,
sequence_number=fragment_index,
)
fragment_confirmed = False
for attempt_number in range(
1,
MAX_ATTEMPTS + 1,
):
data_transmissions += 1
received_data_packet, changed_bits = transmit(
packet=data_packet,
drop_probability=DATA_DROP_PROBABILITY,
bit_error_probability=DATA_BIT_ERROR_PROBABILITY,
random_generator=random_generator,
)
flipped_data_bits += changed_bits
# DATA полностью потерян.
if received_data_packet is None:
dropped_data_packets += 1
continue
# Проверка структуры и CRC.
try:
parsed_data_packet = parse_packet(
received_data_packet
)
except (CRCError, PacketError):
invalid_data_packets += 1
continue
if (
parsed_data_packet.message_type
!= MESSAGE_TYPE_IMAGE_FRAGMENT
):
invalid_data_packets += 1
continue
# Разбор заголовка фрагмента изображения.
try:
received_fragment = decode_image_fragment(
parsed_data_packet.payload
)
except ImageFragmentError:
invalid_data_packets += 1
continue
# Проверяем согласованность номеров.
if (
parsed_data_packet.sequence_number
!= received_fragment.fragment_index
):
invalid_data_packets += 1
continue
if received_fragment.image_id != IMAGE_ID:
invalid_data_packets += 1
continue
# Сохраняем новый фрагмент либо распознаём дубликат.
existing_fragment = received_fragments.get(
received_fragment.fragment_index
)
if existing_fragment is None:
received_fragments[
received_fragment.fragment_index
] = received_fragment
else:
# DATA мог прийти повторно, если предыдущий ACK потерялся.
assert existing_fragment.data == received_fragment.data
duplicate_data_packets += 1
# ====================================================
# Формирование ACK
# ====================================================
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=received_fragment.fragment_index,
)
ack_transmissions += 1
received_ack_packet, changed_ack_bits = transmit(
packet=ack_packet,
drop_probability=ACK_DROP_PROBABILITY,
bit_error_probability=ACK_BIT_ERROR_PROBABILITY,
random_generator=random_generator,
)
flipped_ack_bits += changed_ack_bits
# ACK полностью потерян.
if received_ack_packet is None:
dropped_ack_packets += 1
continue
# Передатчик проверяет ACK.
try:
parsed_ack = parse_packet(
received_ack_packet
)
except (CRCError, PacketError):
invalid_ack_packets += 1
continue
if (
parsed_ack.message_type != MESSAGE_TYPE_ACK
or parsed_ack.sequence_number != fragment_index
or parsed_ack.payload != b""
):
invalid_ack_packets += 1
continue
# Фрагмент успешно подтверждён.
fragment_confirmed = True
confirmed_fragments += 1
attempts_per_fragment[
fragment_index
] = attempt_number
break
if not fragment_confirmed:
failed_fragment_indexes.append(
fragment_index
)
attempts_per_fragment[
fragment_index
] = MAX_ATTEMPTS
# ============================================================
# Результаты передачи
# ============================================================
print("=== Lab008. Передача JPEG с ARQ ===")
print("\nИсходный файл:")
print(source_path)
print("\nРазмер JPEG:")
print(len(source_bytes), "байт")
print("\nКоличество фрагментов:")
print(len(source_fragments))
print("\nПодтверждено фрагментов:")
print(confirmed_fragments)
print("\nПолучено уникальных фрагментов:")
print(len(received_fragments))
print("\nНе подтверждены фрагменты:")
print(failed_fragment_indexes)
print("\nПередачи:")
print(
"Всего передач DATA:",
data_transmissions,
)
print(
"Всего передач ACK:",
ack_transmissions,
)
print("\nОшибки прямого канала:")
print(
"Полностью потеряно DATA:",
dropped_data_packets,
)
print(
"Повреждено DATA:",
invalid_data_packets,
)
print(
"Изменено битов DATA:",
flipped_data_bits,
)
print("\nОшибки обратного канала:")
print(
"Полностью потеряно ACK:",
dropped_ack_packets,
)
print(
"Повреждено ACK:",
invalid_ack_packets,
)
print(
"Изменено битов ACK:",
flipped_ack_bits,
)
print(
"Получено повторных DATA:",
duplicate_data_packets,
)
print("\nКоличество попыток по фрагментам:")
for fragment_index in sorted(
attempts_per_fragment
):
print(
f"Фрагмент {fragment_index}: "
f"{attempts_per_fragment[fragment_index]}"
)
# ============================================================
# Сборка принятого изображения
# ============================================================
assert not failed_fragment_indexes
assert (
len(received_fragments)
== len(source_fragments)
)
received_bytes = reassemble_image(
received_fragments.values()
)
received_path.write_bytes(
received_bytes
)
received_hash = sha256(
received_bytes
).hexdigest()
# ============================================================
# Финальные проверки
# ============================================================
assert received_bytes == source_bytes
assert received_hash == source_hash
with Image.open(received_path) as image:
image.verify()
print("\nSHA-256 исходного JPEG:")
print(source_hash)
print("\nSHA-256 принятого JPEG:")
print(received_hash)
print("\nПринятый файл:")
print(received_path)
print(
"\nПроверка пройдена: "
"все фрагменты подтверждены, "
"JPEG восстановлен байт в байт."
)

View File

@@ -0,0 +1,450 @@
"""
Lab009. Расчёт параметров передачи реальной фотографии.
Программа:
1. Читает реальный JPEG-файл.
2. Определяет его разрешение и размер.
3. Разбивает файл на фрагменты.
4. Формирует реальные пакеты протокола.
5. Учитывает ACK для каждого фрагмента.
6. Рассчитывает время передачи при разных скоростях канала.
Расчёт времени показывает чистое эфирное время без учёта:
- пауз между пакетами;
- обработки на передатчике и приёмнике;
- ожидания ACK;
- повторных передач;
- преамбулы и служебных символов физического уровня.
"""
from pathlib import Path
from PIL import Image
from protocol.image_fragments import (
FRAGMENT_HEADER_SIZE,
encode_image_fragment,
split_image_bytes,
)
from protocol.packet import (
CRC_SIZE,
HEADER_SIZE,
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
)
# ============================================================
# Настройки
# ============================================================
SOURCE_PATH = Path(
"data/raw/lab009_source.jpg"
)
IMAGE_ID = 20260713
# Сколько байтов JPEG помещается в один фрагмент.
FRAGMENT_DATA_SIZE = 512
# Скорости полезного цифрового канала в кбит/с.
BITRATES_KBPS = [
1,
5,
10,
20,
50,
100,
200,
]
# ============================================================
# Вспомогательные функции
# ============================================================
def format_bytes(byte_count: int) -> str:
"""
Представить размер одновременно в байтах и килобайтах.
"""
kibibytes = byte_count / 1024
return (
f"{byte_count} байт "
f"({kibibytes:.2f} КиБ)"
)
def format_duration(seconds: float) -> str:
"""
Представить длительность в удобной форме.
"""
if seconds < 1:
return f"{seconds * 1000:.1f} мс"
if seconds < 60:
return f"{seconds:.2f} с"
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return (
f"{minutes} мин "
f"{remaining_seconds:.1f} с"
)
# ============================================================
# Проверка исходного файла
# ============================================================
if not SOURCE_PATH.exists():
raise FileNotFoundError(
f"Не найден файл: {SOURCE_PATH}"
)
source_bytes = SOURCE_PATH.read_bytes()
if not source_bytes:
raise ValueError(
"Исходный JPEG-файл пуст"
)
# ============================================================
# Чтение параметров изображения
# ============================================================
with Image.open(SOURCE_PATH) as image:
image_format = image.format
image_mode = image.mode
image_width, image_height = image.size
# Проверяем, что файл действительно читается как изображение.
image.verify()
# ============================================================
# Разбиение JPEG на фрагменты
# ============================================================
fragments = split_image_bytes(
image_bytes=source_bytes,
image_id=IMAGE_ID,
fragment_data_size=FRAGMENT_DATA_SIZE,
)
# ============================================================
# Формирование DATA-пакетов
# ============================================================
data_packets = []
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
packet = build_packet(
payload=fragment_payload,
message_type=MESSAGE_TYPE_IMAGE_FRAGMENT,
sequence_number=fragment.fragment_index,
)
data_packets.append(packet)
# ============================================================
# Формирование типового ACK-пакета
# ============================================================
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=0,
)
ack_packet_size = len(ack_packet)
# ============================================================
# Расчёт объёмов
# ============================================================
source_size = len(source_bytes)
fragment_count = len(fragments)
# Полный размер всех DATA-пакетов.
total_data_packet_bytes = sum(
len(packet)
for packet in data_packets
)
# Для каждого DATA-пакета ожидается один ACK.
total_ack_bytes = (
fragment_count
* ack_packet_size
)
# Идеальный радиообмен:
# каждый DATA и каждый ACK переданы ровно один раз.
total_radio_bytes = (
total_data_packet_bytes
+ total_ack_bytes
)
total_radio_bits = (
total_radio_bytes
* 8
)
# ============================================================
# Детализация служебных расходов
# ============================================================
total_fragment_header_bytes = (
fragment_count
* FRAGMENT_HEADER_SIZE
)
data_protocol_overhead = (
fragment_count
* (HEADER_SIZE + CRC_SIZE)
)
total_overhead_bytes = (
total_radio_bytes
- source_size
)
payload_efficiency = (
source_size
/ total_radio_bytes
* 100
)
# ============================================================
# Вывод параметров фотографии
# ============================================================
print("=== Lab009. Расчёт передачи реальной фотографии ===")
print("\nИсходный файл:")
print(SOURCE_PATH)
print("\nФормат изображения:")
print(image_format)
print("\nЦветовой режим:")
print(image_mode)
print("\nРазрешение:")
print(
image_width,
"x",
image_height,
"пикселей",
)
print("\nРазмер исходного JPEG:")
print(
format_bytes(source_size)
)
# ============================================================
# Вывод структуры передачи
# ============================================================
print("\n--- Фрагментация ---")
print(
"Размер данных одного полного фрагмента:",
FRAGMENT_DATA_SIZE,
"байт",
)
print(
"Количество фрагментов:",
fragment_count,
)
print(
"Размер заголовка фрагмента:",
FRAGMENT_HEADER_SIZE,
"байт",
)
print(
"Размер заголовка основного пакета:",
HEADER_SIZE,
"байт",
)
print(
"Размер CRC-32:",
CRC_SIZE,
"байт",
)
print(
"Размер ACK-пакета:",
ack_packet_size,
"байт",
)
# ============================================================
# Вывод полного объёма
# ============================================================
print("\n--- Объём передачи без повторов ---")
print("Исходные данные JPEG:")
print(
format_bytes(source_size)
)
print("\nЗаголовки фрагментов:")
print(
format_bytes(total_fragment_header_bytes)
)
print("\nСлужебные поля и CRC DATA-пакетов:")
print(
format_bytes(data_protocol_overhead)
)
print("\nВсе DATA-пакеты:")
print(
format_bytes(total_data_packet_bytes)
)
print("\nВсе ACK-пакеты:")
print(
format_bytes(total_ack_bytes)
)
print("\nПолный радиообмен DATA + ACK:")
print(
format_bytes(total_radio_bytes)
)
print("\nОбщие служебные расходы:")
print(
format_bytes(total_overhead_bytes)
)
print(
"\nПолезная эффективность протокола:"
)
print(
f"{payload_efficiency:.2f} %"
)
# ============================================================
# Расчёт времени передачи
# ============================================================
print("\n--- Чистое эфирное время ---")
print(
"Скорость DATA без ACK DATA + ACK"
)
for bitrate_kbps in BITRATES_KBPS:
bitrate_bits_per_second = (
bitrate_kbps
* 1000
)
data_time_seconds = (
total_data_packet_bytes
* 8
/ bitrate_bits_per_second
)
full_time_seconds = (
total_radio_bits
/ bitrate_bits_per_second
)
print(
f"{bitrate_kbps:>4} кбит/с"
f" "
f"{format_duration(data_time_seconds):>12}"
f" "
f"{format_duration(full_time_seconds):>12}"
)
# ============================================================
# Сценарий с повторами
# ============================================================
# Коэффициент 2 означает, что суммарно передано
# примерно в два раза больше байтов из-за ошибок,
# потерь и повторных передач.
RETRY_TRAFFIC_MULTIPLIER = 2.0
print(
"\n--- Оценка при удвоении трафика из-за повторов ---"
)
for bitrate_kbps in BITRATES_KBPS:
bitrate_bits_per_second = (
bitrate_kbps
* 1000
)
estimated_seconds = (
total_radio_bits
* RETRY_TRAFFIC_MULTIPLIER
/ bitrate_bits_per_second
)
print(
f"{bitrate_kbps:>4} кбит/с"
f" "
f"{format_duration(estimated_seconds)}"
)
# ============================================================
# Автоматические проверки
# ============================================================
assert fragment_count > 0
assert (
total_data_packet_bytes
> source_size
)
assert (
total_radio_bytes
> total_data_packet_bytes
)
assert (
0 < payload_efficiency < 100
)
print(
"\nПроверка пройдена: "
"параметры передачи фотографии рассчитаны."
)

View File

@@ -0,0 +1,785 @@
"""
Lab010. Оптимизация изображения для слабого радиоканала.
Программа формирует несколько вариантов одного кадра:
1. Исходный JPEG.
2. Цветной кадр до 640x480.
3. Серый кадр до 640x480.
4. Серый кадр до 320x240, JPEG quality 30.
5. Серый кадр до 320x240, JPEG quality 15.
6. Карта контуров 320x240.
7. Серый кадр с наложенными контурами.
Для каждого варианта рассчитываются:
- размер JPEG;
- число фрагментов;
- полный объём DATA + ACK;
- эффективность протокола;
- время передачи на нескольких скоростях;
- время при удвоении трафика из-за повторов.
"""
from csv import DictWriter
from hashlib import sha256
from math import ceil
from pathlib import Path
from PIL import (
Image,
ImageDraw,
ImageFilter,
ImageOps,
)
from protocol.image_fragments import (
encode_image_fragment,
split_image_bytes,
)
from protocol.packet import (
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
)
# ============================================================
# Настройки
# ============================================================
SOURCE_PATH = Path(
"data/raw/lab009_source.jpg"
)
OUTPUT_DIRECTORY = Path(
"data/processed/lab010"
)
FRAGMENT_DATA_SIZE = 512
IMAGE_ID_BASE = 2026071300
BITRATES_KBPS = [
5,
10,
20,
50,
100,
]
# ============================================================
# Вспомогательные функции
# ============================================================
def resize_inside(
image: Image.Image,
maximum_size: tuple[int, int],
) -> Image.Image:
"""
Уменьшить изображение с сохранением пропорций.
Изображение не растягивается и не искажается.
"""
resized_image = image.copy()
resized_image.thumbnail(
maximum_size,
Image.Resampling.LANCZOS,
)
return resized_image
def save_jpeg(
image: Image.Image,
output_path: Path,
quality: int,
) -> None:
"""
Сохранить изображение в JPEG.
"""
output_path.parent.mkdir(
parents=True,
exist_ok=True,
)
image.save(
output_path,
format="JPEG",
quality=quality,
optimize=True,
)
def format_bytes(byte_count: int) -> str:
"""
Представить размер в КиБ.
"""
return f"{byte_count / 1024:.2f} КиБ"
def format_duration(seconds: float) -> str:
"""
Представить время в удобной форме.
"""
if seconds < 1:
return f"{seconds * 1000:.0f} мс"
if seconds < 60:
return f"{seconds:.2f} с"
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return (
f"{minutes} мин "
f"{remaining_seconds:.1f} с"
)
def estimate_transfer(
file_path: Path,
image_id: int,
) -> dict:
"""
Рассчитать параметры передачи одного JPEG.
"""
image_bytes = file_path.read_bytes()
fragments = split_image_bytes(
image_bytes=image_bytes,
image_id=image_id,
fragment_data_size=FRAGMENT_DATA_SIZE,
)
total_data_packet_bytes = 0
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
packet = build_packet(
payload=fragment_payload,
message_type=MESSAGE_TYPE_IMAGE_FRAGMENT,
sequence_number=fragment.fragment_index,
)
total_data_packet_bytes += len(packet)
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=0,
)
total_ack_bytes = (
len(fragments)
* len(ack_packet)
)
total_radio_bytes = (
total_data_packet_bytes
+ total_ack_bytes
)
efficiency_percent = (
len(image_bytes)
/ total_radio_bytes
* 100
)
transfer_times = {}
for bitrate_kbps in BITRATES_KBPS:
bitrate_bits_per_second = (
bitrate_kbps * 1000
)
transfer_times[bitrate_kbps] = (
total_radio_bytes
* 8
/ bitrate_bits_per_second
)
return {
"file_size_bytes": len(image_bytes),
"fragment_count": len(fragments),
"data_packet_bytes": total_data_packet_bytes,
"ack_bytes": total_ack_bytes,
"total_radio_bytes": total_radio_bytes,
"efficiency_percent": efficiency_percent,
"transfer_times": transfer_times,
"sha256": sha256(image_bytes).hexdigest(),
}
# ============================================================
# Проверка исходной фотографии
# ============================================================
if not SOURCE_PATH.exists():
raise FileNotFoundError(
f"Не найден исходный файл: {SOURCE_PATH}"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
# ============================================================
# Чтение исходного изображения
# ============================================================
with Image.open(SOURCE_PATH) as source_image:
source_image.load()
source_rgb = source_image.convert("RGB")
original_width, original_height = (
source_rgb.size
)
# ============================================================
# Формирование вариантов изображения
# ============================================================
variant_paths = []
# ------------------------------------------------------------
# Вариант 0. Исходный JPEG без изменения
# ------------------------------------------------------------
variant_paths.append(
(
"00_original",
SOURCE_PATH,
)
)
# ------------------------------------------------------------
# Вариант 1. Цветной кадр до 640x480, quality 40
# ------------------------------------------------------------
color_640 = resize_inside(
source_rgb,
(640, 480),
)
color_640_path = (
OUTPUT_DIRECTORY
/ "01_color_640_q40.jpg"
)
save_jpeg(
color_640,
color_640_path,
quality=40,
)
variant_paths.append(
(
"01_color_640_q40",
color_640_path,
)
)
# ------------------------------------------------------------
# Вариант 2. Серый кадр до 640x480, quality 40
# ------------------------------------------------------------
gray_640 = ImageOps.grayscale(
color_640
)
gray_640_path = (
OUTPUT_DIRECTORY
/ "02_gray_640_q40.jpg"
)
save_jpeg(
gray_640,
gray_640_path,
quality=40,
)
variant_paths.append(
(
"02_gray_640_q40",
gray_640_path,
)
)
# ------------------------------------------------------------
# Вариант 3. Серый кадр до 320x240, quality 30
# ------------------------------------------------------------
color_320 = resize_inside(
source_rgb,
(320, 240),
)
gray_320 = ImageOps.grayscale(
color_320
)
gray_320_q30_path = (
OUTPUT_DIRECTORY
/ "03_gray_320_q30.jpg"
)
save_jpeg(
gray_320,
gray_320_q30_path,
quality=30,
)
variant_paths.append(
(
"03_gray_320_q30",
gray_320_q30_path,
)
)
# ------------------------------------------------------------
# Вариант 4. Серый кадр до 320x240, quality 15
# ------------------------------------------------------------
gray_320_q15_path = (
OUTPUT_DIRECTORY
/ "04_gray_320_q15.jpg"
)
save_jpeg(
gray_320,
gray_320_q15_path,
quality=15,
)
variant_paths.append(
(
"04_gray_320_q15",
gray_320_q15_path,
)
)
# ------------------------------------------------------------
# Вариант 5. Только контуры
# ------------------------------------------------------------
edge_map = gray_320.filter(
ImageFilter.FIND_EDGES
)
edge_map = ImageOps.autocontrast(
edge_map
)
# Инвертируем: белый фон, тёмные контуры.
edge_map = ImageOps.invert(
edge_map
)
edges_path = (
OUTPUT_DIRECTORY
/ "05_edges_320_q40.jpg"
)
save_jpeg(
edge_map,
edges_path,
quality=40,
)
variant_paths.append(
(
"05_edges_320_q40",
edges_path,
)
)
# ------------------------------------------------------------
# Вариант 6. Серый кадр с усиленными контурами
# ------------------------------------------------------------
gray_edges_overlay = Image.blend(
gray_320,
edge_map,
alpha=0.25,
)
gray_edges_path = (
OUTPUT_DIRECTORY
/ "06_gray_edges_320_q30.jpg"
)
save_jpeg(
gray_edges_overlay,
gray_edges_path,
quality=30,
)
variant_paths.append(
(
"06_gray_edges_320_q30",
gray_edges_path,
)
)
# ============================================================
# Расчёт параметров всех вариантов
# ============================================================
results = []
for variant_number, (
variant_name,
variant_path,
) in enumerate(variant_paths):
with Image.open(variant_path) as variant_image:
width, height = variant_image.size
mode = variant_image.mode
transfer_result = estimate_transfer(
file_path=variant_path,
image_id=IMAGE_ID_BASE + variant_number,
)
result = {
"name": variant_name,
"path": str(variant_path),
"width": width,
"height": height,
"mode": mode,
**transfer_result,
}
results.append(result)
# ============================================================
# Создание сравнительной картинки
# ============================================================
CONTACT_SHEET_COLUMNS = 3
CONTACT_SHEET_CELL_WIDTH = 360
CONTACT_SHEET_CELL_HEIGHT = 290
contact_sheet_rows = ceil(
len(results)
/ CONTACT_SHEET_COLUMNS
)
contact_sheet = Image.new(
"RGB",
(
CONTACT_SHEET_COLUMNS
* CONTACT_SHEET_CELL_WIDTH,
contact_sheet_rows
* CONTACT_SHEET_CELL_HEIGHT,
),
"white",
)
draw = ImageDraw.Draw(
contact_sheet
)
for result_index, result in enumerate(results):
column = (
result_index
% CONTACT_SHEET_COLUMNS
)
row = (
result_index
// CONTACT_SHEET_COLUMNS
)
cell_x = (
column
* CONTACT_SHEET_CELL_WIDTH
)
cell_y = (
row
* CONTACT_SHEET_CELL_HEIGHT
)
with Image.open(result["path"]) as variant_image:
preview = variant_image.convert("RGB")
preview.thumbnail(
(330, 220),
Image.Resampling.LANCZOS,
)
paste_x = (
cell_x
+ (
CONTACT_SHEET_CELL_WIDTH
- preview.width
)
// 2
)
paste_y = (
cell_y + 35
)
contact_sheet.paste(
preview,
(paste_x, paste_y),
)
label = (
f"{result['name']}\n"
f"{format_bytes(result['file_size_bytes'])}, "
f"{result['fragment_count']} fragments"
)
draw.text(
(cell_x + 10, cell_y + 8),
label,
fill="black",
)
contact_sheet_path = (
OUTPUT_DIRECTORY
/ "lab010_comparison.jpg"
)
contact_sheet.save(
contact_sheet_path,
format="JPEG",
quality=90,
)
# ============================================================
# Сохранение таблицы CSV
# ============================================================
csv_path = (
OUTPUT_DIRECTORY
/ "lab010_results.csv"
)
csv_fieldnames = [
"name",
"path",
"width",
"height",
"mode",
"file_size_bytes",
"fragment_count",
"total_radio_bytes",
"efficiency_percent",
"time_5_kbps",
"time_10_kbps",
"time_20_kbps",
"time_50_kbps",
"time_100_kbps",
"time_20_kbps_with_retries_x2",
]
with csv_path.open(
"w",
newline="",
encoding="utf-8-sig",
) as csv_file:
writer = DictWriter(
csv_file,
fieldnames=csv_fieldnames,
)
writer.writeheader()
for result in results:
writer.writerow(
{
"name": result["name"],
"path": result["path"],
"width": result["width"],
"height": result["height"],
"mode": result["mode"],
"file_size_bytes": (
result["file_size_bytes"]
),
"fragment_count": (
result["fragment_count"]
),
"total_radio_bytes": (
result["total_radio_bytes"]
),
"efficiency_percent": (
f"{result['efficiency_percent']:.2f}"
),
"time_5_kbps": (
result["transfer_times"][5]
),
"time_10_kbps": (
result["transfer_times"][10]
),
"time_20_kbps": (
result["transfer_times"][20]
),
"time_50_kbps": (
result["transfer_times"][50]
),
"time_100_kbps": (
result["transfer_times"][100]
),
"time_20_kbps_with_retries_x2": (
result["transfer_times"][20]
* 2
),
}
)
# ============================================================
# Вывод результатов
# ============================================================
print(
"=== Lab010. Оптимизация изображения ==="
)
print("\nИсходное разрешение:")
print(
original_width,
"x",
original_height,
)
print("\nСравнение вариантов:")
print(
f"{'Вариант':<28}"
f"{'Размер':>12}"
f"{'Фрагм.':>9}"
f"{'10 кбит/с':>13}"
f"{'20 кбит/с':>13}"
f"{'50 кбит/с':>13}"
f"{'20 кбит/с x2':>16}"
)
print("-" * 104)
for result in results:
print(
f"{result['name']:<28}"
f"{format_bytes(result['file_size_bytes']):>12}"
f"{result['fragment_count']:>9}"
f"{format_duration(result['transfer_times'][10]):>13}"
f"{format_duration(result['transfer_times'][20]):>13}"
f"{format_duration(result['transfer_times'][50]):>13}"
f"{format_duration(result['transfer_times'][20] * 2):>16}"
)
# ============================================================
# Итоговые кандидаты
# ============================================================
smallest_result = min(
results,
key=lambda item: item["file_size_bytes"],
)
operator_candidate = next(
result
for result in results
if result["name"] == "03_gray_320_q30"
)
print("\nСамый маленький файл:")
print(
smallest_result["name"],
"-",
format_bytes(
smallest_result["file_size_bytes"]
),
)
print("\nБазовый кандидат для оператора:")
print(
operator_candidate["name"],
"-",
format_bytes(
operator_candidate["file_size_bytes"]
),
"-",
format_duration(
operator_candidate[
"transfer_times"
][20]
),
"при 20 кбит/с без повторов",
)
print("\nСравнительная картинка:")
print(contact_sheet_path)
print("\nТаблица результатов CSV:")
print(csv_path)
# ============================================================
# Проверки
# ============================================================
assert len(results) == 7
assert contact_sheet_path.exists()
assert csv_path.exists()
assert all(
result["fragment_count"] > 0
for result in results
)
print(
"\nПроверка пройдена: "
"варианты кадра созданы и рассчитаны."
)

View File

@@ -0,0 +1,525 @@
"""
Lab011. Проверка облегчённых режимов изображения
на сцене, похожей на вид с камеры ровера.
Создаются варианты:
1. Grayscale 320x240, JPEG quality 30.
2. Grayscale 320x240, JPEG quality 15.
3. Grayscale 320x240 с наложением контуров, quality 30.
Для каждого варианта рассчитываются:
- размер JPEG;
- количество радиопакетов;
- время передачи при разных скоростях.
"""
from math import ceil
from pathlib import Path
from PIL import (
Image,
ImageChops,
ImageDraw,
ImageFilter,
ImageOps,
)
from protocol.image_fragments import (
encode_image_fragment,
split_image_bytes,
)
from protocol.packet import (
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
)
# ============================================================
# Настройки
# ============================================================
SOURCE_PATH = Path(
"data/raw/lab011_scene.jpg"
)
OUTPUT_DIRECTORY = Path(
"data/processed/lab011"
)
FRAME_SIZE = (320, 240)
FRAGMENT_DATA_SIZE = 512
IMAGE_ID_BASE = 2026071400
BITRATES_KBPS = [
5,
10,
20,
50,
]
# ============================================================
# Вспомогательные функции
# ============================================================
def prepare_frame(
image: Image.Image,
) -> Image.Image:
"""
Привести изображение к фиксированному кадру 320x240.
Пропорции сохраняются.
Недостающие области заполняются чёрным.
"""
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
return ImageOps.pad(
image,
FRAME_SIZE,
method=Image.Resampling.LANCZOS,
color="black",
centering=(0.5, 0.5),
)
def save_jpeg(
image: Image.Image,
path: Path,
quality: int,
) -> None:
"""
Сохранить изображение в JPEG.
"""
path.parent.mkdir(
parents=True,
exist_ok=True,
)
image.save(
path,
format="JPEG",
quality=quality,
optimize=True,
)
def format_size(
byte_count: int,
) -> str:
"""
Представить размер в КиБ.
"""
return f"{byte_count / 1024:.2f} КиБ"
def format_duration(
seconds: float,
) -> str:
"""
Представить длительность передачи.
"""
if seconds < 1:
return f"{seconds * 1000:.0f} мс"
if seconds < 60:
return f"{seconds:.2f} с"
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return (
f"{minutes} мин "
f"{remaining_seconds:.1f} с"
)
def estimate_transfer(
file_path: Path,
image_id: int,
) -> dict:
"""
Рассчитать полный объём DATA + ACK.
"""
image_bytes = file_path.read_bytes()
fragments = split_image_bytes(
image_bytes=image_bytes,
image_id=image_id,
fragment_data_size=FRAGMENT_DATA_SIZE,
)
data_packet_bytes = 0
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
packet = build_packet(
payload=fragment_payload,
message_type=MESSAGE_TYPE_IMAGE_FRAGMENT,
sequence_number=fragment.fragment_index,
)
data_packet_bytes += len(packet)
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=0,
)
ack_bytes = (
len(fragments)
* len(ack_packet)
)
total_radio_bytes = (
data_packet_bytes
+ ack_bytes
)
times = {}
for bitrate_kbps in BITRATES_KBPS:
times[bitrate_kbps] = (
total_radio_bytes
* 8
/ (bitrate_kbps * 1000)
)
return {
"file_size": len(image_bytes),
"fragment_count": len(fragments),
"total_radio_bytes": total_radio_bytes,
"times": times,
}
# ============================================================
# Проверка исходного файла
# ============================================================
if not SOURCE_PATH.exists():
raise FileNotFoundError(
f"Не найден файл: {SOURCE_PATH}"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
# ============================================================
# Подготовка базового кадра
# ============================================================
with Image.open(SOURCE_PATH) as source_image:
original_size = source_image.size
color_frame = prepare_frame(
source_image
)
gray_frame = ImageOps.grayscale(
color_frame
)
# ============================================================
# Вариант 1. Grayscale, quality 30
# ============================================================
gray_q30_path = (
OUTPUT_DIRECTORY
/ "01_gray_320_q30.jpg"
)
save_jpeg(
gray_frame,
gray_q30_path,
quality=30,
)
# ============================================================
# Вариант 2. Grayscale, quality 15
# ============================================================
gray_q15_path = (
OUTPUT_DIRECTORY
/ "02_gray_320_q15.jpg"
)
save_jpeg(
gray_frame,
gray_q15_path,
quality=15,
)
# ============================================================
# Вариант 3. Grayscale + контуры
# ============================================================
edge_map = gray_frame.filter(
ImageFilter.FIND_EDGES
)
edge_map = ImageOps.autocontrast(
edge_map
)
# Оставляем преимущественно сильные контуры.
binary_edges = edge_map.point(
lambda value: 255 if value >= 45 else 0
)
# После инверсии контуры становятся чёрными,
# а фон — белым.
dark_edges = ImageOps.invert(
binary_edges
)
# Сохраняем исходный серый фон и добавляем
# поверх него тёмные линии контуров.
gray_with_edges = ImageChops.darker(
gray_frame,
dark_edges
)
gray_edges_path = (
OUTPUT_DIRECTORY
/ "03_gray_edges_320_q30.jpg"
)
save_jpeg(
gray_with_edges,
gray_edges_path,
quality=30,
)
# ============================================================
# Список вариантов
# ============================================================
variants = [
(
"gray_320_q30",
gray_q30_path,
),
(
"gray_320_q15",
gray_q15_path,
),
(
"gray_edges_320_q30",
gray_edges_path,
),
]
# ============================================================
# Расчёт параметров
# ============================================================
results = []
for variant_index, (
variant_name,
variant_path,
) in enumerate(variants):
result = estimate_transfer(
file_path=variant_path,
image_id=IMAGE_ID_BASE + variant_index,
)
results.append(
{
"name": variant_name,
"path": variant_path,
**result,
}
)
# ============================================================
# Создание сравнительной картинки
# ============================================================
preview_variants = [
(
"SOURCE PREVIEW",
color_frame,
None,
)
]
for result in results:
with Image.open(result["path"]) as image:
preview_variants.append(
(
result["name"],
image.convert("RGB").copy(),
result,
)
)
columns = 2
cell_width = 440
cell_height = 340
rows = ceil(
len(preview_variants)
/ columns
)
comparison_image = Image.new(
"RGB",
(
columns * cell_width,
rows * cell_height,
),
"white",
)
draw = ImageDraw.Draw(
comparison_image
)
for index, (
label,
preview,
result,
) in enumerate(preview_variants):
column = index % columns
row = index // columns
x = column * cell_width
y = row * cell_height
preview = preview.copy()
preview.thumbnail(
(400, 270),
Image.Resampling.NEAREST,
)
paste_x = (
x
+ (cell_width - preview.width) // 2
)
comparison_image.paste(
preview,
(paste_x, y + 55),
)
if result is None:
text = (
f"{label}\n"
f"original: "
f"{original_size[0]}x{original_size[1]}"
)
else:
text = (
f"{label}\n"
f"{format_size(result['file_size'])}, "
f"{result['fragment_count']} fragments, "
f"{format_duration(result['times'][20])} "
f"at 20 kbps"
)
draw.multiline_text(
(x + 10, y + 10),
text,
fill="black",
spacing=4,
)
comparison_path = (
OUTPUT_DIRECTORY
/ "lab011_comparison.jpg"
)
comparison_image.save(
comparison_path,
format="JPEG",
quality=90,
)
# ============================================================
# Вывод результатов
# ============================================================
print(
"=== Lab011. Режимы кадра для ровера ==="
)
print("\nИсходное разрешение:")
print(
original_size[0],
"x",
original_size[1],
)
print("\nРезультаты:")
print(
f"{'Вариант':<27}"
f"{'Размер':>12}"
f"{'Пакеты':>10}"
f"{'10 кбит/с':>13}"
f"{'20 кбит/с':>13}"
f"{'50 кбит/с':>13}"
)
print("-" * 88)
for result in results:
print(
f"{result['name']:<27}"
f"{format_size(result['file_size']):>12}"
f"{result['fragment_count']:>10}"
f"{format_duration(result['times'][10]):>13}"
f"{format_duration(result['times'][20]):>13}"
f"{format_duration(result['times'][50]):>13}"
)
print("\nСравнительная картинка:")
print(comparison_path)
print(
"\nПроверка пройдена: "
"режимы кадра созданы и рассчитаны."
)

View File

@@ -0,0 +1,558 @@
"""
Lab012. Сравнение цветного и серого кадров
для обнаружения человека и препятствий.
Создаются четыре варианта:
1. Цветной 320x240, JPEG quality 30.
2. Серый 320x240, JPEG quality 30.
3. Цветной 320x240, JPEG quality 15.
4. Серый 320x240, JPEG quality 15.
Для каждого варианта рассчитываются:
- размер JPEG;
- количество фрагментов;
- полный объём DATA + ACK;
- время передачи;
- стоимость сохранения цвета.
"""
from math import ceil
from pathlib import Path
from PIL import (
Image,
ImageDraw,
ImageOps,
)
from protocol.image_fragments import (
encode_image_fragment,
split_image_bytes,
)
from protocol.packet import (
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
)
# ============================================================
# Настройки
# ============================================================
SOURCE_PATH = Path(
"data/raw/lab012_safety_scene.jpg"
)
OUTPUT_DIRECTORY = Path(
"data/processed/lab012"
)
FRAME_SIZE = (320, 240)
FRAGMENT_DATA_SIZE = 512
IMAGE_ID_BASE = 2026071500
BITRATES_KBPS = [
10,
20,
50,
]
# ============================================================
# Вспомогательные функции
# ============================================================
def prepare_frame(
image: Image.Image,
) -> Image.Image:
"""
Подготовить кадр фиксированного размера 320x240.
Изображение:
- автоматически поворачивается по EXIF;
- переводится в RGB;
- масштабируется с сохранением пропорций;
- при необходимости дополняется чёрными полями.
"""
image = ImageOps.exif_transpose(image)
image = image.convert("RGB")
return ImageOps.pad(
image,
FRAME_SIZE,
method=Image.Resampling.LANCZOS,
color="black",
centering=(0.5, 0.5),
)
def save_jpeg(
image: Image.Image,
output_path: Path,
quality: int,
) -> None:
"""
Сохранить изображение в JPEG.
"""
output_path.parent.mkdir(
parents=True,
exist_ok=True,
)
image.save(
output_path,
format="JPEG",
quality=quality,
optimize=True,
)
def format_size(
byte_count: int,
) -> str:
"""
Представить размер файла в КиБ.
"""
return f"{byte_count / 1024:.2f} КиБ"
def format_duration(
seconds: float,
) -> str:
"""
Представить время передачи.
"""
if seconds < 1:
return f"{seconds * 1000:.0f} мс"
if seconds < 60:
return f"{seconds:.2f} с"
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return (
f"{minutes} мин "
f"{remaining_seconds:.1f} с"
)
def estimate_transfer(
file_path: Path,
image_id: int,
) -> dict:
"""
Рассчитать параметры передачи JPEG
с использованием текущего протокола.
"""
image_bytes = file_path.read_bytes()
fragments = split_image_bytes(
image_bytes=image_bytes,
image_id=image_id,
fragment_data_size=FRAGMENT_DATA_SIZE,
)
total_data_bytes = 0
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
packet = build_packet(
payload=fragment_payload,
message_type=MESSAGE_TYPE_IMAGE_FRAGMENT,
sequence_number=fragment.fragment_index,
)
total_data_bytes += len(packet)
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=0,
)
total_ack_bytes = (
len(fragments)
* len(ack_packet)
)
total_radio_bytes = (
total_data_bytes
+ total_ack_bytes
)
transfer_times = {}
for bitrate_kbps in BITRATES_KBPS:
transfer_times[bitrate_kbps] = (
total_radio_bytes
* 8
/ (bitrate_kbps * 1000)
)
return {
"file_size": len(image_bytes),
"fragment_count": len(fragments),
"total_data_bytes": total_data_bytes,
"total_ack_bytes": total_ack_bytes,
"total_radio_bytes": total_radio_bytes,
"times": transfer_times,
}
# ============================================================
# Проверка исходного файла
# ============================================================
if not SOURCE_PATH.exists():
raise FileNotFoundError(
f"Не найден файл: {SOURCE_PATH}"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
# ============================================================
# Подготовка базового кадра
# ============================================================
with Image.open(SOURCE_PATH) as source_image:
original_size = source_image.size
color_frame = prepare_frame(
source_image
)
gray_frame = ImageOps.grayscale(
color_frame
)
# ============================================================
# Создание вариантов
# ============================================================
variant_definitions = [
{
"name": "color_320_q30",
"image": color_frame,
"quality": 30,
"path": (
OUTPUT_DIRECTORY
/ "01_color_320_q30.jpg"
),
},
{
"name": "gray_320_q30",
"image": gray_frame,
"quality": 30,
"path": (
OUTPUT_DIRECTORY
/ "02_gray_320_q30.jpg"
),
},
{
"name": "color_320_q15",
"image": color_frame,
"quality": 15,
"path": (
OUTPUT_DIRECTORY
/ "03_color_320_q15.jpg"
),
},
{
"name": "gray_320_q15",
"image": gray_frame,
"quality": 15,
"path": (
OUTPUT_DIRECTORY
/ "04_gray_320_q15.jpg"
),
},
]
for variant in variant_definitions:
save_jpeg(
image=variant["image"],
output_path=variant["path"],
quality=variant["quality"],
)
# ============================================================
# Расчёт параметров передачи
# ============================================================
results = []
for variant_index, variant in enumerate(
variant_definitions
):
transfer_result = estimate_transfer(
file_path=variant["path"],
image_id=IMAGE_ID_BASE + variant_index,
)
results.append(
{
"name": variant["name"],
"path": variant["path"],
"quality": variant["quality"],
**transfer_result,
}
)
# ============================================================
# Создание сравнительной картинки
# ============================================================
columns = 2
cell_width = 440
cell_height = 340
rows = ceil(
len(results)
/ columns
)
comparison_image = Image.new(
"RGB",
(
columns * cell_width,
rows * cell_height,
),
"white",
)
draw = ImageDraw.Draw(
comparison_image
)
for result_index, result in enumerate(results):
column = result_index % columns
row = result_index // columns
cell_x = column * cell_width
cell_y = row * cell_height
with Image.open(result["path"]) as image:
preview = image.convert("RGB")
# NEAREST не сглаживает пиксели при увеличении.
# Поэтому дефекты сжатия будут хорошо видны.
preview = preview.resize(
(400, 300),
Image.Resampling.NEAREST,
)
paste_x = (
cell_x
+ (cell_width - preview.width) // 2
)
paste_y = cell_y + 35
comparison_image.paste(
preview,
(paste_x, paste_y),
)
label = (
f"{result['name']}\n"
f"{format_size(result['file_size'])}, "
f"{result['fragment_count']} fragments, "
f"{format_duration(result['times'][20])} "
f"at 20 kbps"
)
draw.multiline_text(
(cell_x + 10, cell_y + 5),
label,
fill="black",
spacing=3,
)
comparison_path = (
OUTPUT_DIRECTORY
/ "lab012_comparison.jpg"
)
comparison_image.save(
comparison_path,
format="JPEG",
quality=92,
)
# ============================================================
# Поиск результатов по имени
# ============================================================
results_by_name = {
result["name"]: result
for result in results
}
color_q30 = results_by_name[
"color_320_q30"
]
gray_q30 = results_by_name[
"gray_320_q30"
]
color_q15 = results_by_name[
"color_320_q15"
]
gray_q15 = results_by_name[
"gray_320_q15"
]
# ============================================================
# Расчёт цены сохранения цвета
# ============================================================
color_overhead_q30_bytes = (
color_q30["file_size"]
- gray_q30["file_size"]
)
color_overhead_q30_percent = (
color_overhead_q30_bytes
/ gray_q30["file_size"]
* 100
)
color_overhead_q15_bytes = (
color_q15["file_size"]
- gray_q15["file_size"]
)
color_overhead_q15_percent = (
color_overhead_q15_bytes
/ gray_q15["file_size"]
* 100
)
# ============================================================
# Вывод результатов
# ============================================================
print(
"=== Lab012. Цветной или серый кадр ==="
)
print("\nИсходное разрешение:")
print(
original_size[0],
"x",
original_size[1],
)
print("\nСравнение вариантов:")
print(
f"{'Вариант':<22}"
f"{'Размер':>12}"
f"{'Пакеты':>10}"
f"{'10 кбит/с':>13}"
f"{'20 кбит/с':>13}"
f"{'50 кбит/с':>13}"
)
print("-" * 83)
for result in results:
print(
f"{result['name']:<22}"
f"{format_size(result['file_size']):>12}"
f"{result['fragment_count']:>10}"
f"{format_duration(result['times'][10]):>13}"
f"{format_duration(result['times'][20]):>13}"
f"{format_duration(result['times'][50]):>13}"
)
print("\nЦена сохранения цвета при quality 30:")
print(
format_size(
color_overhead_q30_bytes
),
f"или {color_overhead_q30_percent:.1f} %",
)
print("\nЦена сохранения цвета при quality 15:")
print(
format_size(
color_overhead_q15_bytes
),
f"или {color_overhead_q15_percent:.1f} %",
)
print("\nСравнительная картинка:")
print(comparison_path)
# ============================================================
# Проверки
# ============================================================
assert len(results) == 4
assert comparison_path.exists()
assert all(
result["fragment_count"] > 0
for result in results
)
assert color_q30["file_size"] > 0
assert gray_q30["file_size"] > 0
assert color_q15["file_size"] > 0
assert gray_q15["file_size"] > 0
print(
"\nПроверка пройдена: "
"цветные и серые варианты созданы и рассчитаны."
)

View File

@@ -0,0 +1,528 @@
"""
Lab013. Первая BPSK-передача цифрового пакета в Python.
Программа:
1. Формирует пакет SDR Rover Link с CRC-32.
2. Преобразует байты пакета в отдельные биты.
3. Преобразует биты в BPSK-символы.
4. Представляет символы как комплексные IQ-сэмплы.
5. Добавляет комплексный белый гауссов шум.
6. Демодулирует BPSK.
7. Восстанавливает байты пакета.
8. Проверяет пакет и CRC.
9. Сравнивает результат при разных уровнях SNR.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from protocol.packet import (
CRCError,
MESSAGE_TYPE_TEXT,
PacketError,
build_packet,
parse_packet,
)
# ============================================================
# Настройки эксперимента
# ============================================================
MESSAGE = "ПРИВЕТ SDR"
SEQUENCE_NUMBER = 13
# Проверим несколько уровней отношения сигнал/шум.
SNR_VALUES_DB = [
12.0,
6.0,
2.0,
0.0,
]
# Фиксированное значение обеспечивает повторяемость.
RANDOM_SEED = 2026
OUTPUT_DIRECTORY = Path(
"data/processed/lab013"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
# ============================================================
# Преобразование байтов в биты
# ============================================================
def bytes_to_bits(
data: bytes,
) -> np.ndarray:
"""
Преобразовать последовательность bytes
в массив отдельных битов 0 и 1.
Каждый исходный байт превращается в восемь битов.
"""
if not isinstance(data, bytes):
raise TypeError(
"data должен иметь тип bytes"
)
byte_array = np.frombuffer(
data,
dtype=np.uint8,
)
bits = np.unpackbits(
byte_array
)
return bits
# ============================================================
# Преобразование битов обратно в байты
# ============================================================
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(
"Массив должен содержать только 0 и 1"
)
packed_bytes = np.packbits(
bits
)
return packed_bytes.tobytes()
# ============================================================
# BPSK-модулятор
# ============================================================
def bpsk_modulate(
bits: np.ndarray,
) -> np.ndarray:
"""
Преобразовать биты в комплексные BPSK-символы.
Используем отображение:
0 → -1 + 0j
1 → +1 + 0j
"""
bits = np.asarray(
bits,
dtype=np.uint8,
)
real_symbols = (
2.0 * bits.astype(np.float64)
- 1.0
)
iq_symbols = real_symbols.astype(
np.complex128
)
return iq_symbols
# ============================================================
# Модель радиошума
# ============================================================
def add_awgn(
iq_samples: np.ndarray,
snr_db: float,
random_generator: np.random.Generator,
) -> np.ndarray:
"""
Добавить комплексный белый гауссов шум AWGN.
AWGN:
Additive White Gaussian Noise —
аддитивный белый гауссов шум.
snr_db:
Отношение средней мощности сигнала
к средней мощности шума в децибелах.
"""
signal_power = np.mean(
np.abs(iq_samples) ** 2
)
snr_linear = 10.0 ** (
snr_db / 10.0
)
noise_power = (
signal_power / snr_linear
)
# Комплексный шум имеет две составляющие:
# действительную I и мнимую Q.
#
# Поэтому мощность делится между ними пополам.
noise_sigma = np.sqrt(
noise_power / 2.0
)
noise = noise_sigma * (
random_generator.standard_normal(
len(iq_samples)
)
+ 1j
* random_generator.standard_normal(
len(iq_samples)
)
)
return iq_samples + noise
# ============================================================
# BPSK-демодулятор
# ============================================================
def bpsk_demodulate(
received_iq: np.ndarray,
) -> np.ndarray:
"""
Преобразовать принятые IQ-сэмплы в биты.
Правило решения:
I < 0 → бит 0
I >= 0 → бит 1
"""
received_bits = (
received_iq.real >= 0.0
).astype(np.uint8)
return received_bits
# ============================================================
# Формирование исходного пакета
# ============================================================
payload = MESSAGE.encode(
"utf-8"
)
original_packet = build_packet(
payload=payload,
message_type=MESSAGE_TYPE_TEXT,
sequence_number=SEQUENCE_NUMBER,
)
transmitted_bits = bytes_to_bits(
original_packet
)
transmitted_iq = bpsk_modulate(
transmitted_bits
)
print(
"=== Lab013. BPSK в Python ==="
)
print("\nИсходное сообщение:")
print(MESSAGE)
print("\nРазмер пакета:")
print(
len(original_packet),
"байт",
)
print("\nКоличество передаваемых битов:")
print(
len(transmitted_bits)
)
print("\nПервые 32 бита:")
print(
" ".join(
str(bit)
for bit in transmitted_bits[:32]
)
)
print("\nПервые 16 BPSK-символов:")
print(
transmitted_iq[:16]
)
# ============================================================
# Передача при разных уровнях SNR
# ============================================================
experiment_results = []
constellation_samples = {}
for experiment_index, snr_db in enumerate(
SNR_VALUES_DB
):
random_generator = np.random.default_rng(
RANDOM_SEED + experiment_index
)
received_iq = add_awgn(
iq_samples=transmitted_iq,
snr_db=snr_db,
random_generator=random_generator,
)
received_bits = bpsk_demodulate(
received_iq
)
bit_error_count = int(
np.count_nonzero(
transmitted_bits != received_bits
)
)
bit_error_rate = (
bit_error_count
/ len(transmitted_bits)
)
received_packet = bits_to_bytes(
received_bits
)
packet_status = "CRC OK"
restored_message = None
try:
parsed_packet = parse_packet(
received_packet
)
restored_message = (
parsed_packet.payload.decode(
"utf-8"
)
)
except CRCError:
packet_status = "CRC ERROR"
except PacketError:
packet_status = "PACKET ERROR"
except UnicodeDecodeError:
packet_status = "UTF-8 ERROR"
experiment_results.append(
{
"snr_db": snr_db,
"bit_errors": bit_error_count,
"ber": bit_error_rate,
"packet_status": packet_status,
"restored_message": restored_message,
}
)
# Для графика достаточно первых 300 символов.
constellation_samples[snr_db] = (
received_iq[:300]
)
# ============================================================
# Вывод результатов
# ============================================================
print("\nРезультаты передачи:")
print(
f"{'SNR':>8}"
f"{'Ошибки битов':>16}"
f"{'BER':>14}"
f"{'Пакет':>18}"
)
print("-" * 56)
for result in experiment_results:
print(
f"{result['snr_db']:>6.1f} дБ"
f"{result['bit_errors']:>16}"
f"{result['ber']:>14.6f}"
f"{result['packet_status']:>18}"
)
print("\nВосстановленные сообщения:")
for result in experiment_results:
print(
f"SNR {result['snr_db']:>4.1f} дБ: "
f"{result['restored_message']}"
)
# ============================================================
# График созвездия
# ============================================================
figure, axes = plt.subplots(
2,
2,
figsize=(10, 8),
)
axes = axes.ravel()
for axis, snr_db in zip(
axes,
SNR_VALUES_DB,
):
received_iq = constellation_samples[
snr_db
]
axis.scatter(
received_iq.real,
received_iq.imag,
s=12,
alpha=0.6,
)
axis.axvline(
0.0,
linewidth=1,
)
axis.set_title(
f"BPSK, SNR = {snr_db:.1f} дБ"
)
axis.set_xlabel(
"I — синфазная компонента"
)
axis.set_ylabel(
"Q — квадратурная компонента"
)
axis.set_xlim(
-2.5,
2.5,
)
axis.set_ylim(
-1.8,
1.8,
)
axis.grid(
True
)
figure.tight_layout()
constellation_path = (
OUTPUT_DIRECTORY
/ "lab013_bpsk_constellation.png"
)
figure.savefig(
constellation_path,
dpi=150,
)
plt.close(
figure
)
# ============================================================
# Автоматические проверки
# ============================================================
# Без добавления шума преобразование должно быть обратимым.
ideal_received_bits = bpsk_demodulate(
transmitted_iq
)
ideal_received_packet = bits_to_bytes(
ideal_received_bits
)
assert ideal_received_packet == original_packet
ideal_parsed_packet = parse_packet(
ideal_received_packet
)
assert (
ideal_parsed_packet.payload.decode(
"utf-8"
)
== MESSAGE
)
assert len(experiment_results) == len(
SNR_VALUES_DB
)
assert constellation_path.exists()
print("\nГрафик созвездия:")
print(constellation_path)
print(
"\nПроверка пройдена: "
"пакет преобразован в BPSK IQ-сэмплы "
"и демодулирован обратно."
)

View File

@@ -0,0 +1,617 @@
"""
Lab014. Измерение BER для BPSK в канале AWGN.
Программа:
1. Генерирует 1 000 000 случайных битов.
2. Преобразует их в BPSK-символы.
3. Добавляет комплексный гауссов шум.
4. Демодулирует принятый сигнал.
5. Измеряет экспериментальный BER.
6. Рассчитывает теоретический BER.
7. Оценивает вероятность повреждения пакетов разной длины.
8. Строит BER-кривую.
9. Сохраняет результаты в CSV.
Упрощения модели:
- один бит передаётся одним BPSK-символом;
- один символ представлен одним IQ-сэмплом;
- частотная и фазовая синхронизация идеальны;
- межсимвольные искажения отсутствуют;
- канал содержит только AWGN.
"""
from csv import DictWriter
from math import (
erfc,
expm1,
log1p,
sqrt,
)
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
# ============================================================
# Настройки эксперимента
# ============================================================
BIT_COUNT = 1_000_000
EB_N0_VALUES_DB = [
-4.0,
-2.0,
0.0,
2.0,
4.0,
6.0,
8.0,
10.0,
12.0,
]
RANDOM_SEED = 2026
OUTPUT_DIRECTORY = Path(
"data/processed/lab014"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
GRAPH_PATH = (
OUTPUT_DIRECTORY
/ "lab014_bpsk_ber_curve.png"
)
CSV_PATH = (
OUTPUT_DIRECTORY
/ "lab014_bpsk_ber_results.csv"
)
# ============================================================
# Размеры пакетов для оценки PER
# ============================================================
# Пакет из Lab013:
#
# заголовок 8 байт
# PAYLOAD 16 байт
# CRC 4 байта
#
# Итого 28 байт = 224 бита
SHORT_PACKET_BITS = 224
# Типовой пакет изображения:
#
# заголовок протокола 8 байт
# заголовок фрагмента 12 байт
# данные JPEG 512 байт
# CRC 4 байта
#
# Итого 536 байт
IMAGE_PACKET_BITS = 536 * 8
# ============================================================
# Вспомогательные функции
# ============================================================
def bpsk_modulate(
bits: np.ndarray,
) -> np.ndarray:
"""
Преобразовать биты в BPSK-символы.
Отображение:
0 → -1
1 → +1
"""
bits = np.asarray(
bits,
dtype=np.uint8,
)
if bits.ndim != 1:
raise ValueError(
"bits должен быть одномерным массивом"
)
if not np.all(
(bits == 0) | (bits == 1)
):
raise ValueError(
"bits должен содержать только 0 и 1"
)
symbols = (
2.0 * bits.astype(np.float64)
- 1.0
)
return symbols.astype(
np.complex128
)
def add_awgn(
iq_samples: np.ndarray,
eb_n0_db: float,
random_generator: np.random.Generator,
) -> np.ndarray:
"""
Добавить комплексный AWGN-шум.
Энергия BPSK-символа равна единице.
Для одного бита на символ:
Es/N0 = Eb/N0
"""
eb_n0_linear = 10.0 ** (
eb_n0_db / 10.0
)
# Для комплексного AWGN каждая компонента
# I и Q получает половину полной мощности шума.
noise_sigma = sqrt(
1.0
/ (
2.0
* eb_n0_linear
)
)
noise = noise_sigma * (
random_generator.standard_normal(
len(iq_samples)
)
+ 1j
* random_generator.standard_normal(
len(iq_samples)
)
)
return iq_samples + noise
def bpsk_demodulate(
received_iq: np.ndarray,
) -> np.ndarray:
"""
Демодулировать BPSK по знаку компоненты I.
I < 0 → бит 0
I >= 0 → бит 1
"""
return (
received_iq.real >= 0.0
).astype(np.uint8)
def theoretical_bpsk_ber(
eb_n0_db: float,
) -> float:
"""
Рассчитать теоретический BER когерентной BPSK
в канале AWGN.
BER = 0.5 * erfc(sqrt(Eb/N0))
"""
eb_n0_linear = 10.0 ** (
eb_n0_db / 10.0
)
return 0.5 * erfc(
sqrt(eb_n0_linear)
)
def ber_to_per(
ber: float,
packet_bit_count: int,
) -> float:
"""
Оценить Packet Error Rate из BER.
Предполагается:
- ошибки отдельных битов независимы;
- пакет считается повреждённым,
если ошибся хотя бы один бит.
PER = 1 - (1 - BER) ** N
"""
if not 0.0 <= ber <= 1.0:
raise ValueError(
"BER должен находиться в диапазоне 0...1"
)
if packet_bit_count <= 0:
raise ValueError(
"packet_bit_count должен быть положительным"
)
if ber == 0.0:
return 0.0
if ber == 1.0:
return 1.0
# Такая запись численно устойчивее,
# чем прямое возведение в степень
# для очень маленьких BER.
return -expm1(
packet_bit_count
* log1p(-ber)
)
# ============================================================
# Генерация исходной последовательности
# ============================================================
bit_generator = np.random.default_rng(
RANDOM_SEED
)
transmitted_bits = bit_generator.integers(
low=0,
high=2,
size=BIT_COUNT,
dtype=np.uint8,
)
transmitted_iq = bpsk_modulate(
transmitted_bits
)
# ============================================================
# Эксперимент при разных Eb/N0
# ============================================================
results = []
for experiment_index, eb_n0_db in enumerate(
EB_N0_VALUES_DB
):
noise_generator = np.random.default_rng(
RANDOM_SEED
+ 1000
+ experiment_index
)
received_iq = add_awgn(
iq_samples=transmitted_iq,
eb_n0_db=eb_n0_db,
random_generator=noise_generator,
)
received_bits = bpsk_demodulate(
received_iq
)
bit_error_count = int(
np.count_nonzero(
transmitted_bits
!= received_bits
)
)
experimental_ber = (
bit_error_count
/ BIT_COUNT
)
theoretical_ber = theoretical_bpsk_ber(
eb_n0_db
)
short_packet_per = ber_to_per(
theoretical_ber,
SHORT_PACKET_BITS,
)
image_packet_per = ber_to_per(
theoretical_ber,
IMAGE_PACKET_BITS,
)
results.append(
{
"eb_n0_db": eb_n0_db,
"bit_errors": bit_error_count,
"experimental_ber": experimental_ber,
"theoretical_ber": theoretical_ber,
"short_packet_per": short_packet_per,
"image_packet_per": image_packet_per,
}
)
# ============================================================
# Вывод основных результатов
# ============================================================
print(
"=== Lab014. BER-кривая BPSK ==="
)
print("\nКоличество переданных битов:")
print(
f"{BIT_COUNT:,}".replace(",", " ")
)
print("\nРезультаты:")
print(
f"{'Eb/N0':>9}"
f"{'Ошибки':>12}"
f"{'BER эксперимент':>19}"
f"{'BER теория':>16}"
f"{'PER 224 бит':>16}"
f"{'PER 4288 бит':>17}"
)
print("-" * 89)
for result in results:
print(
f"{result['eb_n0_db']:>6.1f} дБ"
f"{result['bit_errors']:>12}"
f"{result['experimental_ber']:>19.6e}"
f"{result['theoretical_ber']:>16.6e}"
f"{result['short_packet_per']:>16.6f}"
f"{result['image_packet_per']:>17.6f}"
)
# ============================================================
# Сохранение CSV
# ============================================================
with CSV_PATH.open(
"w",
newline="",
encoding="utf-8-sig",
) as csv_file:
fieldnames = [
"eb_n0_db",
"bit_errors",
"experimental_ber",
"theoretical_ber",
"short_packet_per_224_bits",
"image_packet_per_4288_bits",
]
writer = DictWriter(
csv_file,
fieldnames=fieldnames,
)
writer.writeheader()
for result in results:
writer.writerow(
{
"eb_n0_db": result["eb_n0_db"],
"bit_errors": result["bit_errors"],
"experimental_ber": (
result["experimental_ber"]
),
"theoretical_ber": (
result["theoretical_ber"]
),
"short_packet_per_224_bits": (
result["short_packet_per"]
),
"image_packet_per_4288_bits": (
result["image_packet_per"]
),
}
)
# ============================================================
# Подготовка данных для графика
# ============================================================
eb_n0_plot_values = np.array(
[
result["eb_n0_db"]
for result in results
],
dtype=np.float64,
)
experimental_ber_values = np.array(
[
result["experimental_ber"]
for result in results
],
dtype=np.float64,
)
theoretical_ber_values = np.array(
[
result["theoretical_ber"]
for result in results
],
dtype=np.float64,
)
# Нулевой измеренный BER невозможно показать
# на логарифмической шкале.
#
# Поэтому для графика ставим такую точку
# на уровень половины одного наблюдаемого события.
measurement_floor = (
0.5 / BIT_COUNT
)
experimental_ber_for_plot = np.maximum(
experimental_ber_values,
measurement_floor,
)
# ============================================================
# Построение BER-графика
# ============================================================
figure = plt.figure(
figsize=(10, 7)
)
plt.semilogy(
eb_n0_plot_values,
theoretical_ber_values,
marker="o",
label="Теоретический BER BPSK",
)
plt.semilogy(
eb_n0_plot_values,
experimental_ber_for_plot,
marker="s",
linestyle="--",
label="Экспериментальный BER",
)
plt.axhline(
measurement_floor,
linestyle=":",
label=(
"Предел измерения "
f"{measurement_floor:.1e}"
),
)
plt.xlabel(
"Eb/N0, дБ"
)
plt.ylabel(
"BER"
)
plt.title(
"BPSK в канале AWGN: "
"эксперимент и теория"
)
plt.grid(
True,
which="both",
)
plt.legend()
plt.tight_layout()
figure.savefig(
GRAPH_PATH,
dpi=160,
)
plt.close(
figure
)
# ============================================================
# Инженерные контрольные точки
# ============================================================
print("\nИнженерные контрольные точки:")
for target_db in [
6.0,
8.0,
10.0,
]:
result = next(
item
for item in results
if item["eb_n0_db"] == target_db
)
print(
f"\nEb/N0 = {target_db:.1f} дБ"
)
print(
"Теоретический BER:",
f"{result['theoretical_ber']:.6e}",
)
print(
"PER короткого пакета 224 бита:",
f"{result['short_packet_per'] * 100:.2f} %",
)
print(
"PER JPEG-пакета 4288 бит:",
f"{result['image_packet_per'] * 100:.2f} %",
)
# ============================================================
# Автоматические проверки
# ============================================================
assert len(transmitted_bits) == BIT_COUNT
assert len(received_bits) == BIT_COUNT
assert all(
0.0
<= result["experimental_ber"]
<= 1.0
for result in results
)
assert all(
0.0
<= result["theoretical_ber"]
<= 1.0
for result in results
)
assert GRAPH_PATH.exists()
assert CSV_PATH.exists()
print("\nГрафик BER:")
print(GRAPH_PATH)
print("\nТаблица CSV:")
print(CSV_PATH)
print(
"\nПроверка пройдена: "
"экспериментальная BER-кривая построена."
)

View File

@@ -0,0 +1,810 @@
"""
Lab015. Подбор оптимального размера фрагмента изображения.
Программа сравнивает фрагменты размером:
- 64 байта;
- 128 байт;
- 256 байт;
- 512 байт;
- 1024 байта.
Для каждого размера рассчитываются:
1. Количество фрагментов изображения.
2. Служебные расходы протокола.
3. Теоретический BER BPSK.
4. Вероятность успешной доставки DATA-пакета.
5. Вероятность успешной доставки ACK.
6. Среднее число передач одного фрагмента.
7. Ожидаемый полный радиообмен с ARQ.
8. Время передачи фотографии при 20 кбит/с.
9. Эффективная полезная скорость.
Модель канала:
- когерентная BPSK;
- AWGN;
- ошибки битов независимы;
- повреждённый пакет отбрасывается по CRC;
- применяется Stop-and-Wait ARQ;
- DATA и ACK работают при одинаковом Eb/N0.
Не учитываются:
- ожидание тайм-аута;
- паузы между DATA и ACK;
- преамбула;
- синхронизация;
- FEC;
- многолучёвость;
- частотные и фазовые ошибки.
"""
from csv import DictWriter
from math import (
erfc,
exp,
log1p,
sqrt,
)
from pathlib import Path
import matplotlib.pyplot as plt
from protocol.image_fragments import (
encode_image_fragment,
split_image_bytes,
)
from protocol.packet import (
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
)
# ============================================================
# Настройки
# ============================================================
SOURCE_PATH = Path(
"data/raw/lab009_source.jpg"
)
OUTPUT_DIRECTORY = Path(
"data/processed/lab015"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
FRAGMENT_SIZES = [
64,
128,
256,
512,
1024,
]
EB_N0_VALUES_DB = [
6.0,
8.0,
10.0,
12.0,
]
CHANNEL_BITRATE_BPS = 20_000
IMAGE_ID_BASE = 2026071600
CSV_PATH = (
OUTPUT_DIRECTORY
/ "lab015_fragment_results.csv"
)
TIME_GRAPH_PATH = (
OUTPUT_DIRECTORY
/ "lab015_transfer_time.png"
)
GOODPUT_GRAPH_PATH = (
OUTPUT_DIRECTORY
/ "lab015_effective_goodput.png"
)
# ============================================================
# Вспомогательные функции
# ============================================================
def theoretical_bpsk_ber(
eb_n0_db: float,
) -> float:
"""
Теоретический BER когерентной BPSK в AWGN.
BER = 0.5 * erfc(sqrt(Eb/N0))
"""
eb_n0_linear = 10.0 ** (
eb_n0_db / 10.0
)
return 0.5 * erfc(
sqrt(eb_n0_linear)
)
def packet_success_probability(
ber: float,
packet_bit_count: int,
) -> float:
"""
Вероятность того, что весь пакет будет принят
без единой битовой ошибки.
P_success = (1 - BER) ** N
"""
if not 0.0 <= ber <= 1.0:
raise ValueError(
"BER должен находиться в диапазоне 0...1"
)
if packet_bit_count <= 0:
raise ValueError(
"Размер пакета должен быть положительным"
)
if ber == 0.0:
return 1.0
if ber == 1.0:
return 0.0
# Численно устойчивый вариант выражения:
#
# (1 - BER) ** N
return exp(
packet_bit_count
* log1p(-ber)
)
def format_bytes(
byte_count: float,
) -> str:
"""
Представить объём в удобном виде.
"""
if byte_count < 1024:
return f"{byte_count:.0f} байт"
kibibytes = byte_count / 1024
if kibibytes < 1024:
return f"{kibibytes:.2f} КиБ"
mebibytes = kibibytes / 1024
return f"{mebibytes:.2f} МиБ"
def format_duration(
seconds: float,
) -> str:
"""
Представить длительность в удобном виде.
"""
if seconds < 1:
return f"{seconds * 1000:.0f} мс"
if seconds < 60:
return f"{seconds:.2f} с"
if seconds < 3600:
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return (
f"{minutes} мин "
f"{remaining_seconds:.1f} с"
)
hours = int(seconds // 3600)
remaining_minutes = (
seconds % 3600
) / 60
return (
f"{hours} ч "
f"{remaining_minutes:.1f} мин"
)
# ============================================================
# Проверка исходного JPEG
# ============================================================
if not SOURCE_PATH.exists():
raise FileNotFoundError(
f"Не найден файл: {SOURCE_PATH}. "
"Сначала необходимо выполнить Lab009."
)
source_bytes = SOURCE_PATH.read_bytes()
if not source_bytes:
raise ValueError(
"Исходный JPEG пуст"
)
source_size = len(source_bytes)
# ============================================================
# Размер ACK
# ============================================================
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=0,
)
ack_packet_size_bytes = len(
ack_packet
)
ack_packet_size_bits = (
ack_packet_size_bytes * 8
)
# ============================================================
# Основной расчёт
# ============================================================
results = []
for fragment_size_index, fragment_size in enumerate(
FRAGMENT_SIZES
):
fragments = split_image_bytes(
image_bytes=source_bytes,
image_id=(
IMAGE_ID_BASE
+ fragment_size_index
),
fragment_data_size=fragment_size,
)
data_packet_sizes_bytes = []
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
data_packet = build_packet(
payload=fragment_payload,
message_type=(
MESSAGE_TYPE_IMAGE_FRAGMENT
),
sequence_number=(
fragment.fragment_index
),
)
data_packet_sizes_bytes.append(
len(data_packet)
)
ideal_data_bytes = sum(
data_packet_sizes_bytes
)
ideal_ack_bytes = (
len(fragments)
* ack_packet_size_bytes
)
ideal_total_bytes = (
ideal_data_bytes
+ ideal_ack_bytes
)
ideal_efficiency_percent = (
source_size
/ ideal_total_bytes
* 100
)
for eb_n0_db in EB_N0_VALUES_DB:
ber = theoretical_bpsk_ber(
eb_n0_db
)
ack_success_probability = (
packet_success_probability(
ber=ber,
packet_bit_count=(
ack_packet_size_bits
),
)
)
expected_data_bytes = 0.0
expected_ack_bytes = 0.0
expected_data_transmissions = 0.0
data_success_probabilities = []
for data_packet_size_bytes in (
data_packet_sizes_bytes
):
data_packet_size_bits = (
data_packet_size_bytes * 8
)
data_success_probability = (
packet_success_probability(
ber=ber,
packet_bit_count=(
data_packet_size_bits
),
)
)
data_success_probabilities.append(
data_success_probability
)
# Для успешного завершения попытки должны
# одновременно правильно пройти DATA и ACK.
confirmed_attempt_probability = (
data_success_probability
* ack_success_probability
)
if confirmed_attempt_probability == 0.0:
raise RuntimeError(
"Вероятность подтверждения "
"оказалась равной нулю"
)
# Число DATA-передач до успешного ACK
# подчиняется геометрическому распределению.
expected_attempt_count = (
1.0
/ confirmed_attempt_probability
)
expected_data_transmissions += (
expected_attempt_count
)
expected_data_bytes += (
data_packet_size_bytes
* expected_attempt_count
)
# ACK передаётся только после правильно
# принятого DATA.
#
# Среднее количество передач ACK до
# успешного ACK равно 1 / P_ACK.
expected_ack_bytes += (
ack_packet_size_bytes
/ ack_success_probability
)
expected_total_bytes = (
expected_data_bytes
+ expected_ack_bytes
)
expected_transfer_seconds = (
expected_total_bytes
* 8
/ CHANNEL_BITRATE_BPS
)
expected_efficiency_percent = (
source_size
/ expected_total_bytes
* 100
)
effective_goodput_bps = (
source_size
* 8
/ expected_transfer_seconds
)
average_data_transmissions = (
expected_data_transmissions
/ len(fragments)
)
minimum_data_success_probability = min(
data_success_probabilities
)
maximum_data_success_probability = max(
data_success_probabilities
)
results.append(
{
"fragment_size": fragment_size,
"fragment_count": len(fragments),
"eb_n0_db": eb_n0_db,
"ber": ber,
"ideal_total_bytes": (
ideal_total_bytes
),
"ideal_efficiency_percent": (
ideal_efficiency_percent
),
"ack_success_probability": (
ack_success_probability
),
"minimum_data_success_probability": (
minimum_data_success_probability
),
"maximum_data_success_probability": (
maximum_data_success_probability
),
"average_data_transmissions": (
average_data_transmissions
),
"expected_total_bytes": (
expected_total_bytes
),
"expected_transfer_seconds": (
expected_transfer_seconds
),
"expected_efficiency_percent": (
expected_efficiency_percent
),
"effective_goodput_bps": (
effective_goodput_bps
),
}
)
# ============================================================
# Вывод общих данных
# ============================================================
print(
"=== Lab015. Оптимальный размер фрагмента ==="
)
print("\nИсходный JPEG:")
print(SOURCE_PATH)
print("\nРазмер JPEG:")
print(
format_bytes(source_size)
)
print("\nСкорость физического канала:")
print(
CHANNEL_BITRATE_BPS / 1000,
"кбит/с",
)
print("\nРазмер ACK-пакета:")
print(
ack_packet_size_bytes,
"байт",
)
# ============================================================
# Вывод результатов отдельно для каждого Eb/N0
# ============================================================
for eb_n0_db in EB_N0_VALUES_DB:
selected_results = [
result
for result in results
if result["eb_n0_db"] == eb_n0_db
]
print(
f"\n--- Eb/N0 = {eb_n0_db:.1f} дБ ---"
)
print(
f"{'Фрагмент':>10}"
f"{'Кол-во':>9}"
f"{'P DATA':>12}"
f"{'Попыток':>11}"
f"{'Объём с ARQ':>16}"
f"{'Время':>16}"
f"{'Goodput':>13}"
)
print("-" * 87)
for result in selected_results:
print(
f"{result['fragment_size']:>8} Б"
f"{result['fragment_count']:>9}"
f"{result['minimum_data_success_probability']:>12.6f}"
f"{result['average_data_transmissions']:>11.2f}"
f"{format_bytes(result['expected_total_bytes']):>16}"
f"{format_duration(result['expected_transfer_seconds']):>16}"
f"{result['effective_goodput_bps'] / 1000:>10.2f} кбит/с"
)
optimum_result = min(
selected_results,
key=lambda item: (
item["expected_transfer_seconds"]
),
)
print(
"\nОптимальный размер фрагмента:"
)
print(
optimum_result["fragment_size"],
"байт",
)
print(
"Ожидаемое время:",
format_duration(
optimum_result[
"expected_transfer_seconds"
]
),
)
print(
"Полезная скорость:",
f"{optimum_result['effective_goodput_bps'] / 1000:.2f}",
"кбит/с",
)
# ============================================================
# Сохранение CSV
# ============================================================
with CSV_PATH.open(
"w",
newline="",
encoding="utf-8-sig",
) as csv_file:
fieldnames = [
"fragment_size",
"fragment_count",
"eb_n0_db",
"ber",
"ideal_total_bytes",
"ideal_efficiency_percent",
"ack_success_probability",
"minimum_data_success_probability",
"maximum_data_success_probability",
"average_data_transmissions",
"expected_total_bytes",
"expected_transfer_seconds",
"expected_efficiency_percent",
"effective_goodput_bps",
]
writer = DictWriter(
csv_file,
fieldnames=fieldnames,
)
writer.writeheader()
writer.writerows(results)
# ============================================================
# График ожидаемого времени передачи
# ============================================================
plt.figure(
figsize=(10, 7)
)
for eb_n0_db in EB_N0_VALUES_DB:
selected_results = [
result
for result in results
if result["eb_n0_db"] == eb_n0_db
]
fragment_sizes = [
result["fragment_size"]
for result in selected_results
]
transfer_times = [
result["expected_transfer_seconds"]
for result in selected_results
]
plt.plot(
fragment_sizes,
transfer_times,
marker="o",
label=f"Eb/N0 = {eb_n0_db:.0f} дБ",
)
plt.xscale(
"log",
base=2,
)
plt.yscale(
"log",
)
plt.xlabel(
"Размер данных фрагмента, байт"
)
plt.ylabel(
"Ожидаемое время передачи, с"
)
plt.title(
"Влияние размера фрагмента "
"на время передачи JPEG"
)
plt.grid(
True,
which="both",
)
plt.legend()
plt.tight_layout()
plt.savefig(
TIME_GRAPH_PATH,
dpi=160,
)
plt.close()
# ============================================================
# График эффективной полезной скорости
# ============================================================
plt.figure(
figsize=(10, 7)
)
for eb_n0_db in EB_N0_VALUES_DB:
selected_results = [
result
for result in results
if result["eb_n0_db"] == eb_n0_db
]
fragment_sizes = [
result["fragment_size"]
for result in selected_results
]
goodput_values = [
result["effective_goodput_bps"] / 1000
for result in selected_results
]
plt.plot(
fragment_sizes,
goodput_values,
marker="o",
label=f"Eb/N0 = {eb_n0_db:.0f} дБ",
)
plt.xscale(
"log",
base=2,
)
plt.xlabel(
"Размер данных фрагмента, байт"
)
plt.ylabel(
"Полезная скорость, кбит/с"
)
plt.title(
"Полезная скорость JPEG "
"с учётом CRC и ARQ"
)
plt.grid(
True,
which="both",
)
plt.legend()
plt.tight_layout()
plt.savefig(
GOODPUT_GRAPH_PATH,
dpi=160,
)
plt.close()
# ============================================================
# Автоматические проверки
# ============================================================
assert results
assert CSV_PATH.exists()
assert TIME_GRAPH_PATH.exists()
assert GOODPUT_GRAPH_PATH.exists()
assert all(
result["expected_transfer_seconds"] > 0
for result in results
)
assert all(
0.0
< result["ack_success_probability"]
<= 1.0
for result in results
)
print("\nCSV:")
print(CSV_PATH)
print("\nГрафик времени:")
print(TIME_GRAPH_PATH)
print("\nГрафик полезной скорости:")
print(GOODPUT_GRAPH_PATH)
print(
"\nПроверка пройдена: "
"оптимальный размер фрагмента рассчитан."
)

View File

@@ -0,0 +1,605 @@
"""
Lab016. Адаптивный выбор размера фрагмента изображения.
Программа имитирует изменение качества канала во времени.
Для каждой оценки Eb/N0 передатчик решает:
- отключить изображения;
- использовать 128 байт;
- использовать 512 байт;
- использовать 1024 байта.
Дополнительно рассчитывается ожидаемое время передачи
реального JPEG-файла.
"""
from csv import DictWriter
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from protocol.image_fragments import (
encode_image_fragment,
split_image_bytes,
)
from protocol.link_adaptation import (
choose_image_mode,
packet_success_probability,
)
from protocol.packet import (
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
build_packet,
)
# ============================================================
# Настройки
# ============================================================
CHANNEL_BITRATE_BPS = 20_000
MAX_ATTEMPTS = 5
CANDIDATE_FRAGMENT_SIZES = (
128,
512,
1024,
)
# Изменение качества канала во времени.
EB_N0_PROFILE_DB = [
12.0,
10.0,
9.0,
8.0,
7.0,
6.0,
7.0,
8.0,
9.0,
10.0,
12.0,
]
OUTPUT_DIRECTORY = Path(
"data/processed/lab016"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
CSV_PATH = (
OUTPUT_DIRECTORY
/ "lab016_adaptation_results.csv"
)
MODE_GRAPH_PATH = (
OUTPUT_DIRECTORY
/ "lab016_selected_mode.png"
)
TIME_GRAPH_PATH = (
OUTPUT_DIRECTORY
/ "lab016_expected_transfer_time.png"
)
# ============================================================
# Выбор исходного кадра
# ============================================================
source_candidates = [
Path(
"data/processed/lab012/"
"03_color_320_q15.jpg"
),
Path(
"data/raw/lab009_source.jpg"
),
]
SOURCE_PATH = next(
(
path
for path in source_candidates
if path.exists()
),
None,
)
if SOURCE_PATH is None:
raise FileNotFoundError(
"Не найден кадр для передачи. "
"Необходимо выполнить Lab009 или Lab012."
)
source_bytes = SOURCE_PATH.read_bytes()
if not source_bytes:
raise ValueError(
"Исходный JPEG пуст"
)
# ============================================================
# Оценка передачи всего изображения
# ============================================================
def estimate_image_transfer(
image_bytes: bytes,
fragment_size: int,
ber: float,
image_id: int,
) -> dict:
"""
Оценить передачу полного изображения с бесконечным ARQ.
Расчёт учитывает фактический размер последнего фрагмента.
"""
fragments = split_image_bytes(
image_bytes=image_bytes,
image_id=image_id,
fragment_data_size=fragment_size,
)
ack_packet = build_packet(
payload=b"",
message_type=MESSAGE_TYPE_ACK,
sequence_number=0,
)
ack_size_bytes = len(
ack_packet
)
ack_success_probability = (
packet_success_probability(
ber=ber,
packet_bit_count=(
ack_size_bytes * 8
),
)
)
expected_total_bytes = 0.0
for fragment in fragments:
fragment_payload = encode_image_fragment(
fragment
)
data_packet = build_packet(
payload=fragment_payload,
message_type=(
MESSAGE_TYPE_IMAGE_FRAGMENT
),
sequence_number=(
fragment.fragment_index
),
)
data_success_probability = (
packet_success_probability(
ber=ber,
packet_bit_count=(
len(data_packet) * 8
),
)
)
confirmed_probability = (
data_success_probability
* ack_success_probability
)
expected_total_bytes += (
len(data_packet)
/ confirmed_probability
)
expected_total_bytes += (
ack_size_bytes
/ ack_success_probability
)
expected_seconds = (
expected_total_bytes
* 8
/ CHANNEL_BITRATE_BPS
)
effective_goodput_bps = (
len(image_bytes)
* 8
/ expected_seconds
)
return {
"fragment_count": len(fragments),
"expected_total_bytes": expected_total_bytes,
"expected_seconds": expected_seconds,
"effective_goodput_bps": (
effective_goodput_bps
),
}
# ============================================================
# Адаптация по профилю канала
# ============================================================
results = []
for step_index, eb_n0_db in enumerate(
EB_N0_PROFILE_DB
):
decision = choose_image_mode(
eb_n0_db=eb_n0_db,
candidate_fragment_sizes=(
CANDIDATE_FRAGMENT_SIZES
),
channel_bitrate_bps=(
CHANNEL_BITRATE_BPS
),
max_attempts=MAX_ATTEMPTS,
minimum_success_probability=0.85,
minimum_goodput_bps=2_000.0,
)
if decision.images_enabled:
selected_estimate = next(
estimate
for estimate in decision.estimates
if (
estimate.fragment_size
== decision.selected_fragment_size
)
)
image_result = estimate_image_transfer(
image_bytes=source_bytes,
fragment_size=(
decision.selected_fragment_size
),
ber=selected_estimate.ber,
image_id=2026071700 + step_index,
)
fragment_size = (
decision.selected_fragment_size
)
fragment_count = (
image_result["fragment_count"]
)
expected_seconds = (
image_result["expected_seconds"]
)
effective_goodput_bps = (
image_result[
"effective_goodput_bps"
]
)
success_with_retries = (
selected_estimate
.success_probability_with_retries
)
expected_attempts = (
selected_estimate.expected_attempts
)
mode_name = (
f"{fragment_size} B"
)
else:
fragment_size = 0
fragment_count = 0
expected_seconds = None
effective_goodput_bps = 0.0
success_with_retries = 0.0
expected_attempts = 0.0
mode_name = "IMAGE OFF"
results.append(
{
"step": step_index,
"eb_n0_db": eb_n0_db,
"mode": mode_name,
"fragment_size": fragment_size,
"fragment_count": fragment_count,
"expected_seconds": expected_seconds,
"effective_goodput_bps": (
effective_goodput_bps
),
"success_with_retries": (
success_with_retries
),
"expected_attempts": (
expected_attempts
),
"reason": decision.reason,
}
)
# ============================================================
# Вывод
# ============================================================
print(
"=== Lab016. Адаптация размера фрагмента ==="
)
print("\nИсходный кадр:")
print(SOURCE_PATH)
print("\nРазмер JPEG:")
print(
len(source_bytes),
"байт",
)
print("\nРезультаты адаптации:")
print(
f"{'Шаг':>5}"
f"{'Eb/N0':>10}"
f"{'Режим':>14}"
f"{'Фрагм.':>9}"
f"{'Попыток':>11}"
f"{'Успех x5':>12}"
f"{'Время кадра':>15}"
f"{'Goodput':>13}"
)
print("-" * 89)
for result in results:
if result["expected_seconds"] is None:
time_text = ""
else:
time_text = (
f"{result['expected_seconds']:.2f} с"
)
print(
f"{result['step']:>5}"
f"{result['eb_n0_db']:>7.1f} дБ"
f"{result['mode']:>14}"
f"{result['fragment_count']:>9}"
f"{result['expected_attempts']:>11.2f}"
f"{result['success_with_retries'] * 100:>10.1f} %"
f"{time_text:>15}"
f"{result['effective_goodput_bps'] / 1000:>10.2f} кбит/с"
)
# ============================================================
# Сохранение CSV
# ============================================================
with CSV_PATH.open(
"w",
newline="",
encoding="utf-8-sig",
) as csv_file:
fieldnames = list(
results[0].keys()
)
writer = DictWriter(
csv_file,
fieldnames=fieldnames,
)
writer.writeheader()
writer.writerows(results)
# ============================================================
# График выбранного режима
# ============================================================
steps = [
result["step"]
for result in results
]
fragment_sizes = [
result["fragment_size"]
for result in results
]
plt.figure(
figsize=(11, 6)
)
plt.step(
steps,
fragment_sizes,
where="mid",
marker="o",
)
plt.yticks(
[
0,
128,
512,
1024,
],
[
"IMAGE OFF",
"128 B",
"512 B",
"1024 B",
],
)
plt.xlabel(
"Шаг времени"
)
plt.ylabel(
"Выбранный режим"
)
plt.title(
"Автоматический выбор размера фрагмента"
)
plt.grid(
True
)
plt.tight_layout()
plt.savefig(
MODE_GRAPH_PATH,
dpi=160,
)
plt.close()
# ============================================================
# График времени передачи кадра
# ============================================================
transfer_times = np.array(
[
(
result["expected_seconds"]
if result["expected_seconds"] is not None
else np.nan
)
for result in results
],
dtype=np.float64,
)
plt.figure(
figsize=(11, 6)
)
plt.plot(
steps,
transfer_times,
marker="o",
)
plt.xlabel(
"Шаг времени"
)
plt.ylabel(
"Ожидаемое время передачи кадра, с"
)
plt.title(
"Время передачи кадра при адаптации канала"
)
plt.grid(
True
)
plt.tight_layout()
plt.savefig(
TIME_GRAPH_PATH,
dpi=160,
)
plt.close()
# ============================================================
# Проверки ожидаемой логики
# ============================================================
decision_at_6_db = next(
result
for result in results
if result["eb_n0_db"] == 6.0
)
decision_at_8_db = next(
result
for result in results
if result["eb_n0_db"] == 8.0
)
decision_at_9_db = next(
result
for result in results
if result["eb_n0_db"] == 9.0
)
decision_at_10_db = next(
result
for result in results
if result["eb_n0_db"] == 10.0
)
assert (
decision_at_6_db["fragment_size"]
== 0
)
assert (
decision_at_8_db["fragment_size"]
== 128
)
assert (
decision_at_9_db["fragment_size"]
== 512
)
assert (
decision_at_10_db["fragment_size"]
== 1024
)
assert CSV_PATH.exists()
assert MODE_GRAPH_PATH.exists()
assert TIME_GRAPH_PATH.exists()
print("\nCSV:")
print(CSV_PATH)
print("\nГрафик режимов:")
print(MODE_GRAPH_PATH)
print("\nГрафик времени:")
print(TIME_GRAPH_PATH)
print(
"\nПроверка пройдена: "
"адаптивный выбор режима работает."
)

View File

@@ -0,0 +1,468 @@
"""
Lab017. Фильтрация Eb/N0 и гистерезис режимов.
Эксперимент сравнивает два адаптера:
1. Наивный адаптер:
переключает режим при каждом пересечении порога.
2. Устойчивый адаптер:
использует:
- экспоненциальное усреднение;
- гистерезис;
- подтверждение повышения;
- аварийное быстрое понижение.
Профиль Eb/N0 специально содержит колебания
около порогов переключения.
"""
from csv import DictWriter
from pathlib import Path
import matplotlib.pyplot as plt
from protocol.link_controller import (
FRAGMENT_MODE_1024,
IMAGE_OFF,
LinkModeController,
)
# ============================================================
# Настройки
# ============================================================
OUTPUT_DIRECTORY = Path(
"data/processed/lab017"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
CSV_PATH = (
OUTPUT_DIRECTORY
/ "lab017_hysteresis_results.csv"
)
GRAPH_PATH = (
OUTPUT_DIRECTORY
/ "lab017_hysteresis_comparison.png"
)
# ============================================================
# Профиль качества канала
# ============================================================
EB_N0_PROFILE_DB = [
10.2,
9.7,
9.4,
9.6,
9.3,
9.7,
9.2,
9.6,
9.0,
8.7,
8.4,
8.6,
8.3,
8.7,
8.2,
8.6,
8.0,
7.8,
7.5,
7.7,
7.4,
7.8,
7.2,
6.8,
6.5,
6.3,
6.6,
6.2,
6.7,
6.1,
5.5,
6.0,
6.4,
6.7,
6.9,
7.1,
7.3,
8.0,
8.8,
9.0,
9.4,
9.8,
10.0,
10.2,
10.1,
]
# ============================================================
# Наивное решение без гистерезиса
# ============================================================
def choose_naive_mode(
eb_n0_db: float,
) -> int:
"""
Немедленно выбрать режим по одному измерению.
Гистерезиса и фильтрации нет.
"""
if eb_n0_db < 6.5:
return 0
if eb_n0_db < 8.5:
return 128
if eb_n0_db < 9.5:
return 512
return 1024
# ============================================================
# Запуск устойчивого контроллера
# ============================================================
controller = LinkModeController(
initial_mode=FRAGMENT_MODE_1024,
filter_alpha=0.35,
upgrade_confirmation_count=3,
minimum_hold_steps=2,
)
results = []
for measured_eb_n0_db in EB_N0_PROFILE_DB:
stable_result = controller.update(
measured_eb_n0_db
)
naive_mode = choose_naive_mode(
measured_eb_n0_db
)
results.append(
{
"step": stable_result.step,
"measured_eb_n0_db": (
stable_result.measured_eb_n0_db
),
"filtered_eb_n0_db": (
stable_result.filtered_eb_n0_db
),
"naive_mode": naive_mode,
"stable_mode": (
stable_result.selected_mode
),
"mode_changed": (
stable_result.mode_changed
),
"pending_upgrade_mode": (
stable_result.pending_upgrade_mode
),
"pending_upgrade_count": (
stable_result.pending_upgrade_count
),
"reason": stable_result.reason,
}
)
# ============================================================
# Число переключений
# ============================================================
def count_mode_switches(
modes: list[int],
) -> int:
"""
Посчитать число изменений режима.
"""
return sum(
previous_mode != current_mode
for previous_mode, current_mode in zip(
modes,
modes[1:],
)
)
naive_modes = [
result["naive_mode"]
for result in results
]
stable_modes = [
result["stable_mode"]
for result in results
]
naive_switch_count = count_mode_switches(
naive_modes
)
stable_switch_count = count_mode_switches(
stable_modes
)
# ============================================================
# Вывод результатов
# ============================================================
print(
"=== Lab017. Гистерезис режима канала ==="
)
print("\nРезультаты по шагам:")
print(
f"{'Шаг':>5}"
f"{'Eb/N0':>10}"
f"{'Фильтр':>11}"
f"{'Наивный':>11}"
f"{'Устойчивый':>13}"
f"{'Изменён':>10}"
f"{'Причина':>45}"
)
print("-" * 105)
for result in results:
print(
f"{result['step']:>5}"
f"{result['measured_eb_n0_db']:>7.1f} дБ"
f"{result['filtered_eb_n0_db']:>8.2f} дБ"
f"{result['naive_mode']:>11}"
f"{result['stable_mode']:>13}"
f"{str(result['mode_changed']):>10}"
f" {result['reason']}"
)
print("\nЧисло переключений:")
print(
"Наивный адаптер:",
naive_switch_count,
)
print(
"Устойчивый адаптер:",
stable_switch_count,
)
print(
"Предотвращено лишних переключений:",
naive_switch_count - stable_switch_count,
)
# ============================================================
# Сохранение 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)
# ============================================================
# График
# ============================================================
steps = [
result["step"]
for result in results
]
measured_values = [
result["measured_eb_n0_db"]
for result in results
]
filtered_values = [
result["filtered_eb_n0_db"]
for result in results
]
figure, axes = plt.subplots(
2,
1,
figsize=(12, 9),
sharex=True,
)
# ------------------------------------------------------------
# Верхний график: Eb/N0
# ------------------------------------------------------------
axes[0].plot(
steps,
measured_values,
marker=".",
label="Измеренное Eb/N0",
)
axes[0].plot(
steps,
filtered_values,
linewidth=2,
label="Фильтрованное Eb/N0",
)
axes[0].axhline(
6.5,
linestyle=":",
label="Порог наивного адаптера",
)
axes[0].axhline(
8.5,
linestyle=":",
)
axes[0].axhline(
9.5,
linestyle=":",
)
axes[0].set_ylabel(
"Eb/N0, дБ"
)
axes[0].set_title(
"Фильтрация измерения качества канала"
)
axes[0].grid(
True
)
axes[0].legend()
# ------------------------------------------------------------
# Нижний график: режимы
# ------------------------------------------------------------
axes[1].step(
steps,
naive_modes,
where="mid",
marker=".",
label="Наивный адаптер",
)
axes[1].step(
steps,
stable_modes,
where="mid",
marker="o",
label="Гистерезис и фильтрация",
)
axes[1].set_yticks(
[
0,
128,
512,
1024,
],
[
"IMAGE OFF",
"128 B",
"512 B",
"1024 B",
],
)
axes[1].set_xlabel(
"Шаг времени"
)
axes[1].set_ylabel(
"Режим"
)
axes[1].set_title(
"Сравнение переключений режимов"
)
axes[1].grid(
True
)
axes[1].legend()
figure.tight_layout()
figure.savefig(
GRAPH_PATH,
dpi=160,
)
plt.close(
figure
)
# ============================================================
# Проверки
# ============================================================
assert results
assert IMAGE_OFF in stable_modes
assert FRAGMENT_MODE_1024 in stable_modes
assert stable_switch_count < naive_switch_count
assert CSV_PATH.exists()
assert GRAPH_PATH.exists()
print("\nCSV:")
print(CSV_PATH)
print("\nГрафик:")
print(GRAPH_PATH)
print(
"\nПроверка пройдена: "
"гистерезис уменьшил количество переключений."
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
"""
Lab024A. Первый приём реальных IQ-сэмплов с Pluto+.
Схема:
антенна 40860 МГц -> RX1
Передатчики TX1 и TX2 не используются.
"""
from pathlib import Path
import json
import adi
import matplotlib.pyplot as plt
import numpy as np
# ---------------------------------------------------------------------
# Параметры приёмника
# ---------------------------------------------------------------------
PLUTO_URI = "ip:192.168.2.1"
CENTER_FREQUENCY_HZ = 100_000_000
SAMPLE_RATE_HZ = 2_400_000
RX_BANDWIDTH_HZ = 2_000_000
RX_BUFFER_SIZE = 262_144
OUTPUT_DIRECTORY = Path("data/raw")
IQ_FILE_PATH = OUTPUT_DIRECTORY / "lab024a_pluto_rx_100mhz.npy"
METADATA_FILE_PATH = OUTPUT_DIRECTORY / "lab024a_pluto_rx_100mhz.json"
SPECTRUM_FILE_PATH = OUTPUT_DIRECTORY / "lab024a_pluto_rx_100mhz_spectrum.png"
def calculate_spectrum(
samples: np.ndarray,
sample_rate_hz: float,
center_frequency_hz: float,
) -> tuple[np.ndarray, np.ndarray]:
"""
Рассчитывает спектр принятого комплексного IQ-сигнала.
Возвращает:
frequencies_hz — абсолютные радиочастоты;
power_db — относительная мощность спектра в дБ.
"""
sample_count = len(samples)
window = np.hanning(sample_count)
windowed_samples = samples * window
spectrum = np.fft.fftshift(np.fft.fft(windowed_samples))
power = np.abs(spectrum) ** 2
power_db = 10.0 * np.log10(power + 1e-12)
power_db -= np.max(power_db)
baseband_frequencies_hz = np.fft.fftshift(
np.fft.fftfreq(sample_count, d=1.0 / sample_rate_hz)
)
absolute_frequencies_hz = (
center_frequency_hz + baseband_frequencies_hz
)
return absolute_frequencies_hz, power_db
def main() -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
print("Подключение к Pluto+...")
sdr = adi.Pluto(uri=PLUTO_URI)
# Используем только первый приёмный канал RX1.
sdr.rx_enabled_channels = [0]
sdr.sample_rate = SAMPLE_RATE_HZ
sdr.rx_lo = CENTER_FREQUENCY_HZ
sdr.rx_rf_bandwidth = RX_BANDWIDTH_HZ
# Автоматическая регулировка усиления.
sdr.gain_control_mode_chan0 = "slow_attack"
sdr.rx_buffer_size = RX_BUFFER_SIZE
print()
print("Параметры приёмника:")
print(f" URI: {PLUTO_URI}")
print(f" Центральная частота: {sdr.rx_lo / 1e6:.3f} МГц")
print(f" Частота дискретизации: {sdr.sample_rate / 1e6:.3f} Мвыб/с")
print(f" Полоса RX: {sdr.rx_rf_bandwidth / 1e6:.3f} МГц")
print(f" Режим усиления: {sdr.gain_control_mode_chan0}")
print(f" Размер буфера: {sdr.rx_buffer_size} отсчётов")
print()
print("Получение IQ-сэмплов...")
# Первый буфер после перенастройки иногда содержит переходный процесс.
_ = sdr.rx()
# Второй буфер сохраняем и анализируем.
samples = np.asarray(sdr.rx(), dtype=np.complex64)
print("IQ-сэмплы получены.")
np.save(IQ_FILE_PATH, samples)
mean_value = np.mean(samples)
rms_value = np.sqrt(np.mean(np.abs(samples) ** 2))
peak_value = np.max(np.abs(samples))
metadata = {
"pluto_uri": PLUTO_URI,
"center_frequency_hz": int(sdr.rx_lo),
"sample_rate_hz": int(sdr.sample_rate),
"rx_bandwidth_hz": int(sdr.rx_rf_bandwidth),
"gain_control_mode": sdr.gain_control_mode_chan0,
"sample_count": int(len(samples)),
"sample_dtype": str(samples.dtype),
"mean_i": float(np.real(mean_value)),
"mean_q": float(np.imag(mean_value)),
"rms": float(rms_value),
"peak": float(peak_value),
}
with METADATA_FILE_PATH.open("w", encoding="utf-8") as metadata_file:
json.dump(metadata, metadata_file, ensure_ascii=False, indent=4)
frequencies_hz, power_db = calculate_spectrum(
samples=samples,
sample_rate_hz=float(sdr.sample_rate),
center_frequency_hz=float(sdr.rx_lo),
)
plt.figure(figsize=(12, 6))
plt.plot(frequencies_hz / 1e6, power_db)
plt.title("Lab024A. Спектр сигнала, принятого Pluto+")
plt.xlabel("Частота, МГц")
plt.ylabel("Относительная мощность, дБ")
plt.grid(True)
plt.ylim(-100, 5)
plt.tight_layout()
plt.savefig(SPECTRUM_FILE_PATH, dpi=150)
plt.show()
print()
print("Статистика:")
print(f" Количество сэмплов: {len(samples)}")
print(f" Тип данных: {samples.dtype}")
print(f" Среднее I: {np.real(mean_value):.3f}")
print(f" Среднее Q: {np.imag(mean_value):.3f}")
print(f" RMS: {rms_value:.3f}")
print(f" Пиковая амплитуда: {peak_value:.3f}")
print()
print("Созданы файлы:")
print(f" IQ: {IQ_FILE_PATH}")
print(f" Метаданные:{METADATA_FILE_PATH}")
print(f" Спектр: {SPECTRUM_FILE_PATH}")
print()
print("Lab024A выполнена успешно.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,620 @@
"""
Lab024B. Усреднённый спектр Pluto+ методом Уэлча.
Схема подключения:
антенна 40860 МГц -> RX1
Передатчики TX1 и TX2 не используются.
"""
from pathlib import Path
import csv
import adi
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import find_peaks, welch
# ---------------------------------------------------------------------
# Настройки Pluto+
# ---------------------------------------------------------------------
PLUTO_URI = "ip:192.168.2.1"
CENTER_FREQUENCY_HZ = 100_000_000
SAMPLE_RATE_HZ = 2_400_000
RX_BANDWIDTH_HZ = 2_000_000
RX_BUFFER_SIZE = 65_536
BUFFER_COUNT = 16
DISCARD_BUFFER_COUNT = 2
# ---------------------------------------------------------------------
# Настройки спектрального анализа
# ---------------------------------------------------------------------
WELCH_SEGMENT_LENGTH = 8_192
WELCH_OVERLAP_LENGTH = 4_096
SMOOTHING_BANDWIDTH_HZ = 40_000
MINIMUM_PEAK_DISTANCE_HZ = 120_000
MINIMUM_PEAK_PROMINENCE_DB = 4.0
MINIMUM_PEAK_HEIGHT_DB = -25.0
DC_EXCLUSION_HALF_WIDTH_HZ = 40_000
MAXIMUM_CANDIDATE_COUNT = 12
# ---------------------------------------------------------------------
# Выходные файлы
# ---------------------------------------------------------------------
OUTPUT_DIRECTORY = Path("data/processed/lab024b")
SPECTRUM_CSV_PATH = OUTPUT_DIRECTORY / "lab024b_welch_spectrum.csv"
CANDIDATES_CSV_PATH = OUTPUT_DIRECTORY / "lab024b_fm_candidates.csv"
GRAPH_PATH = OUTPUT_DIRECTORY / "lab024b_welch_spectrum.png"
REPORT_PATH = OUTPUT_DIRECTORY / "lab024b_report.txt"
def configure_receiver() -> adi.Pluto:
"""
Подключается к Pluto+ и настраивает приёмник RX1.
"""
print("Подключение к Pluto+...")
sdr = adi.Pluto(uri=PLUTO_URI)
# Используем первый приёмный канал.
sdr.rx_enabled_channels = [0]
sdr.sample_rate = SAMPLE_RATE_HZ
sdr.rx_lo = CENTER_FREQUENCY_HZ
sdr.rx_rf_bandwidth = RX_BANDWIDTH_HZ
# Автоматическая регулировка усиления.
sdr.gain_control_mode_chan0 = "slow_attack"
sdr.rx_buffer_size = RX_BUFFER_SIZE
return sdr
def receive_samples(sdr: adi.Pluto) -> np.ndarray:
"""
Получает несколько последовательных IQ-буферов.
Первые буферы отбрасываются, поскольку сразу после настройки
приёмника могут наблюдаться переходные процессы АРУ и фильтров.
"""
print()
print("Отбрасывание переходных буферов...")
for _ in range(DISCARD_BUFFER_COUNT):
_ = sdr.rx()
received_buffers: list[np.ndarray] = []
print("Получение рабочих IQ-буферов...")
for buffer_number in range(1, BUFFER_COUNT + 1):
samples = np.asarray(sdr.rx(), dtype=np.complex64)
received_buffers.append(samples)
print(
f" Буфер {buffer_number:02d}/{BUFFER_COUNT}: "
f"{len(samples)} отсчётов"
)
combined_samples = np.concatenate(received_buffers)
if len(combined_samples) == 0:
raise RuntimeError("Pluto+ не вернул IQ-сэмплы.")
if not np.all(np.isfinite(combined_samples)):
raise RuntimeError("В IQ-буфере обнаружены NaN или Inf.")
return combined_samples
def calculate_welch_spectrum(
samples: np.ndarray,
sample_rate_hz: float,
center_frequency_hz: float,
) -> tuple[np.ndarray, np.ndarray]:
"""
Рассчитывает спектральную плотность мощности методом Уэлча.
Возвращает:
absolute_frequencies_hz — абсолютные радиочастоты;
power_density — мощность в линейном масштабе.
"""
baseband_frequencies_hz, power_density = welch(
samples,
fs=sample_rate_hz,
window="hann",
nperseg=WELCH_SEGMENT_LENGTH,
noverlap=WELCH_OVERLAP_LENGTH,
detrend=False,
return_onesided=False,
scaling="density",
)
baseband_frequencies_hz = np.fft.fftshift(
baseband_frequencies_hz
)
power_density = np.fft.fftshift(power_density)
absolute_frequencies_hz = (
center_frequency_hz + baseband_frequencies_hz
)
return absolute_frequencies_hz, power_density
def smooth_power_spectrum(
power_density: np.ndarray,
frequency_step_hz: float,
) -> np.ndarray:
"""
Сглаживает спектр скользящим средним в линейном масштабе.
Усреднять следует мощность, а не значения в децибелах.
"""
smoothing_bin_count = int(
round(SMOOTHING_BANDWIDTH_HZ / frequency_step_hz)
)
smoothing_bin_count = max(1, smoothing_bin_count)
# Нечётная длина ядра обеспечивает симметрию относительно центра.
if smoothing_bin_count % 2 == 0:
smoothing_bin_count += 1
kernel = (
np.ones(smoothing_bin_count, dtype=np.float64)
/ smoothing_bin_count
)
smoothed_power = np.convolve(
power_density,
kernel,
mode="same",
)
return smoothed_power
def convert_power_to_relative_db(
raw_power: np.ndarray,
smoothed_power: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""
Переводит мощность в относительные децибелы.
0 дБ соответствует максимальному значению сглаженного спектра.
"""
minimum_positive_value = np.finfo(np.float64).tiny
raw_power_db = 10.0 * np.log10(
raw_power + minimum_positive_value
)
smoothed_power_db = 10.0 * np.log10(
smoothed_power + minimum_positive_value
)
reference_power_db = np.max(smoothed_power_db)
raw_relative_db = raw_power_db - reference_power_db
smoothed_relative_db = smoothed_power_db - reference_power_db
return raw_relative_db, smoothed_relative_db
def detect_signal_candidates(
frequencies_hz: np.ndarray,
smoothed_power_db: np.ndarray,
center_frequency_hz: float,
) -> list[dict[str, float]]:
"""
Ищет локальные максимумы сглаженного спектра.
Максимумы сначала рассчитываются по исходному спектру.
После этого отбрасываются точки из DC-зоны и с краёв диапазона.
Такой порядок не создаёт искусственных провалов -200 дБ
и не искажает значение prominence.
"""
frequency_step_hz = float(
np.mean(np.diff(frequencies_hz))
)
minimum_distance_bins = max(
1,
int(
round(
MINIMUM_PEAK_DISTANCE_HZ
/ abs(frequency_step_hz)
)
),
)
edge_bin_count = max(
1,
int(
round(
SMOOTHING_BANDWIDTH_HZ
/ abs(frequency_step_hz)
)
),
)
# Важно: find_peaks получает настоящий спектр без вставок -200 дБ.
peak_indices, peak_properties = find_peaks(
smoothed_power_db,
height=MINIMUM_PEAK_HEIGHT_DB,
prominence=MINIMUM_PEAK_PROMINENCE_DB,
distance=minimum_distance_bins,
)
candidates: list[dict[str, float]] = []
for peak_number, peak_index in enumerate(peak_indices):
frequency_hz = float(frequencies_hz[peak_index])
# Отбрасываем возможный аппаратный DC-пик.
if (
abs(frequency_hz - center_frequency_hz)
<= DC_EXCLUSION_HALF_WIDTH_HZ
):
continue
# Отбрасываем максимумы возле краёв наблюдаемого диапазона.
if peak_index < edge_bin_count:
continue
if peak_index >= len(smoothed_power_db) - edge_bin_count:
continue
candidates.append(
{
"frequency_hz": frequency_hz,
"relative_power_db": float(
smoothed_power_db[peak_index]
),
"prominence_db": float(
peak_properties["prominences"][peak_number]
),
}
)
# Оставляем наиболее выраженные сигналы.
candidates.sort(
key=lambda item: item["prominence_db"],
reverse=True,
)
candidates = candidates[:MAXIMUM_CANDIDATE_COUNT]
# В итоговой таблице располагаем сигналы по частоте.
candidates.sort(
key=lambda item: item["frequency_hz"]
)
return candidates
def save_spectrum_csv(
frequencies_hz: np.ndarray,
raw_power_db: np.ndarray,
smoothed_power_db: np.ndarray,
) -> None:
"""
Сохраняет полный рассчитанный спектр в CSV-файл.
Для каждой частотной точки сохраняются:
- частота в герцах;
- частота в мегагерцах;
- исходная относительная мощность;
- сглаженная относительная мощность.
"""
with SPECTRUM_CSV_PATH.open(
"w",
encoding="utf-8",
newline="",
) as csv_file:
writer = csv.writer(csv_file)
writer.writerow(
[
"frequency_hz",
"frequency_mhz",
"raw_relative_power_db",
"smoothed_relative_power_db",
]
)
for frequency_hz, raw_db, smoothed_db in zip(
frequencies_hz,
raw_power_db,
smoothed_power_db,
):
writer.writerow(
[
f"{frequency_hz:.3f}",
f"{frequency_hz / 1e6:.6f}",
f"{raw_db:.6f}",
f"{smoothed_db:.6f}",
]
)
def save_candidates_csv(
candidates: list[dict[str, float]],
) -> None:
"""
Сохраняет найденные кандидаты на радиосигналы.
"""
with CANDIDATES_CSV_PATH.open(
"w",
encoding="utf-8",
newline="",
) as csv_file:
writer = csv.writer(csv_file)
writer.writerow(
[
"frequency_hz",
"frequency_mhz",
"relative_power_db",
"prominence_db",
]
)
for candidate in candidates:
writer.writerow(
[
f"{candidate['frequency_hz']:.3f}",
f"{candidate['frequency_hz'] / 1e6:.6f}",
f"{candidate['relative_power_db']:.3f}",
f"{candidate['prominence_db']:.3f}",
]
)
def save_report(
sample_count: int,
candidates: list[dict[str, float]],
) -> None:
"""
Создаёт текстовый отчёт лабораторной.
"""
report_lines = [
"Lab024B. Усреднённый спектр Pluto+ методом Уэлча",
"",
f"URI: {PLUTO_URI}",
f"Центральная частота: {CENTER_FREQUENCY_HZ / 1e6:.3f} МГц",
f"Частота дискретизации: {SAMPLE_RATE_HZ / 1e6:.3f} Мвыб/с",
f"Полоса RX: {RX_BANDWIDTH_HZ / 1e6:.3f} МГц",
f"Количество IQ-сэмплов: {sample_count}",
f"Размер сегмента Уэлча: {WELCH_SEGMENT_LENGTH}",
f"Перекрытие сегментов: {WELCH_OVERLAP_LENGTH}",
"",
f"Найдено кандидатов: {len(candidates)}",
"",
]
for candidate_number, candidate in enumerate(
candidates,
start=1,
):
report_lines.append(
f"{candidate_number:02d}. "
f"{candidate['frequency_hz'] / 1e6:.6f} МГц, "
f"уровень {candidate['relative_power_db']:.2f} дБ, "
f"выраженность {candidate['prominence_db']:.2f} дБ"
)
REPORT_PATH.write_text(
"\n".join(report_lines),
encoding="utf-8",
)
def create_graph(
frequencies_hz: np.ndarray,
raw_power_db: np.ndarray,
smoothed_power_db: np.ndarray,
candidates: list[dict[str, float]],
) -> None:
"""
Строит исходный и сглаженный спектры.
"""
plt.figure(figsize=(13, 7))
plt.plot(
frequencies_hz / 1e6,
raw_power_db,
linewidth=0.6,
alpha=0.45,
label="Спектр Уэлча",
)
plt.plot(
frequencies_hz / 1e6,
smoothed_power_db,
linewidth=1.5,
label="Сглаженный спектр",
)
for candidate in candidates:
frequency_mhz = candidate["frequency_hz"] / 1e6
power_db = candidate["relative_power_db"]
plt.scatter(
[frequency_mhz],
[power_db],
marker="o",
)
plt.annotate(
f"{frequency_mhz:.3f}",
xy=(frequency_mhz, power_db),
xytext=(0, 10),
textcoords="offset points",
ha="center",
fontsize=8,
rotation=45,
)
plt.axvspan(
(CENTER_FREQUENCY_HZ - DC_EXCLUSION_HALF_WIDTH_HZ) / 1e6,
(CENTER_FREQUENCY_HZ + DC_EXCLUSION_HALF_WIDTH_HZ) / 1e6,
alpha=0.15,
label="Исключённая DC-зона",
)
plt.title(
"Lab024B. Усреднённый спектр Pluto+ и кандидаты на FM-сигналы"
)
plt.xlabel("Частота, МГц")
plt.ylabel("Относительная мощность, дБ")
plt.grid(True)
plt.ylim(-60, 5)
plt.legend()
plt.tight_layout()
plt.savefig(GRAPH_PATH, dpi=160)
plt.show()
def main() -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
sdr = configure_receiver()
print()
print("Параметры приёмника:")
print(f" URI: {PLUTO_URI}")
print(
f" Центральная частота: "
f"{sdr.rx_lo / 1e6:.3f} МГц"
)
print(
f" Частота дискретизации: "
f"{sdr.sample_rate / 1e6:.3f} Мвыб/с"
)
print(
f" Полоса RX: "
f"{sdr.rx_rf_bandwidth / 1e6:.3f} МГц"
)
print(
f" Режим усиления: "
f"{sdr.gain_control_mode_chan0}"
)
samples = receive_samples(sdr)
print()
print(f"Всего получено: {len(samples)} IQ-сэмплов")
frequencies_hz, power_density = calculate_welch_spectrum(
samples=samples,
sample_rate_hz=float(sdr.sample_rate),
center_frequency_hz=float(sdr.rx_lo),
)
frequency_step_hz = float(
np.mean(np.diff(frequencies_hz))
)
smoothed_power = smooth_power_spectrum(
power_density=power_density,
frequency_step_hz=frequency_step_hz,
)
raw_power_db, smoothed_power_db = (
convert_power_to_relative_db(
raw_power=power_density,
smoothed_power=smoothed_power,
)
)
candidates = detect_signal_candidates(
frequencies_hz=frequencies_hz,
smoothed_power_db=smoothed_power_db,
center_frequency_hz=float(sdr.rx_lo),
)
save_spectrum_csv(
frequencies_hz=frequencies_hz,
raw_power_db=raw_power_db,
smoothed_power_db=smoothed_power_db,
)
save_candidates_csv(candidates)
save_report(len(samples), candidates)
print()
print("Найденные кандидаты на радиосигналы:")
print()
if candidates:
print(
" № Частота, МГц Уровень, дБ "
"Выраженность, дБ"
)
print(
" -- ------------ ----------- "
"-----------------"
)
for candidate_number, candidate in enumerate(
candidates,
start=1,
):
print(
f" {candidate_number:2d} "
f"{candidate['frequency_hz'] / 1e6:12.6f} "
f"{candidate['relative_power_db']:11.2f} "
f"{candidate['prominence_db']:17.2f}"
)
else:
print(" Кандидаты не найдены.")
create_graph(
frequencies_hz=frequencies_hz,
raw_power_db=raw_power_db,
smoothed_power_db=smoothed_power_db,
candidates=candidates,
)
print()
print("Созданы файлы:")
print(f" Спектр: {SPECTRUM_CSV_PATH}")
print(f" Кандидаты: {CANDIDATES_CSV_PATH}")
print(f" График: {GRAPH_PATH}")
print(f" Отчёт: {REPORT_PATH}")
print()
print("Lab024B выполнена успешно.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,875 @@
"""
Lab025. Приём и программная WFM-демодуляция FM-радиостанции.
Схема подключения:
антенна 40860 МГц -> RX1
Используется только приёмный канал RX1. Передатчики TX1 и TX2
не используются. Необработанные IQ-сэмплы на диск не сохраняются.
"""
from pathlib import Path
import adi
import matplotlib
import numpy as np
from scipy import signal
from scipy.io import wavfile
# Для лабораторной графики сохраняется в файлы без блокирующих окон.
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------
# Параметры приёмника Pluto+
# ---------------------------------------------------------------------
PLUTO_URI = "ip:192.168.2.1"
STATION_FREQUENCY_HZ = 100_100_000
LO_OFFSET_HZ = 250_000
RX_LO_FREQUENCY_HZ = (
STATION_FREQUENCY_HZ + LO_OFFSET_HZ
)
SAMPLE_RATE_HZ = 2_400_000
RX_BANDWIDTH_HZ = 1_500_000
RX_BUFFER_SIZE = 65_536
DISCARD_BUFFER_COUNT = 3
CAPTURE_BUFFER_COUNT = 128
# ---------------------------------------------------------------------
# Параметры обработки сигнала
# ---------------------------------------------------------------------
CHANNEL_DECIMATION = 10
CHANNEL_SAMPLE_RATE_HZ = 240_000
AUDIO_CUTOFF_HZ = 15_000
AUDIO_SAMPLE_RATE_HZ = 48_000
DEEMPHASIS_TIME_CONSTANT_SECONDS = 50e-6
AUDIO_TRANSIENT_DURATION_SECONDS = 0.05
AUDIO_REFERENCE_PERCENTILE = 99.5
AUDIO_TARGET_LEVEL = 0.85
MINIMUM_AUDIO_REFERENCE_PEAK = 1e-12
RF_WELCH_MAXIMUM_SAMPLE_COUNT = 1_048_576
RF_WELCH_SEGMENT_LENGTH = 8_192
RF_WELCH_OVERLAP_LENGTH = 4_096
AUDIO_WELCH_SEGMENT_LENGTH = 8_192
# ---------------------------------------------------------------------
# Выходные файлы
# ---------------------------------------------------------------------
OUTPUT_DIRECTORY = Path("data/processed/lab025")
WAV_FILE_PATH = (
OUTPUT_DIRECTORY / "lab025_wfm_audio_100_1mhz.wav"
)
RF_SPECTRUM_FILE_PATH = (
OUTPUT_DIRECTORY / "lab025_rf_spectrum.png"
)
AUDIO_WAVEFORM_FILE_PATH = (
OUTPUT_DIRECTORY / "lab025_audio_waveform.png"
)
AUDIO_SPECTRUM_FILE_PATH = (
OUTPUT_DIRECTORY / "lab025_audio_spectrum.png"
)
REPORT_FILE_PATH = OUTPUT_DIRECTORY / "lab025_report.txt"
def configure_receiver() -> adi.Pluto:
"""
Подключается к Pluto+ и настраивает только приёмный канал RX1.
Частота гетеродина смещена на 250 кГц выше частоты станции.
Благодаря этому полезный сигнал не совпадает с аппаратным DC-пиком
в центре комплексной полосы приёмника.
"""
print("Подключение к Pluto+...")
sdr = adi.Pluto(uri=PLUTO_URI)
# Используем только первый приёмный канал RX1.
sdr.rx_enabled_channels = [0]
sdr.sample_rate = SAMPLE_RATE_HZ
sdr.rx_lo = RX_LO_FREQUENCY_HZ
sdr.rx_rf_bandwidth = RX_BANDWIDTH_HZ
# Медленная АРУ подходит для приёма вещательной FM-станции.
sdr.gain_control_mode_chan0 = "slow_attack"
sdr.rx_buffer_size = RX_BUFFER_SIZE
return sdr
def receive_samples(sdr: adi.Pluto) -> np.ndarray:
"""
Получает последовательность комплексных IQ-буферов.
Первые буферы отбрасываются, поскольку после настройки приёмника
в них могут присутствовать переходные процессы АРУ и фильтров.
Рабочие буферы объединяются только в оперативной памяти.
"""
print()
print(
"Отбрасывание переходных буферов: "
f"{DISCARD_BUFFER_COUNT}..."
)
for _ in range(DISCARD_BUFFER_COUNT):
_ = sdr.rx()
print("Получение рабочих IQ-буферов...")
received_buffers: list[np.ndarray] = []
for buffer_number in range(1, CAPTURE_BUFFER_COUNT + 1):
received_buffer = np.asarray(
sdr.rx(),
dtype=np.complex64,
)
if received_buffer.size == 0:
raise RuntimeError(
"Pluto+ вернул пустой рабочий IQ-буфер."
)
received_buffers.append(received_buffer)
if (
buffer_number % 16 == 0
or buffer_number == CAPTURE_BUFFER_COUNT
):
print(
f" Принято {buffer_number:3d}/"
f"{CAPTURE_BUFFER_COUNT} буферов"
)
if not received_buffers:
raise RuntimeError("Pluto+ не вернул IQ-сэмплы.")
combined_samples = np.concatenate(received_buffers)
if combined_samples.size == 0:
raise RuntimeError("После объединения получен пустой IQ-массив.")
if not np.all(np.isfinite(combined_samples)):
raise RuntimeError("В IQ-сэмплах обнаружены NaN или Inf.")
return np.asarray(combined_samples, dtype=np.complex64)
def shift_station_to_baseband(
samples: np.ndarray,
sample_rate_hz: float,
frequency_shift_hz: float,
) -> np.ndarray:
"""
Переносит выбранную станцию в центр цифровой полосы.
При положительном смещении комплексный генератор переносит сигнал,
расположенный на отрицательной относительной частоте, к 0 Гц.
"""
if samples.size == 0:
raise RuntimeError(
"Невозможно выполнить цифровой перенос пустого IQ-массива."
)
sample_indices = np.arange(
len(samples),
dtype=np.float64,
)
digital_oscillator = np.exp(
1j
* 2.0
* np.pi
* frequency_shift_hz
* sample_indices
/ sample_rate_hz
).astype(np.complex64)
centered_samples = samples * digital_oscillator
if not np.all(np.isfinite(centered_samples)):
raise RuntimeError(
"После цифрового переноса обнаружены NaN или Inf."
)
return np.asarray(centered_samples, dtype=np.complex64)
def extract_wfm_channel(
centered_samples: np.ndarray,
) -> np.ndarray:
"""
Фильтрует WFM-канал и понижает частоту до 240 кГц.
Полигармоническая передискретизация одновременно выполняет
низкочастотную фильтрацию и децимацию в десять раз.
"""
calculated_sample_rate_hz = (
SAMPLE_RATE_HZ / CHANNEL_DECIMATION
)
if not np.isclose(
calculated_sample_rate_hz,
CHANNEL_SAMPLE_RATE_HZ,
):
raise RuntimeError(
"Частота канального сигнала после децимации "
"не равна 240 кГц."
)
channel_samples = signal.resample_poly(
centered_samples,
up=1,
down=CHANNEL_DECIMATION,
window=("kaiser", 8.0),
)
if channel_samples.size == 0:
raise RuntimeError("После выделения WFM-канала массив пуст.")
if not np.all(np.isfinite(channel_samples)):
raise RuntimeError(
"После выделения WFM-канала обнаружены NaN или Inf."
)
return np.asarray(channel_samples, dtype=np.complex64)
def demodulate_fm(
channel_samples: np.ndarray,
) -> np.ndarray:
"""
Выполняет частотную демодуляцию по фазовой разности отсчётов.
Результат представляет монофонический композитный FM-сигнал
до звуковой фильтрации и коррекции предыскажений.
"""
if channel_samples.size < 2:
raise RuntimeError(
"Недостаточно канальных отсчётов для FM-демодуляции."
)
phase_difference = np.angle(
channel_samples[1:]
* np.conj(channel_samples[:-1])
).astype(np.float64)
demodulated = phase_difference - np.mean(phase_difference)
if demodulated.size == 0:
raise RuntimeError("FM-дискриминатор вернул пустой массив.")
if not np.all(np.isfinite(demodulated)):
raise RuntimeError(
"После FM-демодуляции обнаружены NaN или Inf."
)
return demodulated
def lowpass_audio(
demodulated_samples: np.ndarray,
sample_rate_hz: float,
) -> np.ndarray:
"""
Выделяет монофонический звук L+R в полосе до 15 кГц.
Фильтр подавляет стереопилот 19 кГц, стереоразностную часть,
RDS и внеполосный высокочастотный шум.
"""
audio_sos = signal.butter(
6,
AUDIO_CUTOFF_HZ,
btype="lowpass",
fs=sample_rate_hz,
output="sos",
)
filtered_audio = signal.sosfiltfilt(
audio_sos,
demodulated_samples,
)
if filtered_audio.size == 0:
raise RuntimeError("Звуковой фильтр вернул пустой массив.")
if not np.all(np.isfinite(filtered_audio)):
raise RuntimeError(
"После звукового фильтра обнаружены NaN или Inf."
)
return np.asarray(filtered_audio, dtype=np.float64)
def apply_deemphasis(
audio_samples: np.ndarray,
sample_rate_hz: float,
time_constant_seconds: float,
) -> np.ndarray:
"""
Выполняет европейскую FM-коррекцию предыскажений 50 мкс.
Используется устойчивый однополюсный рекурсивный фильтр,
реализованный функцией scipy.signal.lfilter.
"""
alpha = np.exp(
-1.0
/ (
sample_rate_hz
* time_constant_seconds
)
)
deemphasized = signal.lfilter(
[1.0 - alpha],
[1.0, -alpha],
audio_samples,
)
deemphasized = deemphasized - np.mean(deemphasized)
if deemphasized.size == 0:
raise RuntimeError("De-emphasis вернул пустой массив.")
if not np.all(np.isfinite(deemphasized)):
raise RuntimeError(
"После de-emphasis обнаружены NaN или Inf."
)
return np.asarray(deemphasized, dtype=np.float64)
def resample_audio_to_output_rate(
audio_samples: np.ndarray,
) -> np.ndarray:
"""
Преобразует звуковой сигнал с 240 кГц в выходные 48 кГц.
"""
output_decimation = 5
calculated_output_rate_hz = (
CHANNEL_SAMPLE_RATE_HZ / output_decimation
)
if not np.isclose(
calculated_output_rate_hz,
AUDIO_SAMPLE_RATE_HZ,
):
raise RuntimeError(
"Рассчитанная частота WAV не равна 48 кГц."
)
output_audio = signal.resample_poly(
audio_samples,
up=1,
down=output_decimation,
)
if output_audio.size == 0:
raise RuntimeError(
"После преобразования в 48 кГц получен пустой массив."
)
if not np.all(np.isfinite(output_audio)):
raise RuntimeError(
"После преобразования звука обнаружены NaN или Inf."
)
return np.asarray(output_audio, dtype=np.float64)
def convert_audio_to_pcm16(
audio_samples: np.ndarray,
) -> np.ndarray:
"""
Удаляет переходный участок, нормализует звук и создаёт PCM16.
Опорный уровень определяется по 99,5-му процентилю модуля,
поэтому единичный выброс не делает весь WAV слишком тихим.
"""
if audio_samples.size == 0:
raise RuntimeError("Невозможно нормализовать пустой звук.")
centered_audio = audio_samples - np.mean(audio_samples)
transient_sample_count = int(
round(
AUDIO_TRANSIENT_DURATION_SECONDS
* AUDIO_SAMPLE_RATE_HZ
)
)
if centered_audio.size <= transient_sample_count:
raise RuntimeError(
"Звуковой массив короче переходного участка 0,05 с."
)
centered_audio = centered_audio[transient_sample_count:]
reference_peak = float(
np.percentile(
np.abs(centered_audio),
AUDIO_REFERENCE_PERCENTILE,
)
)
if (
not np.isfinite(reference_peak)
or reference_peak <= MINIMUM_AUDIO_REFERENCE_PEAK
):
raise RuntimeError(
"Сигнал слишком мал для безопасной нормализации PCM16."
)
normalized_audio = (
centered_audio
/ reference_peak
* AUDIO_TARGET_LEVEL
)
normalized_audio = np.clip(
normalized_audio,
-1.0,
1.0,
)
pcm16_audio = np.round(
normalized_audio * np.iinfo(np.int16).max
).astype(np.int16)
if pcm16_audio.size == 0:
raise RuntimeError("После преобразования PCM16 массив пуст.")
return pcm16_audio
def calculate_rf_spectrum(
samples: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""
Рассчитывает ограниченный спектр исходного IQ методом Уэлча.
Для графика используется не более 1 048 576 отсчётов, поэтому
полная восьмимиллионная запись не передаётся в одну большую FFT.
"""
analysis_sample_count = min(
len(samples),
RF_WELCH_MAXIMUM_SAMPLE_COUNT,
)
analysis_samples = samples[:analysis_sample_count]
relative_frequencies_hz, power_density = signal.welch(
analysis_samples,
fs=SAMPLE_RATE_HZ,
window="hann",
nperseg=RF_WELCH_SEGMENT_LENGTH,
noverlap=RF_WELCH_OVERLAP_LENGTH,
detrend=False,
return_onesided=False,
scaling="density",
)
relative_frequencies_hz = np.fft.fftshift(
relative_frequencies_hz
)
power_density = np.fft.fftshift(power_density)
minimum_positive_value = np.finfo(np.float64).tiny
power_db = 10.0 * np.log10(
power_density + minimum_positive_value
)
power_db -= np.max(power_db)
return relative_frequencies_hz, power_db
def create_rf_spectrum_graph(samples: np.ndarray) -> None:
"""
Сохраняет спектр до цифрового переноса станции в центр.
"""
frequencies_hz, power_db = calculate_rf_spectrum(samples)
figure, axes = plt.subplots(figsize=(13, 7))
axes.plot(
frequencies_hz / 1e3,
power_db,
linewidth=0.8,
)
expected_station_offset_hz = -LO_OFFSET_HZ
axes.axvline(
expected_station_offset_hz / 1e3,
color="tab:red",
linestyle="--",
linewidth=1.4,
label=(
"Ожидаемая станция 100,100 МГц "
"(-250 кГц)"
),
)
axes.set_title(
"Lab025. Спектр принятого IQ до цифрового переноса\n"
"RX LO = 100,350 МГц; станция = 100,100 МГц"
)
axes.set_xlabel("Относительная частота относительно RX LO, кГц")
axes.set_ylabel("Относительная мощность, дБ")
axes.set_ylim(-90, 5)
axes.grid(True)
axes.legend()
figure.tight_layout()
figure.savefig(RF_SPECTRUM_FILE_PATH, dpi=160)
plt.close(figure)
def create_audio_waveform_graph(pcm16_audio: np.ndarray) -> None:
"""
Сохраняет первые 0,1 секунды нормированного звука.
"""
displayed_sample_count = min(
len(pcm16_audio),
int(round(0.1 * AUDIO_SAMPLE_RATE_HZ)),
)
displayed_audio = (
pcm16_audio[:displayed_sample_count].astype(np.float64)
/ np.iinfo(np.int16).max
)
time_seconds = (
np.arange(displayed_sample_count, dtype=np.float64)
/ AUDIO_SAMPLE_RATE_HZ
)
figure, axes = plt.subplots(figsize=(12, 5))
axes.plot(time_seconds, displayed_audio, linewidth=0.8)
axes.set_title("Lab025. Первые 0,1 секунды демодулированного звука")
axes.set_xlabel("Время, с")
axes.set_ylabel("Нормированная амплитуда")
axes.set_ylim(-1.05, 1.05)
axes.grid(True)
figure.tight_layout()
figure.savefig(AUDIO_WAVEFORM_FILE_PATH, dpi=160)
plt.close(figure)
def create_audio_spectrum_graph(pcm16_audio: np.ndarray) -> None:
"""
Рассчитывает методом Уэлча и сохраняет односторонний спектр WAV.
"""
normalized_audio = (
pcm16_audio.astype(np.float64)
/ np.iinfo(np.int16).max
)
segment_length = min(
AUDIO_WELCH_SEGMENT_LENGTH,
len(normalized_audio),
)
frequencies_hz, power_density = signal.welch(
normalized_audio,
fs=AUDIO_SAMPLE_RATE_HZ,
window="hann",
nperseg=segment_length,
noverlap=segment_length // 2,
detrend="constant",
return_onesided=True,
scaling="density",
)
minimum_positive_value = np.finfo(np.float64).tiny
power_db = 10.0 * np.log10(
power_density + minimum_positive_value
)
power_db -= np.max(power_db)
figure, axes = plt.subplots(figsize=(12, 6))
axes.plot(frequencies_hz / 1e3, power_db, linewidth=0.9)
axes.set_xlim(0, 20)
axes.set_ylim(-100, 5)
axes.set_title("Lab025. Спектр демодулированного монофонического звука")
axes.set_xlabel("Частота, кГц")
axes.set_ylabel("Относительная спектральная плотность, дБ")
axes.grid(True)
figure.tight_layout()
figure.savefig(AUDIO_SPECTRUM_FILE_PATH, dpi=160)
plt.close(figure)
def calculate_audio_statistics(
pcm16_audio: np.ndarray,
) -> tuple[float, float, float]:
"""
Возвращает нормированные RMS, пик и процент предельных отсчётов.
"""
normalized_audio = (
pcm16_audio.astype(np.float64)
/ np.iinfo(np.int16).max
)
rms_value = float(
np.sqrt(np.mean(normalized_audio ** 2))
)
peak_value = float(np.max(np.abs(normalized_audio)))
clipped_sample_count = int(
np.count_nonzero(
(pcm16_audio == np.iinfo(np.int16).min)
| (pcm16_audio == np.iinfo(np.int16).max)
)
)
clipping_percentage = (
100.0
* clipped_sample_count
/ len(pcm16_audio)
)
return rms_value, peak_value, clipping_percentage
def save_report(
iq_sample_count: int,
iq_duration_seconds: float,
pcm16_audio: np.ndarray,
audio_rms: float,
audio_peak: float,
clipping_percentage: float,
) -> None:
"""
Создаёт текстовый отчёт с параметрами приёма и WAV-файла.
"""
wav_duration_seconds = (
len(pcm16_audio) / AUDIO_SAMPLE_RATE_HZ
)
report_lines = [
"Lab025. Приём и программная WFM-демодуляция",
"",
f"URI Pluto+: {PLUTO_URI}",
f"Частота станции: {STATION_FREQUENCY_HZ} Гц",
f"RX LO: {RX_LO_FREQUENCY_HZ} Гц",
f"Цифровое смещение: +{LO_OFFSET_HZ} Гц",
f"Частота дискретизации RX: {SAMPLE_RATE_HZ} Гц",
f"Полоса RX: {RX_BANDWIDTH_HZ} Гц",
f"Количество рабочих буферов: {CAPTURE_BUFFER_COUNT}",
f"Количество IQ-сэмплов: {iq_sample_count}",
f"Длительность IQ-записи: {iq_duration_seconds:.6f} с",
(
"Частота канального сигнала после децимации: "
f"{CHANNEL_SAMPLE_RATE_HZ} Гц"
),
f"Длительность итогового WAV: {wav_duration_seconds:.6f} с",
f"Частота WAV: {AUDIO_SAMPLE_RATE_HZ} Гц",
"Тип PCM: signed PCM16, mono",
f"RMS итогового аудио: {audio_rms:.6f}",
f"Пиковая амплитуда: {audio_peak:.6f}",
f"Отсчёты на границе PCM16: {clipping_percentage:.6f} %",
"",
"Выходные файлы:",
f"WAV: {WAV_FILE_PATH}",
f"Радиоспектр: {RF_SPECTRUM_FILE_PATH}",
f"Звуковая волна: {AUDIO_WAVEFORM_FILE_PATH}",
f"Спектр звука: {AUDIO_SPECTRUM_FILE_PATH}",
f"Отчёт: {REPORT_FILE_PATH}",
"",
"Использован только приёмный канал RX1.",
"Передатчики TX1 и TX2 не использовались.",
"Необработанные IQ-сэмплы на диск не сохранялись.",
]
REPORT_FILE_PATH.write_text(
"\n".join(report_lines),
encoding="utf-8",
)
def main() -> None:
"""
Выполняет полный цикл приёма, WFM-демодуляции и сохранения WAV.
"""
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
sdr = None
try:
sdr = configure_receiver()
print()
print("Параметры RX:")
print(f" URI: {PLUTO_URI}")
print(
f" Станция: "
f"{STATION_FREQUENCY_HZ / 1e6:.3f} МГц"
)
print(
f" RX LO: "
f"{sdr.rx_lo / 1e6:.3f} МГц"
)
print(
f" Частота дискретизации: "
f"{sdr.sample_rate / 1e6:.3f} Мвыб/с"
)
print(
f" Полоса RX: "
f"{sdr.rx_rf_bandwidth / 1e6:.3f} МГц"
)
print(
f" Режим усиления: "
f"{sdr.gain_control_mode_chan0}"
)
print(f" Размер буфера: {sdr.rx_buffer_size}")
print(" Активный канал: RX1")
samples = receive_samples(sdr)
iq_sample_count = len(samples)
iq_duration_seconds = iq_sample_count / SAMPLE_RATE_HZ
print()
print(f"Количество IQ-сэмплов: {iq_sample_count}")
print(f"Длительность записи: {iq_duration_seconds:.3f} с")
# Удаляем остаточную комплексную постоянную составляющую.
samples = samples - np.mean(samples)
print()
print("Цифровой перенос станции в центр полосы...")
centered_samples = shift_station_to_baseband(
samples=samples,
sample_rate_hz=SAMPLE_RATE_HZ,
frequency_shift_hz=LO_OFFSET_HZ,
)
print("Фильтрация WFM-канала и децимация до 240 кГц...")
channel_samples = extract_wfm_channel(centered_samples)
print("FM-демодуляция...")
demodulated_samples = demodulate_fm(channel_samples)
print("Звуковой low-pass фильтр 015 кГц...")
filtered_audio = lowpass_audio(
demodulated_samples=demodulated_samples,
sample_rate_hz=CHANNEL_SAMPLE_RATE_HZ,
)
print("De-emphasis 50 мкс...")
deemphasized_audio = apply_deemphasis(
audio_samples=filtered_audio,
sample_rate_hz=CHANNEL_SAMPLE_RATE_HZ,
time_constant_seconds=(
DEEMPHASIS_TIME_CONSTANT_SECONDS
),
)
print("Преобразование звука в 48 кГц...")
output_audio = resample_audio_to_output_rate(
deemphasized_audio
)
pcm16_audio = convert_audio_to_pcm16(output_audio)
audio_rms, audio_peak, clipping_percentage = (
calculate_audio_statistics(pcm16_audio)
)
print("Сохранение WAV...")
wavfile.write(
WAV_FILE_PATH,
AUDIO_SAMPLE_RATE_HZ,
pcm16_audio,
)
if not WAV_FILE_PATH.is_file():
raise RuntimeError("Выходной WAV-файл не создан.")
print("Сохранение графиков...")
create_rf_spectrum_graph(samples)
create_audio_waveform_graph(pcm16_audio)
create_audio_spectrum_graph(pcm16_audio)
print("Создание текстового отчёта...")
save_report(
iq_sample_count=iq_sample_count,
iq_duration_seconds=iq_duration_seconds,
pcm16_audio=pcm16_audio,
audio_rms=audio_rms,
audio_peak=audio_peak,
clipping_percentage=clipping_percentage,
)
print()
print("Созданы файлы:")
print(f" WAV: {WAV_FILE_PATH}")
print(f" Радиоспектр: {RF_SPECTRUM_FILE_PATH}")
print(f" Звуковая волна: {AUDIO_WAVEFORM_FILE_PATH}")
print(f" Спектр звука: {AUDIO_SPECTRUM_FILE_PATH}")
print(f" Отчёт: {REPORT_FILE_PATH}")
print()
print(f"RMS аудио: {audio_rms:.6f}")
print(f"Пиковая амплитуда: {audio_peak:.6f}")
print(
f"Отсчёты на границе PCM: "
f"{clipping_percentage:.6f} %"
)
print()
print("Lab025 выполнена успешно.")
print()
print("Для прослушивания откройте:")
print(WAV_FILE_PATH)
finally:
if sdr is not None:
destroy_buffer = getattr(
sdr,
"rx_destroy_buffer",
None,
)
if callable(destroy_buffer):
try:
destroy_buffer()
except Exception as cleanup_error:
print(
"Предупреждение: не удалось освободить "
f"RX-буфер: {cleanup_error}"
)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,925 @@
"""
Lab027. Динамическое превью временного обновления BASE и ROI.
Скрипт не повторяет расчёт benchmark Lab027 и не изменяет его
результаты. Он создаёт одно игнорируемое Git preview-видео с четырьмя
фиксированными профилями. Для каждого профиля независимо удерживаются
последние декодированные JPEG-состояния BASE и ROI.
Отдельные JPEG-файлы не сохраняются: кодирование и декодирование
выполняются только в памяти средствами OpenCV.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
PREVIEW_DIRECTORY = Path("data/raw/lab027_previews")
MP4_PREVIEW_PATH = (
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.mp4"
)
AVI_PREVIEW_PATH = (
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.avi"
)
OUTPUT_FPS = 30.0
PANEL_WIDTH = 640
PANEL_HEIGHT = 360
OUTPUT_WIDTH = PANEL_WIDTH * 2
OUTPUT_HEIGHT = PANEL_HEIGHT * 2
ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00
FRAME_TIME_EPSILON_SECONDS = 1e-9
NEW_UPDATE_LABEL_SECONDS = 0.15
@dataclass(frozen=True)
class PreviewProfile:
"""
Описывает один из четырёх фиксированных preview-профилей.
"""
name: str
base_width: int
base_height: int
base_fps: float
base_quality: int
roi_enabled: bool
roi_width: int
roi_height: int
roi_fps: float
roi_quality: int
measured_payload_kbps: float
@dataclass
class ProfileState:
"""
Хранит независимое временное состояние BASE и ROI профиля.
"""
latest_base: np.ndarray | None = None
latest_roi: np.ndarray | None = None
next_base_time: float = 0.0
next_roi_time: float = 0.0
last_base_update_time: float = 0.0
last_roi_update_time: float = 0.0
base_update_count: int = 0
roi_update_count: int = 0
def read_video_metadata(
source_path: Path,
) -> tuple[int, int, float, int, float]:
"""
Читает и проверяет параметры исходного видео.
"""
if not source_path.exists():
raise RuntimeError(
f"Исходный видеофайл отсутствует: {source_path}"
)
capture = cv2.VideoCapture(str(source_path))
if not capture.isOpened():
raise RuntimeError(
f"OpenCV не смог открыть видео: {source_path}"
)
try:
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = float(capture.get(cv2.CAP_PROP_FPS))
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
finally:
capture.release()
if width <= 0 or height <= 0:
raise RuntimeError(
"OpenCV вернул некорректное разрешение видео."
)
if fps <= 0.0:
raise RuntimeError("FPS исходного видео равен нулю.")
if frame_count <= 0:
raise RuntimeError("Число кадров исходного видео равно нулю.")
duration_seconds = frame_count / fps
return width, height, fps, frame_count, duration_seconds
def build_profiles() -> list[PreviewProfile]:
"""
Создаёт ровно четыре согласованных preview-профиля.
"""
profiles = [
PreviewProfile(
name="baseline",
base_width=320,
base_height=180,
base_fps=2.0,
base_quality=40,
roi_enabled=False,
roi_width=0,
roi_height=0,
roi_fps=0.0,
roi_quality=0,
measured_payload_kbps=80.27,
),
PreviewProfile(
name="ROI normal",
base_width=240,
base_height=135,
base_fps=1.0,
base_quality=25,
roi_enabled=True,
roi_width=320,
roi_height=180,
roi_fps=2.0,
roi_quality=35,
measured_payload_kbps=93.28,
),
PreviewProfile(
name="ROI economy",
base_width=160,
base_height=90,
base_fps=1.0,
base_quality=25,
roi_enabled=True,
roi_width=320,
roi_height=180,
roi_fps=2.0,
roi_quality=35,
measured_payload_kbps=85.19,
),
PreviewProfile(
name="ROI degraded channel",
base_width=240,
base_height=135,
base_fps=1.0,
base_quality=20,
roi_enabled=True,
roi_width=320,
roi_height=180,
roi_fps=1.0,
roi_quality=30,
measured_payload_kbps=50.49,
),
]
if len(profiles) != 4:
raise RuntimeError(
"Preview Lab027 должно содержать ровно четыре профиля."
)
return profiles
def normalized_roi_to_pixels(
width: int,
height: int,
) -> tuple[int, int, int, int]:
"""
Переводит фиксированные нормализованные координаты ROI в пиксели.
Результат задаёт полуоткрытый прямоугольник:
x_min, y_min, x_max, y_max.
"""
if width <= 0 or height <= 0:
raise ValueError("Размер кадра должен быть положительным.")
x_min = int(round(width * ROI_X_MIN))
x_max = int(round(width * ROI_X_MAX))
y_min = int(round(height * ROI_Y_MIN))
y_max = int(round(height * ROI_Y_MAX))
x_min = min(max(x_min, 0), width - 1)
x_max = min(max(x_max, x_min + 1), width)
y_min = min(max(y_min, 0), height - 1)
y_max = min(max(y_max, y_min + 1), height)
return x_min, y_min, x_max, y_max
def should_update(
current_time: float,
next_update_time: float,
epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
) -> bool:
"""
Проверяет наступление времени очередного обновления потока.
"""
return (
current_time + epsilon_seconds
>= next_update_time
)
def encode_decode_grayscale_jpeg(
gray_frame: np.ndarray,
jpeg_quality: int,
) -> np.ndarray:
"""
Кодирует grayscale JPEG в памяти и декодирует его обратно.
"""
if gray_frame.ndim != 2:
raise RuntimeError(
"JPEG preview должен получать grayscale-кадр."
)
encoding_ok, encoded = cv2.imencode(
".jpg",
gray_frame,
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
)
if not encoding_ok or encoded is None or encoded.size == 0:
raise RuntimeError("OpenCV не смог закодировать JPEG.")
decoded = cv2.imdecode(
encoded,
cv2.IMREAD_GRAYSCALE,
)
if decoded is None or decoded.shape != gray_frame.shape:
raise RuntimeError(
"Декодированный JPEG имеет некорректный размер."
)
return decoded
def encode_decode_base(
source_frame: np.ndarray,
profile: PreviewProfile,
) -> np.ndarray:
"""
Формирует очередное декодированное состояние BASE.
"""
source_gray = cv2.cvtColor(
source_frame,
cv2.COLOR_BGR2GRAY,
)
resized_base = cv2.resize(
source_gray,
(profile.base_width, profile.base_height),
interpolation=cv2.INTER_AREA,
)
return encode_decode_grayscale_jpeg(
resized_base,
profile.base_quality,
)
def encode_decode_roi(
source_frame: np.ndarray,
source_roi: tuple[int, int, int, int],
profile: PreviewProfile,
) -> np.ndarray:
"""
Вырезает ROI исходного BGR-кадра и формирует JPEG-состояние.
"""
if not profile.roi_enabled:
raise RuntimeError(
"Нельзя кодировать ROI для отключённого ROI-профиля."
)
x_min, y_min, x_max, y_max = source_roi
roi_bgr = source_frame[y_min:y_max, x_min:x_max]
if roi_bgr.size == 0:
raise RuntimeError("Вырезана пустая область ROI.")
roi_gray = cv2.cvtColor(
roi_bgr,
cv2.COLOR_BGR2GRAY,
)
resized_roi = cv2.resize(
roi_gray,
(profile.roi_width, profile.roi_height),
interpolation=cv2.INTER_AREA,
)
return encode_decode_grayscale_jpeg(
resized_roi,
profile.roi_quality,
)
def reconstruct_panel(
profile: PreviewProfile,
state: ProfileState,
panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
"""
Восстанавливает одну grayscale-панель размером 640x360.
"""
if state.latest_base is None:
raise RuntimeError("BASE-состояние ещё не создано.")
reconstructed = cv2.resize(
state.latest_base,
(PANEL_WIDTH, PANEL_HEIGHT),
interpolation=cv2.INTER_LINEAR,
)
if profile.roi_enabled:
if state.latest_roi is None:
raise RuntimeError("ROI-состояние ещё не создано.")
x_min, y_min, x_max, y_max = panel_roi
resized_roi = cv2.resize(
state.latest_roi,
(x_max - x_min, y_max - y_min),
interpolation=cv2.INTER_LINEAR,
)
reconstructed[y_min:y_max, x_min:x_max] = resized_roi
return reconstructed
def draw_text_line(
image: np.ndarray,
text: str,
y_position: int,
text_color: tuple[int, int, int] = (255, 255, 255),
) -> None:
"""
Рисует одну ASCII-строку служебной информации OpenCV.
"""
cv2.putText(
image,
text,
(10, y_position),
cv2.FONT_HERSHEY_SIMPLEX,
0.46,
text_color,
1,
cv2.LINE_AA,
)
def draw_panel_information(
reconstructed_gray: np.ndarray,
profile: PreviewProfile,
state: ProfileState,
current_time: float,
panel_roi: tuple[int, int, int, int],
) -> np.ndarray:
"""
Добавляет рамку ROI, параметры и индикаторы обновления панели.
"""
panel = cv2.cvtColor(
reconstructed_gray,
cv2.COLOR_GRAY2BGR,
)
if profile.roi_enabled:
cv2.rectangle(
panel,
(panel_roi[0], panel_roi[1]),
(panel_roi[2] - 1, panel_roi[3] - 1),
(0, 255, 255),
2,
)
overlay = panel.copy()
cv2.rectangle(
overlay,
(0, 0),
(PANEL_WIDTH - 1, 142),
(0, 0, 0),
thickness=-1,
)
cv2.addWeighted(
overlay,
0.72,
panel,
0.28,
0.0,
panel,
)
base_age = max(
0.0,
current_time - state.last_base_update_time,
)
roi_age = max(
0.0,
current_time - state.last_roi_update_time,
)
base_is_new = (
base_age <= NEW_UPDATE_LABEL_SECONDS
)
roi_is_new = (
profile.roi_enabled
and roi_age <= NEW_UPDATE_LABEL_SECONDS
)
draw_text_line(panel, profile.name, 20)
draw_text_line(
panel,
(
f"BASE: {profile.base_width}x{profile.base_height}, "
f"{profile.base_fps:g} fps, Q{profile.base_quality}"
),
41,
)
if profile.roi_enabled:
roi_text = (
f"ROI: {profile.roi_width}x{profile.roi_height}, "
f"{profile.roi_fps:g} fps, Q{profile.roi_quality}"
)
roi_age_text = f"{roi_age:.3f} s"
else:
roi_text = "ROI: disabled"
roi_age_text = "disabled"
draw_text_line(panel, roi_text, 62)
draw_text_line(
panel,
(
f"payload={profile.measured_payload_kbps:.2f} kbps, "
f"source time={current_time:.3f} s"
),
83,
)
draw_text_line(
panel,
(
f"since BASE={base_age:.3f} s, "
f"since ROI={roi_age_text}"
),
104,
)
update_labels: list[str] = []
if base_is_new:
update_labels.append("NEW BASE")
if roi_is_new:
update_labels.append("NEW ROI")
if update_labels:
draw_text_line(
panel,
" | ".join(update_labels),
130,
text_color=(0, 255, 0),
)
return panel
def compose_grid(panels: list[np.ndarray]) -> np.ndarray:
"""
Объединяет четыре панели в сетку 2x2 размером 1280x720.
"""
if len(panels) != 4:
raise RuntimeError(
"Для сетки preview требуется ровно четыре панели."
)
for panel in panels:
if panel.shape != (PANEL_HEIGHT, PANEL_WIDTH, 3):
raise RuntimeError(
f"Некорректный размер панели: {panel.shape}"
)
top_row = np.hstack((panels[0], panels[1]))
bottom_row = np.hstack((panels[2], panels[3]))
grid = np.vstack((top_row, bottom_row))
if grid.shape != (OUTPUT_HEIGHT, OUTPUT_WIDTH, 3):
raise RuntimeError(
f"Некорректный размер сетки: {grid.shape}"
)
return grid
def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]:
"""
Открывает MP4 writer либо разрешённый fallback AVI/MJPG.
"""
mp4_writer = cv2.VideoWriter(
str(MP4_PREVIEW_PATH),
cv2.VideoWriter_fourcc(*"mp4v"),
OUTPUT_FPS,
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
True,
)
if mp4_writer.isOpened():
return mp4_writer, MP4_PREVIEW_PATH, False
mp4_writer.release()
if MP4_PREVIEW_PATH.exists():
MP4_PREVIEW_PATH.unlink()
avi_writer = cv2.VideoWriter(
str(AVI_PREVIEW_PATH),
cv2.VideoWriter_fourcc(*"MJPG"),
OUTPUT_FPS,
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
True,
)
if not avi_writer.isOpened():
avi_writer.release()
if AVI_PREVIEW_PATH.exists():
AVI_PREVIEW_PATH.unlink()
raise RuntimeError(
"OpenCV не смог открыть ни MP4, ни AVI writer."
)
return avi_writer, AVI_PREVIEW_PATH, True
def create_preview(
source_path: Path,
profiles: list[PreviewProfile],
source_width: int,
source_height: int,
source_fps: float,
expected_frame_count: int,
) -> tuple[Path, bool, int, list[ProfileState]]:
"""
Создаёт preview-видео с одним выходным кадром на исходный кадр.
"""
if len(profiles) != 4:
raise RuntimeError(
"Ожидалось ровно четыре preview-профиля."
)
PREVIEW_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
capture = cv2.VideoCapture(str(source_path))
if not capture.isOpened():
raise RuntimeError(
f"OpenCV не смог открыть видео: {source_path}"
)
writer, preview_path, fallback_used = open_preview_writer()
source_roi = normalized_roi_to_pixels(
source_width,
source_height,
)
panel_roi = normalized_roi_to_pixels(
PANEL_WIDTH,
PANEL_HEIGHT,
)
states = [
ProfileState()
for _ in profiles
]
frame_index = 0
try:
while True:
frame_read, source_frame = capture.read()
if not frame_read:
break
if source_frame is None:
raise RuntimeError(
f"Получен пустой кадр {frame_index}."
)
if (
source_frame.shape[1] != source_width
or source_frame.shape[0] != source_height
):
raise RuntimeError(
"Размер кадра отличается от метаданных."
)
current_time = frame_index / source_fps
panels: list[np.ndarray] = []
for profile, state in zip(profiles, states):
if should_update(
current_time,
state.next_base_time,
):
state.latest_base = encode_decode_base(
source_frame,
profile,
)
state.last_base_update_time = current_time
state.next_base_time += 1.0 / profile.base_fps
state.base_update_count += 1
if (
profile.roi_enabled
and should_update(
current_time,
state.next_roi_time,
)
):
state.latest_roi = encode_decode_roi(
source_frame,
source_roi,
profile,
)
state.last_roi_update_time = current_time
state.next_roi_time += 1.0 / profile.roi_fps
state.roi_update_count += 1
reconstructed = reconstruct_panel(
profile,
state,
panel_roi,
)
panels.append(
draw_panel_information(
reconstructed,
profile,
state,
current_time,
panel_roi,
)
)
writer.write(compose_grid(panels))
frame_index += 1
if (
frame_index % 100 == 0
or frame_index == expected_frame_count
):
print(
f" Written frames: "
f"{frame_index}/{expected_frame_count}"
)
except Exception:
capture.release()
writer.release()
if preview_path.exists():
preview_path.unlink()
raise
finally:
capture.release()
writer.release()
if frame_index != expected_frame_count:
if preview_path.exists():
preview_path.unlink()
raise RuntimeError(
"Число записанных кадров не совпало с исходным: "
f"{frame_index} != {expected_frame_count}."
)
return (
preview_path,
fallback_used,
frame_index,
states,
)
def read_frame_at(
capture: cv2.VideoCapture,
frame_index: int,
) -> tuple[bool, tuple[int, ...] | None]:
"""
Читает один кадр preview по индексу для итоговой проверки.
"""
capture.set(
cv2.CAP_PROP_POS_FRAMES,
frame_index,
)
frame_read, frame = capture.read()
if not frame_read or frame is None:
return False, None
return True, frame.shape
def verify_preview(
preview_path: Path,
source_frame_count: int,
source_duration_seconds: float,
) -> tuple[
int,
int,
float,
int,
float,
tuple[bool, tuple[int, ...] | None],
tuple[bool, tuple[int, ...] | None],
tuple[bool, tuple[int, ...] | None],
]:
"""
Проверяет контейнер, геометрию, FPS, длительность и три кадра.
"""
if not preview_path.exists():
raise RuntimeError(
f"Preview отсутствует: {preview_path}"
)
if preview_path.stat().st_size <= 0:
raise RuntimeError("Preview имеет нулевой размер.")
capture = cv2.VideoCapture(str(preview_path))
if not capture.isOpened():
raise RuntimeError(
f"OpenCV не смог открыть preview: {preview_path}"
)
try:
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = float(capture.get(cv2.CAP_PROP_FPS))
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
if fps <= 0.0:
raise RuntimeError("FPS preview равен нулю.")
duration_seconds = frame_count / fps
first_frame = read_frame_at(capture, 0)
middle_frame = read_frame_at(
capture,
frame_count // 2,
)
last_frame = read_frame_at(
capture,
frame_count - 1,
)
finally:
capture.release()
if width != OUTPUT_WIDTH or height != OUTPUT_HEIGHT:
raise RuntimeError(
f"Некорректное разрешение preview: {width}x{height}."
)
if frame_count != source_frame_count:
raise RuntimeError(
"Число кадров preview не совпало с исходным."
)
allowed_duration_difference = (
1.0 / fps + FRAME_TIME_EPSILON_SECONDS
)
if (
abs(duration_seconds - source_duration_seconds)
> allowed_duration_difference
):
raise RuntimeError(
"Длительность preview отличается более чем на один кадр."
)
for label, frame_result in [
("первый", first_frame),
("средний", middle_frame),
("последний", last_frame),
]:
if not frame_result[0]:
raise RuntimeError(
f"Не удалось прочитать {label} кадр preview."
)
return (
width,
height,
fps,
frame_count,
duration_seconds,
first_frame,
middle_frame,
last_frame,
)
def main() -> None:
"""
Создаёт и проверяет динамическое preview Lab027.
"""
print("Reading source video metadata...")
(
source_width,
source_height,
source_fps,
source_frame_count,
source_duration_seconds,
) = read_video_metadata(SOURCE_VIDEO_PATH)
print(f" Source: {SOURCE_VIDEO_PATH}")
print(f" Resolution: {source_width}x{source_height}")
print(f" FPS: {source_fps:.6f}")
print(f" Frames: {source_frame_count}")
print(f" Duration: {source_duration_seconds:.6f} s")
profiles = build_profiles()
print("Creating dynamic preview...")
(
preview_path,
fallback_used,
written_frame_count,
states,
) = create_preview(
source_path=SOURCE_VIDEO_PATH,
profiles=profiles,
source_width=source_width,
source_height=source_height,
source_fps=source_fps,
expected_frame_count=source_frame_count,
)
print("Verifying preview...")
(
preview_width,
preview_height,
preview_fps,
preview_frame_count,
preview_duration_seconds,
first_frame,
middle_frame,
last_frame,
) = verify_preview(
preview_path,
source_frame_count,
source_duration_seconds,
)
print("")
print(f"Preview path: {preview_path}")
print(f"Fallback used: {fallback_used}")
print(f"File size: {preview_path.stat().st_size} bytes")
print(
f"Preview resolution: "
f"{preview_width}x{preview_height}"
)
print(f"Preview FPS: {preview_fps:.6f}")
print(f"Written frames: {written_frame_count}")
print(f"Verified frames: {preview_frame_count}")
print(
f"Preview duration: "
f"{preview_duration_seconds:.6f} s"
)
print(f"First frame: {first_frame}")
print(f"Middle frame: {middle_frame}")
print(f"Last frame: {last_frame}")
print("")
print("Profile update counts:")
for profile, state in zip(profiles, states):
print(
f" {profile.name}: "
f"BASE={state.base_update_count}, "
f"ROI={state.roi_update_count}"
)
print("")
print("Lab027 temporal preview completed successfully.")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,869 @@
"""
Lab028. Packetization of synchronous BASE + ROI JPEG video objects.
The laboratory forms the selected Lab027E profile directly from the source
video, keeps every JPEG and packet in memory, verifies the transport layer,
and writes only aggregate CSV/report/plot artifacts.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
import random
from typing import Callable
import cv2
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from protocol.video_packet import (
CompositeFrame,
CompositeReassembler,
HEADER_FORMAT,
HEADER_SIZE,
ObjectCRCError,
ObjectType,
PacketCRCError,
VideoPacket,
decode_packet,
encode_packet,
packetize_jpeg,
)
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
OUTPUT_DIRECTORY = Path("data/processed/lab028")
CSV_PATH = OUTPUT_DIRECTORY / "lab028_packet_payload_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab028_report.txt"
OVERHEAD_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab028_overhead_efficiency.png"
)
TRAFFIC_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab028_packets_wire_bitrate.png"
)
COMPOSITE_FPS = 3.0
BASE_WIDTH = 240
BASE_HEIGHT = 135
BASE_QUALITY = 23
ROI_WIDTH = 320
ROI_HEIGHT = 180
ROI_QUALITY = 33
ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00
PAYLOAD_LENGTHS = (64, 128, 256, 512, 1024)
FRAME_TIME_EPSILON_SECONDS = 1e-9
CSV_FIELDS = [
"max_payload_bytes",
"composite_frames",
"mean_base_packets_per_frame",
"mean_roi_packets_per_frame",
"mean_total_packets_per_frame",
"max_total_packets_per_frame",
"packets_per_second",
"jpeg_payload_bytes",
"jpeg_payload_bitrate_kbps",
"header_bytes_per_second",
"header_bitrate_kbps",
"wire_bytes",
"wire_bitrate_kbps",
"service_data_percent",
"efficiency_percent",
"mean_wire_packet_bytes",
"p95_wire_packet_bytes",
"max_wire_packet_bytes",
]
@dataclass(frozen=True)
class VideoMetadata:
width: int
height: int
fps: float
frame_count: int
duration_seconds: float
@dataclass(frozen=True)
class EncodedComposite:
composite_frame_id: int
source_frame_index: int
base_jpeg: bytes
roi_jpeg: bytes
@dataclass(frozen=True)
class PayloadMetrics:
max_payload_bytes: int
composite_frames: int
mean_base_packets_per_frame: float
mean_roi_packets_per_frame: float
mean_total_packets_per_frame: float
max_total_packets_per_frame: int
packets_per_second: float
jpeg_payload_bytes: int
jpeg_payload_bitrate_kbps: float
header_bytes_per_second: float
header_bitrate_kbps: float
wire_bytes: int
wire_bitrate_kbps: float
service_data_percent: float
efficiency_percent: float
mean_wire_packet_bytes: float
p95_wire_packet_bytes: float
max_wire_packet_bytes: int
@dataclass(frozen=True)
class TestResult:
name: str
passed: bool
detail: str
def normalized_roi_to_pixels(
width: int,
height: int,
) -> tuple[int, int, int, int]:
"""Use the normalized ROI coordinates from Lab027 through Lab027E."""
coordinates = (
int(round(width * ROI_X_MIN)),
int(round(height * ROI_Y_MIN)),
int(round(width * ROI_X_MAX)),
int(round(height * ROI_Y_MAX)),
)
x_min, y_min, x_max, y_max = coordinates
if not (
0 <= x_min < x_max <= width
and 0 <= y_min < y_max <= height
):
raise RuntimeError("calculated ROI is outside the source frame")
return coordinates
def encode_grayscale_jpeg(image: np.ndarray, quality: int) -> bytes:
"""Encode one grayscale image to an in-memory JPEG."""
encoded, buffer = cv2.imencode(
".jpg",
image,
[int(cv2.IMWRITE_JPEG_QUALITY), int(quality)],
)
if not encoded:
raise RuntimeError("OpenCV could not encode JPEG")
jpeg = buffer.tobytes()
if not jpeg:
raise RuntimeError("OpenCV produced an empty JPEG")
return jpeg
def encode_composite(
source_frame: np.ndarray,
source_roi: tuple[int, int, int, int],
composite_frame_id: int,
source_frame_index: int,
) -> EncodedComposite:
"""Form synchronous BASE and ROI JPEGs from exactly one source frame."""
grayscale = cv2.cvtColor(source_frame, cv2.COLOR_BGR2GRAY)
base = cv2.resize(
grayscale,
(BASE_WIDTH, BASE_HEIGHT),
interpolation=cv2.INTER_AREA,
)
x_min, y_min, x_max, y_max = source_roi
roi = grayscale[y_min:y_max, x_min:x_max]
if roi.size == 0:
raise RuntimeError("source ROI is empty")
roi = cv2.resize(
roi,
(ROI_WIDTH, ROI_HEIGHT),
interpolation=cv2.INTER_AREA,
)
return EncodedComposite(
composite_frame_id=composite_frame_id,
source_frame_index=source_frame_index,
base_jpeg=encode_grayscale_jpeg(base, BASE_QUALITY),
roi_jpeg=encode_grayscale_jpeg(roi, ROI_QUALITY),
)
def load_video_profile(
source_path: Path,
) -> tuple[VideoMetadata, list[EncodedComposite]]:
"""Read the video sequentially and select synchronous updates at 3 fps."""
if not source_path.exists():
raise FileNotFoundError(f"source video is missing: {source_path}")
capture = cv2.VideoCapture(str(source_path))
if not capture.isOpened():
raise RuntimeError(f"OpenCV could not open {source_path}")
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = float(capture.get(cv2.CAP_PROP_FPS))
declared_frame_count = int(
capture.get(cv2.CAP_PROP_FRAME_COUNT)
)
if width <= 0 or height <= 0 or fps <= 0.0:
capture.release()
raise RuntimeError("invalid source video metadata")
source_roi = normalized_roi_to_pixels(width, height)
selected: list[EncodedComposite] = []
source_frame_index = 0
next_composite_time = 0.0
try:
while True:
frame_read, source_frame = capture.read()
if not frame_read or source_frame is None:
break
source_time = source_frame_index / fps
if (
source_time + FRAME_TIME_EPSILON_SECONDS
>= next_composite_time
):
selected.append(
encode_composite(
source_frame,
source_roi,
len(selected),
source_frame_index,
)
)
next_composite_time += 1.0 / COMPOSITE_FPS
source_frame_index += 1
finally:
capture.release()
if source_frame_index <= 0 or not selected:
raise RuntimeError("source video did not yield frames")
if (
declared_frame_count > 0
and source_frame_index != declared_frame_count
):
raise RuntimeError(
"decoded frame count differs from video metadata: "
f"{source_frame_index} != {declared_frame_count}"
)
metadata = VideoMetadata(
width=width,
height=height,
fps=fps,
frame_count=source_frame_index,
duration_seconds=source_frame_index / fps,
)
return metadata, selected
def packets_for_composite(
composite: EncodedComposite,
max_payload_bytes: int,
) -> tuple[list[bytes], list[bytes]]:
"""Packetize BASE and ROI separately with one composite frame ID."""
base_packets = packetize_jpeg(
composite.base_jpeg,
composite.composite_frame_id,
ObjectType.BASE,
max_payload_bytes,
)
roi_packets = packetize_jpeg(
composite.roi_jpeg,
composite.composite_frame_id,
ObjectType.ROI,
max_payload_bytes,
)
return base_packets, roi_packets
def calculate_payload_metrics(
composites: list[EncodedComposite],
duration_seconds: float,
max_payload_bytes: int,
) -> PayloadMetrics:
"""Calculate actual packet and bitrate statistics for one payload limit."""
base_counts: list[int] = []
roi_counts: list[int] = []
total_counts: list[int] = []
wire_packet_sizes: list[int] = []
for composite in composites:
base_packets, roi_packets = packets_for_composite(
composite, max_payload_bytes
)
base_counts.append(len(base_packets))
roi_counts.append(len(roi_packets))
total_counts.append(len(base_packets) + len(roi_packets))
wire_packet_sizes.extend(
len(packet) for packet in base_packets + roi_packets
)
jpeg_payload_bytes = sum(
len(composite.base_jpeg) + len(composite.roi_jpeg)
for composite in composites
)
packet_count = len(wire_packet_sizes)
header_bytes = packet_count * HEADER_SIZE
wire_bytes = jpeg_payload_bytes + header_bytes
return PayloadMetrics(
max_payload_bytes=max_payload_bytes,
composite_frames=len(composites),
mean_base_packets_per_frame=float(np.mean(base_counts)),
mean_roi_packets_per_frame=float(np.mean(roi_counts)),
mean_total_packets_per_frame=float(np.mean(total_counts)),
max_total_packets_per_frame=max(total_counts),
packets_per_second=packet_count / duration_seconds,
jpeg_payload_bytes=jpeg_payload_bytes,
jpeg_payload_bitrate_kbps=(
jpeg_payload_bytes * 8.0 / duration_seconds / 1000.0
),
header_bytes_per_second=header_bytes / duration_seconds,
header_bitrate_kbps=(
header_bytes * 8.0 / duration_seconds / 1000.0
),
wire_bytes=wire_bytes,
wire_bitrate_kbps=(
wire_bytes * 8.0 / duration_seconds / 1000.0
),
service_data_percent=header_bytes / wire_bytes * 100.0,
efficiency_percent=jpeg_payload_bytes / wire_bytes * 100.0,
mean_wire_packet_bytes=float(np.mean(wire_packet_sizes)),
p95_wire_packet_bytes=float(
np.percentile(wire_packet_sizes, 95)
),
max_wire_packet_bytes=max(wire_packet_sizes),
)
def feed_packets(
packets: list[bytes],
reassembler: CompositeReassembler | None = None,
) -> tuple[list[CompositeFrame], CompositeReassembler]:
"""Feed packets and collect every atomically published frame."""
receiver = reassembler or CompositeReassembler()
completed = []
for packet in packets:
frame = receiver.ingest(packet)
if frame is not None:
completed.append(frame)
return completed, receiver
def assert_frame_matches(
frame: CompositeFrame,
expected: EncodedComposite,
) -> None:
if frame.composite_frame_id != expected.composite_frame_id:
raise AssertionError("composite frame ID differs")
if frame.base_jpeg != expected.base_jpeg:
raise AssertionError("BASE JPEG differs byte-for-byte")
if frame.roi_jpeg != expected.roi_jpeg:
raise AssertionError("ROI JPEG differs byte-for-byte")
def run_functional_tests(
composites: list[EncodedComposite],
) -> list[TestResult]:
"""Run header and all mandatory Lab028 transport checks."""
if len(composites) < 2:
raise RuntimeError("functional checks need two video frames")
first = composites[0]
second = composites[1]
base_packets, roi_packets = packets_for_composite(first, 256)
all_packets = base_packets + roi_packets
tests: list[tuple[str, Callable[[], str]]] = []
def header_round_trip() -> str:
parsed = decode_packet(all_packets[0])
rebuilt = encode_packet(
VideoPacket(
composite_frame_id=parsed.composite_frame_id,
object_type=parsed.object_type,
fragment_index=parsed.fragment_index,
fragment_count=parsed.fragment_count,
jpeg_size=parsed.jpeg_size,
object_crc32=parsed.object_crc32,
payload=parsed.payload,
)
)
if rebuilt != all_packets[0]:
raise AssertionError("serialized bytes changed after round trip")
return f"fixed {HEADER_SIZE}-byte header round trip is exact"
def ordered_lossless() -> str:
frames, _ = feed_packets(all_packets)
if len(frames) != 1:
raise AssertionError("ordered transfer did not emit one frame")
assert_frame_matches(frames[0], first)
return "BASE and ROI match original JPEG bytes"
def shuffled_packets() -> str:
shuffled = list(all_packets)
random.Random(28001).shuffle(shuffled)
frames, _ = feed_packets(shuffled)
if len(frames) != 1:
raise AssertionError("shuffled transfer did not emit one frame")
assert_frame_matches(frames[0], first)
return "arbitrary packet order reconstructed correctly"
def duplicate_packets() -> str:
duplicated = list(all_packets)
duplicated.extend(
[all_packets[0], all_packets[len(base_packets)]]
)
random.Random(28002).shuffle(duplicated)
frames, receiver = feed_packets(duplicated)
if len(frames) != 1:
raise AssertionError("duplicates changed publication count")
assert_frame_matches(frames[0], first)
if receiver.duplicate_packets < 2:
raise AssertionError("exact duplicates were not counted")
return f"{receiver.duplicate_packets} exact duplicates ignored"
def packet_crc_corruption() -> str:
corrupted = bytearray(all_packets[0])
corrupted[-1] ^= 0x01
receiver = CompositeReassembler()
try:
receiver.ingest(bytes(corrupted))
except PacketCRCError:
pass
else:
raise AssertionError("corrupted packet passed packet CRC")
frames, _ = feed_packets(all_packets[1:], receiver)
if frames:
raise AssertionError("frame emitted despite rejected packet")
return "payload bit flip rejected; composite not emitted"
def missing_fragment() -> str:
missing_packet = roi_packets[len(roi_packets) // 2]
missing_index = decode_packet(missing_packet).fragment_index
remaining = [
packet
for packet in all_packets
if packet is not missing_packet
]
frames, receiver = feed_packets(remaining)
if frames:
raise AssertionError("frame emitted with a missing fragment")
missing = receiver.missing_fragments(
first.composite_frame_id, ObjectType.ROI
)
if missing is None or missing_index not in missing:
raise AssertionError("missing fragment was not reported")
return f"ROI fragment {missing_index} reported missing"
def object_crc_corruption() -> str:
target_position = len(base_packets)
parsed = decode_packet(all_packets[target_position])
changed_payload = bytearray(parsed.payload)
changed_payload[0] ^= 0x01
altered = encode_packet(
VideoPacket(
composite_frame_id=parsed.composite_frame_id,
object_type=parsed.object_type,
fragment_index=parsed.fragment_index,
fragment_count=parsed.fragment_count,
jpeg_size=parsed.jpeg_size,
object_crc32=parsed.object_crc32,
payload=bytes(changed_payload),
)
)
formally_valid = list(all_packets)
formally_valid[target_position] = altered
receiver = CompositeReassembler()
emitted = []
object_error_seen = False
for packet in formally_valid:
try:
frame = receiver.ingest(packet)
except ObjectCRCError:
object_error_seen = True
continue
if frame is not None:
emitted.append(frame)
if not object_error_seen:
raise AssertionError("object CRC did not detect changed JPEG")
if emitted:
raise AssertionError("frame emitted after object CRC failure")
return "valid packet CRCs still failed full-object CRC"
def adjacent_frames_do_not_mix() -> str:
first_packets = sum(packets_for_composite(first, 256), [])
second_packets = sum(packets_for_composite(second, 256), [])
interleaved = first_packets + second_packets
random.Random(28003).shuffle(interleaved)
frames, _ = feed_packets(interleaved)
by_id = {frame.composite_frame_id: frame for frame in frames}
if set(by_id) != {
first.composite_frame_id,
second.composite_frame_id,
}:
raise AssertionError("adjacent frames were lost or mixed")
assert_frame_matches(by_id[first.composite_frame_id], first)
assert_frame_matches(by_id[second.composite_frame_id], second)
return "two interleaved frame IDs remained independent"
def base_only_is_not_atomic() -> str:
frames, receiver = feed_packets(base_packets)
if frames:
raise AssertionError("BASE-only input emitted a composite")
if not receiver.object_is_complete(
first.composite_frame_id, ObjectType.BASE
):
raise AssertionError("complete BASE object was not retained")
return "complete BASE retained while composite stayed unpublished"
tests.extend(
[
("header_serialization_round_trip", header_round_trip),
("ordered_lossless_transfer", ordered_lossless),
("random_packet_order", shuffled_packets),
("exact_duplicate_packets", duplicate_packets),
("packet_crc_corruption", packet_crc_corruption),
("missing_fragment", missing_fragment),
("object_crc_corruption", object_crc_corruption),
("adjacent_frame_isolation", adjacent_frames_do_not_mix),
("base_without_roi_atomicity", base_only_is_not_atomic),
]
)
results = []
for name, test in tests:
try:
detail = test()
except Exception as error:
results.append(TestResult(name, False, str(error)))
else:
results.append(TestResult(name, True, detail))
failed = [result for result in results if not result.passed]
if failed:
details = "; ".join(
f"{result.name}: {result.detail}" for result in failed
)
raise RuntimeError(f"functional transport tests failed: {details}")
return results
def save_csv(metrics: list[PayloadMetrics]) -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
writer.writeheader()
for item in metrics:
row = {}
for field_name in CSV_FIELDS:
value = getattr(item, field_name)
row[field_name] = (
f"{value:.6f}"
if isinstance(value, float)
else value
)
writer.writerow(row)
def save_plots(metrics: list[PayloadMetrics]) -> None:
payloads = [item.max_payload_bytes for item in metrics]
figure, axis = plt.subplots(figsize=(9, 5.5))
axis.plot(
payloads,
[item.service_data_percent for item in metrics],
marker="o",
linewidth=2,
label="Service data (header / wire)",
)
axis.plot(
payloads,
[item.efficiency_percent for item in metrics],
marker="s",
linewidth=2,
label="Efficiency (JPEG / wire)",
)
axis.set_xscale("log", base=2)
axis.set_xticks(payloads)
axis.set_xticklabels([str(value) for value in payloads])
axis.set_xlabel("Maximum packet payload, bytes")
axis.set_ylabel("Share, %")
axis.set_title("Lab028 packet overhead and efficiency")
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(OVERHEAD_PLOT_PATH, dpi=160)
plt.close(figure)
figure, packet_axis = plt.subplots(figsize=(9, 5.5))
bitrate_axis = packet_axis.twinx()
packet_line = packet_axis.plot(
payloads,
[item.packets_per_second for item in metrics],
color="tab:blue",
marker="o",
linewidth=2,
label="Packets/s",
)
bitrate_lines = bitrate_axis.plot(
payloads,
[item.wire_bitrate_kbps for item in metrics],
color="tab:red",
marker="s",
linewidth=2,
label="Wire bitrate",
)
bitrate_axis.plot(
payloads,
[item.jpeg_payload_bitrate_kbps for item in metrics],
color="tab:green",
linestyle="--",
linewidth=2,
label="JPEG payload bitrate",
)
packet_axis.set_xscale("log", base=2)
packet_axis.set_xticks(payloads)
packet_axis.set_xticklabels([str(value) for value in payloads])
packet_axis.set_xlabel("Maximum packet payload, bytes")
packet_axis.set_ylabel("Packets per second", color="tab:blue")
bitrate_axis.set_ylabel("Bitrate, kbit/s", color="tab:red")
packet_axis.set_title("Lab028 packet rate and wire bitrate")
packet_axis.grid(True, alpha=0.3)
lines = packet_line + bitrate_lines + bitrate_axis.lines[1:]
packet_axis.legend(
lines,
[line.get_label() for line in lines],
loc="best",
)
figure.tight_layout()
figure.savefig(TRAFFIC_PLOT_PATH, dpi=160)
plt.close(figure)
def metrics_table(metrics: list[PayloadMetrics]) -> list[str]:
lines = [
(
"payload | BASE pkt/frame | ROI pkt/frame | all pkt/frame | "
"max pkt/frame | pkt/s | JPEG kbit/s | headers B/s | "
"wire kbit/s | service % | efficiency % | packet mean/p95/max"
),
(
"-------:|---------------:|--------------:|--------------:|"
"--------------:|------:|------------:|------------:|"
"------------:|----------:|-------------:|--------------------:"
),
]
for item in metrics:
lines.append(
f"{item.max_payload_bytes} | "
f"{item.mean_base_packets_per_frame:.3f} | "
f"{item.mean_roi_packets_per_frame:.3f} | "
f"{item.mean_total_packets_per_frame:.3f} | "
f"{item.max_total_packets_per_frame} | "
f"{item.packets_per_second:.3f} | "
f"{item.jpeg_payload_bitrate_kbps:.3f} | "
f"{item.header_bytes_per_second:.3f} | "
f"{item.wire_bitrate_kbps:.3f} | "
f"{item.service_data_percent:.3f} | "
f"{item.efficiency_percent:.3f} | "
f"{item.mean_wire_packet_bytes:.1f}/"
f"{item.p95_wire_packet_bytes:.1f}/"
f"{item.max_wire_packet_bytes}"
)
return lines
def write_report(
metadata: VideoMetadata,
composites: list[EncodedComposite],
metrics: list[PayloadMetrics],
test_results: list[TestResult],
) -> None:
source_roi = normalized_roi_to_pixels(
metadata.width, metadata.height
)
base_sizes = [len(item.base_jpeg) for item in composites]
roi_sizes = [len(item.roi_jpeg) for item in composites]
lines = [
"Lab028. Пакетирование синхронного BASE + ROI",
"",
"Профиль и исходные данные",
f"- Видео: {SOURCE_VIDEO_PATH}",
(
f"- Источник: {metadata.width}x{metadata.height}, "
f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
f"{metadata.duration_seconds:.6f} с."
),
(
f"- ROI: x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, "
f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}; "
f"пиксели x={source_roi[0]}...{source_roi[2]}, "
f"y={source_roi[1]}...{source_roi[3]}."
),
(
f"- Синхронные составные кадры: {len(composites)} "
f"при {COMPOSITE_FPS:.3f} fps."
),
(
f"- BASE: {BASE_WIDTH}x{BASE_HEIGHT}, grayscale JPEG Q"
f"{BASE_QUALITY}; средний размер {np.mean(base_sizes):.3f} B, "
f"min/max {min(base_sizes)}/{max(base_sizes)} B."
),
(
f"- ROI: {ROI_WIDTH}x{ROI_HEIGHT}, grayscale JPEG Q"
f"{ROI_QUALITY}; средний размер {np.mean(roi_sizes):.3f} B, "
f"min/max {min(roi_sizes)}/{max(roi_sizes)} B."
),
(
"- BASE и ROI формируются из одного source frame и имеют "
"общий composite_frame_id."
),
"",
"Бинарный заголовок",
f"- struct format: {HEADER_FORMAT}",
f"- Размер: {HEADER_SIZE} байта; network byte order; padding нет.",
(
"- Layout: magic[4] @0, version:u8 @4, object_type:u8 @5, "
"header_size:u16 @6, composite_frame_id:u32 @8, "
"fragment_index:u16 @12, fragment_count:u16 @14, "
"payload_length:u16 @16, flags:u16 @18, jpeg_size:u32 @20, "
"object_crc32:u32 @24, packet_crc32:u32 @28."
),
"- object_type: 1=BASE, 2=ROI; flags зарезервирован и равен 0.",
"",
"CRC32",
(
"- object_crc32 = zlib.crc32(полный JPEG) & 0xFFFFFFFF; "
"значение помещается во все фрагменты объекта и проверяется "
"после полной сборки."
),
(
"- packet_crc32: сначала поле packet_crc32 заголовка "
"обнуляется, затем CRC считается как "
"zlib.crc32(header_with_zero_crc + payload) & 0xFFFFFFFF."
),
"- Таким образом packet CRC защищает все поля заголовка и payload.",
"",
"Функциональные проверки",
]
lines.extend(
f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}"
for item in test_results
)
lines.extend(
[
"",
"Результаты размеров packet payload",
*metrics_table(metrics),
"",
(
"JPEG payload bitrate одинаков для всех строк, потому что "
"исходные JPEG не меняются при выборе размера фрагмента."
),
(
"Wire bitrate включает только JPEG payload и 32-байтные "
"заголовки каждого пакета."
),
(
"Wire bitrate пока НЕ включает FEC, преамбулу, "
"синхронизацию, модуляцию, интервалы, повторные передачи, "
"команды управления и телеметрию."
),
(
"Лучший размер packet payload автоматически не выбирается: "
"таблица показывает только транспортный компромисс."
),
"",
"Артефакты",
f"- CSV: {CSV_PATH}",
f"- Overhead/efficiency: {OVERHEAD_PLOT_PATH}",
f"- Packet count/wire bitrate: {TRAFFIC_PLOT_PATH}",
(
"- Промежуточные JPEG и пакеты сохранялись только в памяти; "
"бинарные дампы не создавались."
),
"",
]
)
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
def validate_outputs(metrics: list[PayloadMetrics]) -> None:
if len(metrics) != len(PAYLOAD_LENGTHS):
raise RuntimeError("not all payload sizes were measured")
if [item.max_payload_bytes for item in metrics] != list(
PAYLOAD_LENGTHS
):
raise RuntimeError("payload result order differs")
if any(
abs(
item.service_data_percent
+ item.efficiency_percent
- 100.0
) > 1e-9
for item in metrics
):
raise RuntimeError("overhead and efficiency do not sum to 100%")
for path in (
CSV_PATH,
REPORT_PATH,
OVERHEAD_PLOT_PATH,
TRAFFIC_PLOT_PATH,
):
if not path.exists() or path.stat().st_size <= 0:
raise RuntimeError(f"missing or empty output: {path}")
def main() -> None:
print("Lab028: loading and JPEG-encoding the Lab027E profile...")
metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
print(
f" source={metadata.width}x{metadata.height}, "
f"{metadata.fps:.3f} fps, frames={metadata.frame_count}"
)
print(f" synchronous composite frames={len(composites)}")
print("Running functional transport checks...")
test_results = run_functional_tests(composites)
for result in test_results:
print(f" PASS {result.name}: {result.detail}")
print("Measuring packet payload sizes...")
metrics = [
calculate_payload_metrics(
composites,
metadata.duration_seconds,
payload_length,
)
for payload_length in PAYLOAD_LENGTHS
]
for item in metrics:
print(
f" payload={item.max_payload_bytes:4d} B: "
f"{item.packets_per_second:.3f} packet/s, "
f"wire={item.wire_bitrate_kbps:.3f} kbit/s, "
f"service={item.service_data_percent:.3f}%"
)
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
save_csv(metrics)
save_plots(metrics)
write_report(metadata, composites, metrics, test_results)
validate_outputs(metrics)
print(f"CSV: {CSV_PATH}")
print(f"Report: {REPORT_PATH}")
print(f"Plots: {OVERHEAD_PLOT_PATH}, {TRAFFIC_PLOT_PATH}")
print("Lab028 completed successfully.")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,748 @@
"""Lab035: predictive admission and whole-frame video service."""
from __future__ import annotations
import csv
from dataclasses import asdict, dataclass
from pathlib import Path
import subprocess
from typing import Iterable
import cv2
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from protocol.link_packet import HEADER_SIZE, TrafficClass, decode_link_packet
from protocol.packet_erasure_fec import decode_fec_block, decode_outer_symbol
from protocol.video_frame_scheduler import (
FramePolicy,
FrameScheduleResult,
VideoFrameGroup,
predict_frame_completion,
schedule_video_frames,
)
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
from experiments.lab028_video_packetization import COMPOSITE_FPS
from experiments.lab034_stale_video_drop import (
PolicyDefinition as Lab034Policy,
build_aligned_workload,
build_lab033_workload,
percentile,
simulate_aligned,
)
OUTPUT_DIRECTORY = Path("data/processed/lab035")
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab035_summary.csv"
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab035_video_metrics.csv"
CONTROL_CSV_PATH = OUTPUT_DIRECTORY / "lab035_control_metrics.csv"
PREDICTION_CSV_PATH = OUTPUT_DIRECTORY / "lab035_prediction_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab035_report.txt"
UPDATE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_update_rate.png"
AGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_image_age.png"
PUBLICATION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_publication_delay.png"
OUTCOME_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_frame_outcomes.png"
QUEUE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_queue_size.png"
PREDICTION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_prediction_accuracy.png"
CONTROL_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_control_delay.png"
COMPARISON_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_policy_comparison.png"
PLOT_PATHS = (
UPDATE_PLOT_PATH,
AGE_PLOT_PATH,
PUBLICATION_PLOT_PATH,
OUTCOME_PLOT_PATH,
QUEUE_PLOT_PATH,
PREDICTION_PLOT_PATH,
CONTROL_PLOT_PATH,
COMPARISON_PLOT_PATH,
)
LAB034_COMMIT = "b63e36abdb7031d642de8b8138b43cc29e94b759"
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
TIME_EPSILON_SECONDS = 1e-9
@dataclass(frozen=True)
class PolicyDefinition:
name: str
label: str
scheduler_policy: FramePolicy | None
reactive: bool = False
POLICIES = (
PolicyDefinition("no_drop", "Без удаления", FramePolicy.NO_DROP),
PolicyDefinition("reactive_1500ms", "Реактивная 1500 мс", None, True),
PolicyDefinition("latest_only", "Самый свежий", FramePolicy.LATEST_ONLY),
PolicyDefinition("two_waiting", "Два ожидающих", FramePolicy.TWO_WAITING),
PolicyDefinition("predict_1000ms", "Прогноз 1000 мс", FramePolicy.PREDICT_1000MS),
PolicyDefinition("predict_500ms", "Прогноз 500 мс", FramePolicy.PREDICT_500MS),
)
POLICY_BY_NAME = {policy.name: policy for policy in POLICIES}
@dataclass(frozen=True)
class TheoreticalCapacity:
channel_kbps: float
nonvideo_load_kbps: float
remaining_video_kbps: float
video_capacity_ratio: float
minimum_skip_fraction: float
maximum_update_fps: float
@dataclass(frozen=True)
class SummaryMetrics:
channel_kbps: float
policy: str
offered_load_kbps: float
offered_to_capacity_ratio: float
transmitted_packets: int
transmitted_bytes: int
dropped_before_start_packets: int
dropped_before_start_bytes: int
wasted_transmitted_bytes: int
mean_queue_packets: float
max_queue_packets: int
mean_queue_bytes: float
max_queue_bytes: int
mean_waiting_video_frames: float
max_waiting_video_frames: int
queue_at_source_end_packets: int
additional_drain_seconds: float
remaining_video_capacity_kbps: float
theoretical_minimum_skip_fraction: float
theoretical_maximum_update_fps: float
@dataclass(frozen=True)
class VideoMetrics:
channel_kbps: float
policy: str
created_frames: int
started_frames: int
published_frames: int
dropped_before_start_frames: int
partially_transmitted_cancelled_frames: int
published_fraction: float
actual_update_fps: float
mean_publication_delay_ms: float
p95_publication_delay_ms: float
max_publication_delay_ms: float
mean_display_age_ms: float
p95_display_age_ms: float
max_display_age_ms: float
display_age_over_500ms_fraction: float
display_age_over_1000ms_fraction: float
mean_no_update_duration_ms: float
p95_no_update_duration_ms: float
max_no_update_duration_ms: float
mean_missing_run_frames: float
p95_missing_run_frames: float
max_missing_run_frames: int
mean_publication_gap_ms: float
max_publication_gap_ms: float
transmitted_video_bytes: int
dropped_before_start_video_bytes: int
wasted_transmitted_video_bytes: int
delivered_useful_video_kbps: float
@dataclass(frozen=True)
class ControlMetrics:
channel_kbps: float
policy: str
control_p95_delay_ms: float
control_max_delay_ms: float
control_deadline_misses: int
control_max_receive_gap_ms: float
emergency_delay_ms: float
emergency_deadline_met: bool
emergency_blocker_class: str
emergency_blocking_delay_ms: float
telemetry_deadline_misses: int
@dataclass(frozen=True)
class PredictionMetrics:
channel_kbps: float
policy: str
admitted_frames: int
prediction_rejected_frames: int
mean_absolute_error_ms: float
p95_absolute_error_ms: float
max_absolute_error_ms: float
published_after_deadline_frames: int
false_rejections: int
@dataclass(frozen=True)
class ScenarioResult:
summary: SummaryMetrics
video: VideoMetrics
control: ControlMetrics
prediction: PredictionMetrics
publication_times: dict[int, float]
schedule: FrameScheduleResult | None
@dataclass(frozen=True)
class FunctionalTestResult:
name: str
passed: bool
detail: str
def build_frame_groups(aligned) -> tuple[VideoFrameGroup, ...]:
grouped: dict[int, list] = {}
for item in aligned.packets:
if item.composite_frame_id is not None:
grouped.setdefault(item.composite_frame_id, []).append(item)
return tuple(
VideoFrameGroup(
composite_frame_id=frame_id,
generation_time_us=packets[0].packet.generation_time_us,
packets=tuple(sorted(packets, key=lambda item: item.packet.sequence_number)),
)
for frame_id, packets in sorted(grouped.items())
)
def high_priority_packets(aligned) -> tuple:
return tuple(
item for item in aligned.packets
if item.packet.traffic_class is not TrafficClass.VIDEO
)
def theoretical_capacity(aligned, rate: float) -> TheoreticalCapacity:
duration = aligned.lab033.metadata.duration_seconds
nonvideo_bytes = sum(
item.wire_size_bytes for item in aligned.packets
if item.packet.traffic_class is not TrafficClass.VIDEO
)
nonvideo_kbps = nonvideo_bytes * 8.0 / duration / 1000.0
video_kbps = aligned.layout_metrics.after_link_kbps
remaining = max(0.0, rate - nonvideo_kbps)
ratio = remaining / video_kbps
skip = max(0.0, 1.0 - ratio)
return TheoreticalCapacity(
rate,
nonvideo_kbps,
remaining,
ratio,
skip,
COMPOSITE_FPS * min(1.0, ratio),
)
def receive_whole_frames(schedule: FrameScheduleResult) -> dict[int, float]:
dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
receiver = CompositeReassembler()
publication_times: dict[int, float] = {}
symbols_by_block: dict[int, list[bytes]] = {}
decoded_blocks: set[int] = set()
for sent in schedule.transmitted:
link = decode_link_packet(sent.wire_packet)
if link.traffic_class is not TrafficClass.VIDEO:
continue
frame_id = sent.item.composite_frame_id
assert frame_id is not None and frame_id not in dropped
outer = decode_outer_symbol(link.payload)
symbols = symbols_by_block.setdefault(outer.block_id, [])
symbols.append(link.payload)
if not outer.is_parity:
completed = receiver.ingest(outer.data)
if completed is not None:
publication_times[completed.composite_frame_id] = sent.end_seconds
if outer.block_id not in decoded_blocks and len(symbols) >= outer.source_count:
decoded = decode_fec_block(tuple(symbols))
for inner in decoded.source_packets:
decode_inner_packet(inner)
decoded_blocks.add(outer.block_id)
if set(publication_times) != set(schedule.completed_frame_ids):
raise AssertionError("published frames differ from completed whole frames")
return publication_times
def display_and_gap_metrics(publications: dict[int, float], source_end: float):
events = sorted((time, frame) for frame, time in publications.items() if time <= source_end + TIME_EPSILON_SECONDS)
samples = np.arange(0.0, source_end + 0.005, 0.01)
ages = []
index = 0
last_frame = None
for time in samples:
while index < len(events) and events[index][0] <= time:
last_frame = events[index][1]; index += 1
generation = 0.0 if last_frame is None else last_frame / COMPOSITE_FPS
ages.append(max(0.0, time - generation))
update_times = [0.0] + [time for time, _ in events] + [source_end]
no_update = tuple(max(0.0, right - left) for left, right in zip(update_times, update_times[1:]))
publication_gaps = tuple(right[0] - left[0] for left, right in zip(events, events[1:]))
return ages, no_update, publication_gaps
def missing_runs(frame_count: int, published: set[int]) -> tuple[int, ...]:
runs = []
current = 0
for frame_id in range(frame_count):
if frame_id not in published:
current += 1
elif current:
runs.append(current); current = 0
if current:
runs.append(current)
return tuple(runs)
def queue_metrics(schedule: FrameScheduleResult, frames, source_end: float):
intervals = []
first_start: dict[int, float] = {}
for sent in schedule.transmitted:
intervals.append((sent.item.available_time_seconds, sent.end_seconds, sent.item.wire_size_bytes))
if sent.item.composite_frame_id is not None:
first_start.setdefault(sent.item.composite_frame_id, sent.start_seconds)
drop_time = {item.frame.composite_frame_id: item.drop_time_seconds for item in schedule.dropped_frames}
for item in schedule.dropped_frames:
for packet in item.frame.packets:
intervals.append((packet.available_time_seconds, item.drop_time_seconds, packet.wire_size_bytes))
for item in schedule.replacements:
intervals.append((item.removed.available_time_seconds, item.time_seconds, item.removed.wire_size_bytes))
packet_area = byte_area = 0.0
events: dict[float, list[int]] = {}
remaining = 0
for start, end, size in intervals:
if start <= source_end + TIME_EPSILON_SECONDS < end - TIME_EPSILON_SECONDS:
remaining += 1
left, right = max(0.0, start), min(source_end, end)
if right <= left + TIME_EPSILON_SECONDS:
continue
packet_area += right - left; byte_area += (right - left) * size
events.setdefault(left, [0, 0])[0] += 1; events[left][1] += size
events.setdefault(right, [0, 0])[0] -= 1; events[right][1] -= size
count = size_now = max_count = max_size = 0
for time in sorted(events):
count += events[time][0]; size_now += events[time][1]
max_count = max(max_count, count); max_size = max(max_size, size_now)
frame_intervals = []
for frame in frames:
end = first_start.get(frame.composite_frame_id, drop_time.get(frame.composite_frame_id, source_end))
frame_intervals.append((frame.generation_time_seconds, end))
frame_area = 0.0; frame_events: dict[float, int] = {}
for start, end in frame_intervals:
left, right = max(0.0, start), min(source_end, end)
if right <= left + TIME_EPSILON_SECONDS: continue
frame_area += right - left
frame_events[left] = frame_events.get(left, 0) + 1
frame_events[right] = frame_events.get(right, 0) - 1
waiting = max_waiting = 0
for time in sorted(frame_events):
waiting += frame_events[time]; max_waiting = max(max_waiting, waiting)
finish = max([source_end] + [item.end_seconds for item in schedule.transmitted] + [item.drop_time_seconds for item in schedule.dropped_frames])
return (
packet_area / source_end, max_count, byte_area / source_end, max_size,
frame_area / source_end, max_waiting, remaining, max(0.0, finish - source_end),
)
def control_metrics(schedule: FrameScheduleResult, rate: float, policy: str) -> ControlMetrics:
by_class = {
traffic: [item for item in schedule.transmitted if item.item.packet.traffic_class is traffic]
for traffic in (TrafficClass.CONTROL, TrafficClass.EMERGENCY, TrafficClass.TELEMETRY)
}
control = by_class[TrafficClass.CONTROL]
control_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in control]
gaps = [right.end_seconds - left.end_seconds for left, right in zip(control, control[1:])]
telemetry_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in by_class[TrafficClass.TELEMETRY]]
emergency = by_class[TrafficClass.EMERGENCY]
if len(emergency) != 1: raise AssertionError("exactly one emergency command is required")
urgent = emergency[0]
urgent_delay = urgent.end_seconds - urgent.item.packet.generation_time_us / 1_000_000.0
return ControlMetrics(
rate, policy,
percentile(control_delays, 95) * 1000.0,
max(control_delays) * 1000.0,
sum(delay > 0.1 + TIME_EPSILON_SECONDS for delay in control_delays),
max(gaps, default=0.0) * 1000.0,
urgent_delay * 1000.0,
urgent_delay <= 0.05 + TIME_EPSILON_SECONDS,
urgent.blocked_by.packet.traffic_class.name.lower() if urgent.blocked_by else "none",
urgent.blocking_delay_seconds * 1000.0,
sum(delay > 0.5 + TIME_EPSILON_SECONDS for delay in telemetry_delays),
)
def video_metrics(aligned, schedule, publications, rate, policy):
source_end = aligned.lab033.metadata.duration_seconds
published = set(publications)
dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
delays = [publications[frame] - frame / COMPOSITE_FPS for frame in sorted(published)]
ages, no_update, publication_gaps = display_and_gap_metrics(publications, source_end)
runs = missing_runs(len(aligned.lab033.composites), published)
transmitted_video_bytes = sum(
len(item.wire_packet) for item in schedule.transmitted
if item.item.packet.traffic_class is TrafficClass.VIDEO
)
dropped_bytes = sum(item.frame.wire_size_bytes for item in schedule.dropped_frames)
useful = sum(
len(aligned.lab033.composites[frame].base_jpeg) + len(aligned.lab033.composites[frame].roi_jpeg)
for frame in published
)
return VideoMetrics(
rate, policy, len(aligned.lab033.composites), len(schedule.started_frame_ids),
len(published), len(dropped), 0, len(published) / len(aligned.lab033.composites),
sum(time <= source_end + TIME_EPSILON_SECONDS for time in publications.values()) / source_end,
float(np.mean(delays)) * 1000.0 if delays else 0.0,
percentile(delays, 95) * 1000.0, max(delays, default=0.0) * 1000.0,
float(np.mean(ages)) * 1000.0, percentile(ages, 95) * 1000.0,
max(ages, default=0.0) * 1000.0,
sum(age > 0.5 for age in ages) / len(ages),
sum(age > 1.0 for age in ages) / len(ages),
float(np.mean(no_update)) * 1000.0, percentile(no_update, 95) * 1000.0,
max(no_update, default=0.0) * 1000.0,
float(np.mean(runs)) if runs else 0.0, percentile(runs, 95), max(runs, default=0),
float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
max(publication_gaps, default=0.0) * 1000.0,
transmitted_video_bytes, dropped_bytes, 0,
useful * 8.0 / source_end / 1000.0,
)
def prediction_metrics(schedule, rate, policy):
errors = [abs(item.prediction_error_seconds) for item in schedule.admissions]
deadline = schedule.policy.deadline_seconds
rejected = [item for item in schedule.dropped_frames if item.reason == "prediction_reject"]
late = sum(
item.actual_completion_seconds - item.composite_frame_id / COMPOSITE_FPS
> deadline + TIME_EPSILON_SECONDS
for item in schedule.admissions
) if deadline is not None else 0
return PredictionMetrics(
rate, policy, len(schedule.admissions), len(rejected),
float(np.mean(errors)) * 1000.0 if errors else 0.0,
percentile(errors, 95) * 1000.0, max(errors, default=0.0) * 1000.0,
late, 0,
)
def whole_frame_result(aligned, frames, high, rate, policy_def):
schedule = schedule_video_frames(frames, high, policy_def.scheduler_policy, rate * 1000.0)
publications = receive_whole_frames(schedule)
video = video_metrics(aligned, schedule, publications, rate, policy_def.name)
control = control_metrics(schedule, rate, policy_def.name)
prediction = prediction_metrics(schedule, rate, policy_def.name)
source_end = aligned.lab033.metadata.duration_seconds
qp, qmax, qb, qbmax, fq, fqmax, remaining, drain = queue_metrics(schedule, frames, source_end)
capacity = theoretical_capacity(aligned, rate)
offered_bytes = sum(item.wire_size_bytes for item in aligned.packets)
summary = SummaryMetrics(
rate, policy_def.name,
offered_bytes * 8.0 / source_end / 1000.0,
offered_bytes * 8.0 / source_end / (rate * 1000.0),
len(schedule.transmitted), sum(len(item.wire_packet) for item in schedule.transmitted),
sum(len(item.frame.packets) for item in schedule.dropped_frames),
sum(item.frame.wire_size_bytes for item in schedule.dropped_frames), 0,
qp, qmax, qb, qbmax, fq, fqmax, remaining, drain,
capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
capacity.maximum_update_fps,
)
return ScenarioResult(summary, video, control, prediction, publications, schedule)
def reactive_result(aligned, rate):
old_policy = Lab034Policy("aligned_1500ms", "По кадрам, 1500 мс", "aligned", 1500)
old = simulate_aligned(aligned, rate, old_policy)
capacity = theoretical_capacity(aligned, rate)
s, v, c = old.summary, old.video, old.control
source_end = aligned.lab033.metadata.duration_seconds
dropped_frames = set(old.dropped_frames)
partial_frames = {
frame_id for frame_id in dropped_frames
if old.transmitted_video_by_frame.get(frame_id, 0) > 0
}
before_start_frames = dropped_frames - partial_frames
dropped_before_packets = [
item for item in old.schedule.dropped_video
if item.item.composite_frame_id in before_start_frames
]
first_start = {}
for item in old.schedule.transmitted:
if item.item.composite_frame_id is not None:
first_start.setdefault(item.item.composite_frame_id, item.start_seconds)
frame_drop_time = {}
for item in old.schedule.dropped_video:
assert item.item.composite_frame_id is not None
frame_drop_time.setdefault(item.item.composite_frame_id, item.drop_time_seconds)
frame_events = {}
frame_area = 0.0
for frame_id in range(len(aligned.lab033.composites)):
start = frame_id / COMPOSITE_FPS
end = first_start.get(frame_id, frame_drop_time.get(frame_id, start))
left, right = max(0.0, start), min(source_end, end)
if right <= left + TIME_EPSILON_SECONDS:
continue
frame_area += right - left
frame_events[left] = frame_events.get(left, 0) + 1
frame_events[right] = frame_events.get(right, 0) - 1
waiting = max_waiting = 0
for time in sorted(frame_events):
waiting += frame_events[time]
max_waiting = max(max_waiting, waiting)
publication_events = sorted(old.publication_times.values())
publication_gaps = [
right - left for left, right in zip(publication_events, publication_events[1:])
]
summary = SummaryMetrics(
rate, "reactive_1500ms", s.offered_load_kbps, s.offered_to_capacity_ratio,
s.transmitted_packets, s.transmitted_bytes,
len(dropped_before_packets),
sum(item.item.wire_size_bytes for item in dropped_before_packets),
s.wasted_transmitted_bytes, s.mean_queue_packets, s.max_queue_packets,
s.mean_queue_bytes, s.max_queue_bytes,
frame_area / source_end, max_waiting,
s.queue_at_source_end_packets, s.additional_drain_seconds,
capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
capacity.maximum_update_fps,
)
video = VideoMetrics(
rate, "reactive_1500ms", v.created_frames,
v.published_frames + v.partially_transmitted_cancelled_frames,
v.published_frames,
v.intentionally_dropped_frames - v.partially_transmitted_cancelled_frames,
v.partially_transmitted_cancelled_frames,
v.published_fraction, v.actual_update_fps,
v.mean_publication_delay_ms, v.p95_publication_delay_ms, v.max_publication_delay_ms,
v.mean_display_age_ms, v.p95_display_age_ms, v.max_display_age_ms,
v.display_age_over_500ms_fraction, v.display_age_over_1000ms_fraction,
v.mean_no_update_duration_ms, v.p95_no_update_duration_ms, v.max_no_update_duration_ms,
v.mean_missing_run_frames, v.p95_missing_run_frames, v.max_missing_run_frames,
float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
max(publication_gaps, default=0.0) * 1000.0,
sum(len(item.wire_packet) for item in old.schedule.transmitted if item.item.packet.traffic_class is TrafficClass.VIDEO),
0, v.wasted_transmitted_video_bytes, v.delivered_useful_video_kbps,
)
control = ControlMetrics(
rate, "reactive_1500ms", c.control_p95_age_ms, c.control_max_age_ms,
c.control_deadline_misses, c.control_max_receive_gap_ms,
c.emergency_total_delay_ms, c.emergency_deadline_met,
c.emergency_blocker_class, c.emergency_blocking_delay_ms,
c.telemetry_deadline_misses,
)
prediction = PredictionMetrics(rate, "reactive_1500ms", 0, 0, 0.0, 0.0, 0.0, 0, 0)
return ScenarioResult(summary, video, control, prediction, old.publication_times, None)
def run_experiment(aligned, frames, high):
results = []
for rate in CHANNEL_RATES_KBPS:
for policy in POLICIES:
results.append(
reactive_result(aligned, rate)
if policy.reactive
else whole_frame_result(aligned, frames, high, rate, policy)
)
return tuple(results)
def run_functional_tests(aligned, frames, high, results):
lookup = {(r.summary.channel_kbps, r.summary.policy): r for r in results}
checks = []
def check(name):
def decorator(function): checks.append((name, function)); return function
return decorator
whole = [result for result in results if result.schedule is not None]
@check("01_video_frames_do_not_interleave")
def _():
for result in whole:
sequence = [item.item.composite_frame_id for item in result.schedule.transmitted if item.item.composite_frame_id is not None]
compressed = [frame for index, frame in enumerate(sequence) if index == 0 or frame != sequence[index - 1]]
assert len(compressed) == len(set(compressed))
@check("02_started_frame_never_dropped")
def _():
for result in whole:
assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})
@check("03_high_priority_between_frame_packets")
def _():
assert any(
any(item.item.packet.traffic_class is not TrafficClass.VIDEO for item in result.schedule.transmitted[left + 1:right])
for result in whole
for left, right in zip(
[i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][:-1],
[i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][1:],
)
if result.schedule.transmitted[left].item.composite_frame_id == result.schedule.transmitted[right].item.composite_frame_id
)
@check("04_latest_drops_only_unstarted")
def _():
for rate in CHANNEL_RATES_KBPS:
result = lookup[(rate, "latest_only")]
assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})
@check("05_two_waiting_limit")
def _(): assert all(lookup[(rate, "two_waiting")].summary.max_waiting_video_frames <= 2 for rate in CHANNEL_RATES_KBPS)
@check("06_prediction_is_pure")
def _():
ready = list(high[:2]); future = list(high[2:20]); ready_before=list(ready); future_before=list(future)
predict_frame_completion(0.0, frames[0], 230_000.0, ready, future)
assert ready == ready_before and future == future_before
@check("07_prediction_uses_actual_sizes")
def _():
small = VideoFrameGroup(999, 0, (frames[0].packets[0],))
full = predict_frame_completion(0.0, frames[0], 300_000.0, (), ())
one = predict_frame_completion(0.0, small, 300_000.0, (), ())
assert full > one and abs(one - small.wire_size_bytes * 8.0 / 300_000.0) < 1e-12
@check("08_prestart_drop_has_no_waste")
def _(): assert all(result.video.wasted_transmitted_video_bytes == 0 for result in whole)
@check("09_partial_only_reactive")
def _():
assert all(result.video.partially_transmitted_cancelled_frames == 0 for result in whole)
assert lookup[(230.0, "reactive_1500ms")].video.partially_transmitted_cancelled_frames > 0
@check("10_incomplete_not_published")
def _():
for result in whole:
dropped = {item.frame.composite_frame_id for item in result.schedule.dropped_frames}
assert not (dropped & set(result.publication_times))
@check("11_crc_layers_pass")
def _(): assert all(result.video.published_frames == len(result.publication_times) for result in results)
@check("12_emergency_never_deleted")
def _(): assert all(result.control.emergency_deadline_met for result in results)
@check("13_priority_above_video")
def _(): assert all(result.control.control_deadline_misses == 0 and result.control.telemetry_deadline_misses == 0 for result in results)
@check("14_300kbps_no_unnecessary_loss")
def _(): assert all(lookup[(300.0, policy.name)].video.published_frames == 63 for policy in POLICIES)
@check("15_230kbps_bounded_queue")
def _():
baseline = lookup[(230.0, "no_drop")].summary.max_queue_packets
assert all(lookup[(230.0, name)].summary.max_queue_packets < baseline for name in ("latest_only", "two_waiting", "predict_1000ms", "predict_500ms"))
@check("16_frame_accounting")
def _():
for result in results:
assert result.video.published_frames + result.video.dropped_before_start_frames + result.video.partially_transmitted_cancelled_frames == 63
@check("17_byte_accounting")
def _():
for result in whole:
assert result.summary.transmitted_bytes == sum(len(item.wire_packet) for item in result.schedule.transmitted)
assert result.summary.dropped_before_start_bytes == sum(item.frame.wire_size_bytes for item in result.schedule.dropped_frames)
@check("18_reproducible")
def _():
original = lookup[(230.0, "predict_1000ms")].schedule
repeated = schedule_video_frames(frames, high, FramePolicy.PREDICT_1000MS, 230_000.0)
assert [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in original.transmitted] == [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in repeated.transmitted]
@check("19_predict_1000_never_known_late")
def _(): assert all(lookup[(rate, "predict_1000ms")].prediction.published_after_deadline_frames == 0 for rate in CHANNEL_RATES_KBPS)
@check("20_command_delay_bound")
def _():
lab033 = {}
with Path("data/processed/lab033/lab033_summary.csv").open(encoding="utf-8") as file:
for row in csv.DictReader(file):
if row["scheduler"] == "latest_state": lab033[float(row["channel_kbps"])] = float(row["control_max_age_ms"])
max_video_bytes = max(packet.wire_size_bytes for frame in frames for packet in frame.packets)
for result in results:
bound = lab033[result.summary.channel_kbps] + max_video_bytes * 8.0 / (result.summary.channel_kbps * 1000.0) * 1000.0
assert result.control.control_max_delay_ms <= bound + 1e-9
output=[]
for name,function in checks:
try: function(); output.append(FunctionalTestResult(name,True,"PASS"))
except Exception as error: output.append(FunctionalTestResult(name,False,f"{type(error).__name__}: {error}"))
if not all(item.passed for item in output): raise AssertionError("functional checks failed: "+", ".join(item.name for item in output if not item.passed))
return tuple(output)
def save_csv(results):
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
for path, cls, rows in (
(SUMMARY_CSV_PATH, SummaryMetrics, (r.summary for r in results)),
(VIDEO_CSV_PATH, VideoMetrics, (r.video for r in results)),
(CONTROL_CSV_PATH, ControlMetrics, (r.control for r in results)),
(PREDICTION_CSV_PATH, PredictionMetrics, (r.prediction for r in results)),
):
with path.open("w",encoding="utf-8",newline="") as file:
writer=csv.DictWriter(file,fieldnames=list(cls.__dataclass_fields__)); writer.writeheader(); writer.writerows(asdict(row) for row in rows)
def grouped_plot(results,value,ylabel,title,path):
x=np.arange(len(CHANNEL_RATES_KBPS)); width=.13
fig,axis=plt.subplots(figsize=(12,5.5))
for index,policy in enumerate(POLICIES):
rows=[r for r in results if r.summary.policy==policy.name]
axis.bar(x+(index-2.5)*width,[value(r) for r in rows],width,label=policy.label)
axis.set_xticks(x,[f"{rate:.0f}" for rate in CHANNEL_RATES_KBPS]); axis.set_xlabel("Скорость, кбит/с"); axis.set_ylabel(ylabel); axis.set_title(title); axis.grid(axis="y",alpha=.3); axis.legend(fontsize=8); fig.tight_layout(); fig.savefig(path,dpi=150); plt.close(fig)
def save_plots(results):
grouped_plot(results,lambda r:r.video.actual_update_fps,"Обновлений/с","Фактическая частота обновления",UPDATE_PLOT_PATH)
grouped_plot(results,lambda r:r.video.p95_display_age_ms,"P95 возраста, мс","Возраст отображаемого изображения",AGE_PLOT_PATH)
grouped_plot(results,lambda r:r.video.p95_publication_delay_ms,"P95 задержки, мс","Задержка публикации",PUBLICATION_PLOT_PATH)
grouped_plot(results,lambda r:r.video.published_frames,"Кадров","Опубликованные кадры",OUTCOME_PLOT_PATH)
grouped_plot(results,lambda r:r.summary.max_queue_packets,"Пакетов","Максимальный размер очереди",QUEUE_PLOT_PATH)
grouped_plot(results,lambda r:r.prediction.p95_absolute_error_ms,"P95 ошибки, мс","Точность прогноза",PREDICTION_PLOT_PATH)
grouped_plot(results,lambda r:r.control.control_p95_delay_ms,"P95, мс","Задержка команд",CONTROL_PLOT_PATH)
grouped_plot(results,lambda r:r.video.dropped_before_start_frames,"Кадров","Сравнение политик упреждающего удаления",COMPARISON_PLOT_PATH)
def write_report(aligned,results,tests):
git_status=subprocess.run(("git","status","--short","--branch"),check=True,capture_output=True,text=True,encoding="utf-8").stdout.rstrip()
capacities={rate:theoretical_capacity(aligned,rate) for rate in CHANNEL_RATES_KBPS}
lines=[
"Lab035. Упреждающий допуск видеокадров и обслуживание видео целыми кадрами","",
"1. Исходное состояние",f"- Commit Lab034: {LAB034_COMMIT}.","- Перед Lab035 рабочее дерево было чистым; main опережала origin/main на два commit.","",
"2. Правило обслуживания","- После первого видеопакета кадр становится активным и не удаляется.","- При повторном выборе видео передаётся следующий пакет активного кадра; команды и телеметрия могут передаваться между пакетами.","- Новый видеокадр начинается только после полного завершения активного; видеопакеты разных кадров не чередуются; отдельный пакет не прерывается.","- Только неактивные кадры могут быть удалены до передачи первого пакета.","",
"3. Теоретическая пропускная способность","speed | nonvideo kbps | remaining video kbps | remaining/aligned | minimum skip | maximum fps",
]
for rate in CHANNEL_RATES_KBPS:
c=capacities[rate]; lines.append(f"{rate:.0f} | {c.nonvideo_load_kbps:.3f} | {c.remaining_video_kbps:.3f} | {c.video_capacity_ratio:.6f} | {c.minimum_skip_fraction:.6f} | {c.maximum_update_fps:.3f}")
lines.extend(["","4. Восемнадцать сочетаний","speed | policy | published/drop/partial | fps | age P95 ms | no-update max ms | queue max/waiting frames | waste bytes | prediction MAE/P95/max ms | control P95/max ms | emergency ms"])
for r in results:
s,v,c,p=r.summary,r.video,r.control,r.prediction
lines.append(f"{s.channel_kbps:.0f} | {POLICY_BY_NAME[s.policy].label} | {v.published_frames}/{v.dropped_before_start_frames}/{v.partially_transmitted_cancelled_frames} | {v.actual_update_fps:.3f} | {v.p95_display_age_ms:.3f} | {v.max_no_update_duration_ms:.3f} | {s.max_queue_packets}/{s.max_waiting_video_frames} | {v.wasted_transmitted_video_bytes} | {p.mean_absolute_error_ms:.6f}/{p.p95_absolute_error_ms:.6f}/{p.max_absolute_error_ms:.6f} | {c.control_p95_delay_ms:.3f}/{c.control_max_delay_ms:.3f} | {c.emergency_delay_ms:.3f}")
lines.extend(["","5. Интерпретация","- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.","- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.","- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.","- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.","- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.","- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.","- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.","","6. Допущения","- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.","- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.","- Политика автоматически не выбирается.","","7. Функциональные проверки"])
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
lines.extend(["","8. Созданные файлы"])
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("experiments/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")
def validate_outputs():
for path in (SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH):
with path.open(encoding="utf-8",newline="") as file: rows=list(csv.DictReader(file))
if len(rows)!=18: raise AssertionError(f"{path} must contain 18 rows")
if "Lab035" not in REPORT_PATH.read_text(encoding="utf-8"): raise AssertionError("invalid report")
for path in PLOT_PATHS:
image=cv2.imread(str(path),cv2.IMREAD_UNCHANGED)
if image is None or image.size==0: raise AssertionError(f"OpenCV could not read {path}")
def main():
lab033=build_lab033_workload(); aligned=build_aligned_workload(lab033)
frames=build_frame_groups(aligned); high=high_priority_packets(aligned)
results=run_experiment(aligned,frames,high)
tests=run_functional_tests(aligned,frames,high,results)
save_csv(results); save_plots(results); write_report(aligned,results,tests); validate_outputs()
print(f"Lab035 complete: {len(results)} scenarios, {len(tests)} checks")
if __name__=="__main__": main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff