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:
0
experiments/__init__.py
Normal file
0
experiments/__init__.py
Normal file
54
experiments/lab001_text_bytes.py
Normal file
54
experiments/lab001_text_bytes.py
Normal 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Проверка пройдена: исходный текст восстановлен без ошибок.")
|
||||
61
experiments/lab001_utf8_russian.py
Normal file
61
experiments/lab001_utf8_russian.py
Normal 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Проверка пройдена: русский текст восстановлен без ошибок.")
|
||||
153
experiments/lab002_bit_corruption.py
Normal file
153
experiments/lab002_bit_corruption.py
Normal 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 приёмник не смог определить ошибку.")
|
||||
146
experiments/lab002_packet_structure.py
Normal file
146
experiments/lab002_packet_structure.py
Normal 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
198
experiments/lab003_crc32.py
Normal 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 обнаружил изменение одного бита.")
|
||||
120
experiments/lab004_protocol_module.py
Normal file
120
experiments/lab004_protocol_module.py
Normal 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 успешно выполнены.")
|
||||
254
experiments/lab005_virtual_channel.py
Normal file
254
experiments/lab005_virtual_channel.py
Normal 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Проверка пройдена: "
|
||||
"все переданные пакеты учтены."
|
||||
)
|
||||
325
experiments/lab006_stop_and_wait_arq.py
Normal file
325
experiments/lab006_stop_and_wait_arq.py
Normal 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Проверка пройдена: "
|
||||
"все сообщения учтены."
|
||||
)
|
||||
270
experiments/lab007_image_fragmentation.py
Normal file
270
experiments/lab007_image_fragmentation.py
Normal 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 восстановлен байт в байт."
|
||||
)
|
||||
428
experiments/lab008_image_transfer_arq.py
Normal file
428
experiments/lab008_image_transfer_arq.py
Normal 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 восстановлен байт в байт."
|
||||
)
|
||||
450
experiments/lab009_real_photo_estimate.py
Normal file
450
experiments/lab009_real_photo_estimate.py
Normal 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Проверка пройдена: "
|
||||
"параметры передачи фотографии рассчитаны."
|
||||
)
|
||||
785
experiments/lab010_image_optimization.py
Normal file
785
experiments/lab010_image_optimization.py
Normal 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Проверка пройдена: "
|
||||
"варианты кадра созданы и рассчитаны."
|
||||
)
|
||||
525
experiments/lab011_scene_modes.py
Normal file
525
experiments/lab011_scene_modes.py
Normal 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Проверка пройдена: "
|
||||
"режимы кадра созданы и рассчитаны."
|
||||
)
|
||||
558
experiments/lab012_color_vs_gray.py
Normal file
558
experiments/lab012_color_vs_gray.py
Normal 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Проверка пройдена: "
|
||||
"цветные и серые варианты созданы и рассчитаны."
|
||||
)
|
||||
528
experiments/lab013_bpsk_python.py
Normal file
528
experiments/lab013_bpsk_python.py
Normal 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-сэмплы "
|
||||
"и демодулирован обратно."
|
||||
)
|
||||
617
experiments/lab014_bpsk_ber_curve.py
Normal file
617
experiments/lab014_bpsk_ber_curve.py
Normal 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-кривая построена."
|
||||
)
|
||||
810
experiments/lab015_fragment_size_optimization.py
Normal file
810
experiments/lab015_fragment_size_optimization.py
Normal 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Проверка пройдена: "
|
||||
"оптимальный размер фрагмента рассчитан."
|
||||
)
|
||||
605
experiments/lab016_adaptive_fragment_size.py
Normal file
605
experiments/lab016_adaptive_fragment_size.py
Normal 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Проверка пройдена: "
|
||||
"адаптивный выбор режима работает."
|
||||
)
|
||||
468
experiments/lab017_link_hysteresis.py
Normal file
468
experiments/lab017_link_hysteresis.py
Normal 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Проверка пройдена: "
|
||||
"гистерезис уменьшил количество переключений."
|
||||
)
|
||||
1148
experiments/lab018_bpsk_radio_frame.py
Normal file
1148
experiments/lab018_bpsk_radio_frame.py
Normal file
File diff suppressed because it is too large
Load Diff
1212
experiments/lab019_bpsk_receiver.py
Normal file
1212
experiments/lab019_bpsk_receiver.py
Normal file
File diff suppressed because it is too large
Load Diff
1494
experiments/lab020_bpsk_frame_error_rate.py
Normal file
1494
experiments/lab020_bpsk_frame_error_rate.py
Normal file
File diff suppressed because it is too large
Load Diff
1483
experiments/lab021_frequency_offset_correction.py
Normal file
1483
experiments/lab021_frequency_offset_correction.py
Normal file
File diff suppressed because it is too large
Load Diff
1716
experiments/lab022_noise_and_frequency_offset.py
Normal file
1716
experiments/lab022_noise_and_frequency_offset.py
Normal file
File diff suppressed because it is too large
Load Diff
1637
experiments/lab023_guarded_cfo_correction.py
Normal file
1637
experiments/lab023_guarded_cfo_correction.py
Normal file
File diff suppressed because it is too large
Load Diff
172
experiments/lab024a_pluto_rx_capture.py
Normal file
172
experiments/lab024a_pluto_rx_capture.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Lab024A. Первый приём реальных IQ-сэмплов с Pluto+.
|
||||
|
||||
Схема:
|
||||
антенна 40–860 МГц -> 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()
|
||||
|
||||
620
experiments/lab024b_pluto_welch_spectrum.py
Normal file
620
experiments/lab024b_pluto_welch_spectrum.py
Normal file
@@ -0,0 +1,620 @@
|
||||
"""
|
||||
Lab024B. Усреднённый спектр Pluto+ методом Уэлча.
|
||||
|
||||
Схема подключения:
|
||||
антенна 40–860 МГц -> 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()
|
||||
875
experiments/lab025_pluto_wfm_demodulation.py
Normal file
875
experiments/lab025_pluto_wfm_demodulation.py
Normal file
@@ -0,0 +1,875 @@
|
||||
"""
|
||||
Lab025. Приём и программная WFM-демодуляция FM-радиостанции.
|
||||
|
||||
Схема подключения:
|
||||
антенна 40–860 МГц -> 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 фильтр 0–15 кГц...")
|
||||
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()
|
||||
1358
experiments/lab026_video_bitrate_benchmark.py
Normal file
1358
experiments/lab026_video_bitrate_benchmark.py
Normal file
File diff suppressed because it is too large
Load Diff
1861
experiments/lab026b_four_bit_video_benchmark.py
Normal file
1861
experiments/lab026b_four_bit_video_benchmark.py
Normal file
File diff suppressed because it is too large
Load Diff
1710
experiments/lab027_roi_multiresolution_benchmark.py
Normal file
1710
experiments/lab027_roi_multiresolution_benchmark.py
Normal file
File diff suppressed because it is too large
Load Diff
925
experiments/lab027_roi_temporal_preview.py
Normal file
925
experiments/lab027_roi_temporal_preview.py
Normal 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()
|
||||
1941
experiments/lab027c_synchronous_roi_preview.py
Normal file
1941
experiments/lab027c_synchronous_roi_preview.py
Normal file
File diff suppressed because it is too large
Load Diff
1517
experiments/lab027d_fps_quality_preview.py
Normal file
1517
experiments/lab027d_fps_quality_preview.py
Normal file
File diff suppressed because it is too large
Load Diff
1575
experiments/lab027e_operator_view_preview.py
Normal file
1575
experiments/lab027e_operator_view_preview.py
Normal file
File diff suppressed because it is too large
Load Diff
869
experiments/lab028_video_packetization.py
Normal file
869
experiments/lab028_video_packetization.py
Normal 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()
|
||||
1353
experiments/lab029_packet_channel_simulation.py
Normal file
1353
experiments/lab029_packet_channel_simulation.py
Normal file
File diff suppressed because it is too large
Load Diff
1378
experiments/lab029b_time_based_burst_simulation.py
Normal file
1378
experiments/lab029b_time_based_burst_simulation.py
Normal file
File diff suppressed because it is too large
Load Diff
1711
experiments/lab030_packet_erasure_fec.py
Normal file
1711
experiments/lab030_packet_erasure_fec.py
Normal file
File diff suppressed because it is too large
Load Diff
1576
experiments/lab031_fec_interleaving.py
Normal file
1576
experiments/lab031_fec_interleaving.py
Normal file
File diff suppressed because it is too large
Load Diff
1786
experiments/lab032_fec_parameter_sweep.py
Normal file
1786
experiments/lab032_fec_parameter_sweep.py
Normal file
File diff suppressed because it is too large
Load Diff
1043
experiments/lab033_priority_channel_scheduler.py
Normal file
1043
experiments/lab033_priority_channel_scheduler.py
Normal file
File diff suppressed because it is too large
Load Diff
1020
experiments/lab034_stale_video_drop.py
Normal file
1020
experiments/lab034_stale_video_drop.py
Normal file
File diff suppressed because it is too large
Load Diff
748
experiments/lab035_video_frame_admission.py
Normal file
748
experiments/lab035_video_frame_admission.py
Normal 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()
|
||||
1361
experiments/lab036_long_duration_scheduler.py
Normal file
1361
experiments/lab036_long_duration_scheduler.py
Normal file
File diff suppressed because it is too large
Load Diff
1238
experiments/lab037_lossy_full_link.py
Normal file
1238
experiments/lab037_lossy_full_link.py
Normal file
File diff suppressed because it is too large
Load Diff
1645
experiments/lab038_control_failsafe.py
Normal file
1645
experiments/lab038_control_failsafe.py
Normal file
File diff suppressed because it is too large
Load Diff
1890
experiments/lab039_two_stage_braking.py
Normal file
1890
experiments/lab039_two_stage_braking.py
Normal file
File diff suppressed because it is too large
Load Diff
1749
experiments/lab040_persistent_emergency.py
Normal file
1749
experiments/lab040_persistent_emergency.py
Normal file
File diff suppressed because it is too large
Load Diff
1513
experiments/lab041_session_restart_safety.py
Normal file
1513
experiments/lab041_session_restart_safety.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user