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,0 +1 @@
|
||||
"""Быстрые автоматические проверки ядра протокола."""
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: исходный текст восстановлен без ошибок.")
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: русский текст восстановлен без ошибок.")
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
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 приёмник не смог определить ошибку.")
|
||||
@@ -1,146 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: пакет сформирован и разобран без ошибок.")
|
||||
@@ -1,198 +0,0 @@
|
||||
"""
|
||||
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 обнаружил изменение одного бита.")
|
||||
@@ -1,120 +0,0 @@
|
||||
# -*- 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 успешно выполнены.")
|
||||
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"все переданные пакеты учтены."
|
||||
)
|
||||
@@ -1,325 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"все сообщения учтены."
|
||||
)
|
||||
@@ -1,270 +0,0 @@
|
||||
"""
|
||||
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 восстановлен байт в байт."
|
||||
)
|
||||
@@ -1,428 +0,0 @@
|
||||
"""
|
||||
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 восстановлен байт в байт."
|
||||
)
|
||||
@@ -1,450 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"параметры передачи фотографии рассчитаны."
|
||||
)
|
||||
@@ -1,785 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"варианты кадра созданы и рассчитаны."
|
||||
)
|
||||
@@ -1,525 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"режимы кадра созданы и рассчитаны."
|
||||
)
|
||||
@@ -1,558 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"цветные и серые варианты созданы и рассчитаны."
|
||||
)
|
||||
@@ -1,528 +0,0 @@
|
||||
"""
|
||||
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-сэмплы "
|
||||
"и демодулирован обратно."
|
||||
)
|
||||
@@ -1,617 +0,0 @@
|
||||
"""
|
||||
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-кривая построена."
|
||||
)
|
||||
@@ -1,810 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"оптимальный размер фрагмента рассчитан."
|
||||
)
|
||||
@@ -1,605 +0,0 @@
|
||||
"""
|
||||
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Проверка пройдена: "
|
||||
"адаптивный выбор режима работает."
|
||||
)
|
||||
@@ -1,468 +0,0 @@
|
||||
"""
|
||||
Lab017. Фильтрация Eb/N0 и гистерезис режимов.
|
||||
|
||||
Эксперимент сравнивает два адаптера:
|
||||
|
||||
1. Наивный адаптер:
|
||||
переключает режим при каждом пересечении порога.
|
||||
|
||||
2. Устойчивый адаптер:
|
||||
использует:
|
||||
- экспоненциальное усреднение;
|
||||
- гистерезис;
|
||||
- подтверждение повышения;
|
||||
- аварийное быстрое понижение.
|
||||
|
||||
Профиль Eb/N0 специально содержит колебания
|
||||
около порогов переключения.
|
||||
"""
|
||||
|
||||
from csv import DictWriter
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from protocol.link_controller import (
|
||||
FRAGMENT_MODE_1024,
|
||||
IMAGE_OFF,
|
||||
LinkModeController,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Настройки
|
||||
# ============================================================
|
||||
|
||||
OUTPUT_DIRECTORY = Path(
|
||||
"data/processed/lab017"
|
||||
)
|
||||
|
||||
OUTPUT_DIRECTORY.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
CSV_PATH = (
|
||||
OUTPUT_DIRECTORY
|
||||
/ "lab017_hysteresis_results.csv"
|
||||
)
|
||||
|
||||
GRAPH_PATH = (
|
||||
OUTPUT_DIRECTORY
|
||||
/ "lab017_hysteresis_comparison.png"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Профиль качества канала
|
||||
# ============================================================
|
||||
|
||||
EB_N0_PROFILE_DB = [
|
||||
10.2,
|
||||
9.7,
|
||||
9.4,
|
||||
9.6,
|
||||
9.3,
|
||||
9.7,
|
||||
9.2,
|
||||
9.6,
|
||||
9.0,
|
||||
8.7,
|
||||
8.4,
|
||||
8.6,
|
||||
8.3,
|
||||
8.7,
|
||||
8.2,
|
||||
8.6,
|
||||
8.0,
|
||||
7.8,
|
||||
7.5,
|
||||
7.7,
|
||||
7.4,
|
||||
7.8,
|
||||
7.2,
|
||||
6.8,
|
||||
6.5,
|
||||
6.3,
|
||||
6.6,
|
||||
6.2,
|
||||
6.7,
|
||||
6.1,
|
||||
5.5,
|
||||
6.0,
|
||||
6.4,
|
||||
6.7,
|
||||
6.9,
|
||||
7.1,
|
||||
7.3,
|
||||
8.0,
|
||||
8.8,
|
||||
9.0,
|
||||
9.4,
|
||||
9.8,
|
||||
10.0,
|
||||
10.2,
|
||||
10.1,
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Наивное решение без гистерезиса
|
||||
# ============================================================
|
||||
|
||||
def choose_naive_mode(
|
||||
eb_n0_db: float,
|
||||
) -> int:
|
||||
"""
|
||||
Немедленно выбрать режим по одному измерению.
|
||||
|
||||
Гистерезиса и фильтрации нет.
|
||||
"""
|
||||
|
||||
if eb_n0_db < 6.5:
|
||||
return 0
|
||||
|
||||
if eb_n0_db < 8.5:
|
||||
return 128
|
||||
|
||||
if eb_n0_db < 9.5:
|
||||
return 512
|
||||
|
||||
return 1024
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Запуск устойчивого контроллера
|
||||
# ============================================================
|
||||
|
||||
controller = LinkModeController(
|
||||
initial_mode=FRAGMENT_MODE_1024,
|
||||
filter_alpha=0.35,
|
||||
upgrade_confirmation_count=3,
|
||||
minimum_hold_steps=2,
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
for measured_eb_n0_db in EB_N0_PROFILE_DB:
|
||||
|
||||
stable_result = controller.update(
|
||||
measured_eb_n0_db
|
||||
)
|
||||
|
||||
naive_mode = choose_naive_mode(
|
||||
measured_eb_n0_db
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"step": stable_result.step,
|
||||
"measured_eb_n0_db": (
|
||||
stable_result.measured_eb_n0_db
|
||||
),
|
||||
"filtered_eb_n0_db": (
|
||||
stable_result.filtered_eb_n0_db
|
||||
),
|
||||
"naive_mode": naive_mode,
|
||||
"stable_mode": (
|
||||
stable_result.selected_mode
|
||||
),
|
||||
"mode_changed": (
|
||||
stable_result.mode_changed
|
||||
),
|
||||
"pending_upgrade_mode": (
|
||||
stable_result.pending_upgrade_mode
|
||||
),
|
||||
"pending_upgrade_count": (
|
||||
stable_result.pending_upgrade_count
|
||||
),
|
||||
"reason": stable_result.reason,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Число переключений
|
||||
# ============================================================
|
||||
|
||||
def count_mode_switches(
|
||||
modes: list[int],
|
||||
) -> int:
|
||||
"""
|
||||
Посчитать число изменений режима.
|
||||
"""
|
||||
|
||||
return sum(
|
||||
previous_mode != current_mode
|
||||
for previous_mode, current_mode in zip(
|
||||
modes,
|
||||
modes[1:],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
naive_modes = [
|
||||
result["naive_mode"]
|
||||
for result in results
|
||||
]
|
||||
|
||||
stable_modes = [
|
||||
result["stable_mode"]
|
||||
for result in results
|
||||
]
|
||||
|
||||
naive_switch_count = count_mode_switches(
|
||||
naive_modes
|
||||
)
|
||||
|
||||
stable_switch_count = count_mode_switches(
|
||||
stable_modes
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Вывод результатов
|
||||
# ============================================================
|
||||
|
||||
print(
|
||||
"=== Lab017. Гистерезис режима канала ==="
|
||||
)
|
||||
|
||||
print("\nРезультаты по шагам:")
|
||||
|
||||
print(
|
||||
f"{'Шаг':>5}"
|
||||
f"{'Eb/N0':>10}"
|
||||
f"{'Фильтр':>11}"
|
||||
f"{'Наивный':>11}"
|
||||
f"{'Устойчивый':>13}"
|
||||
f"{'Изменён':>10}"
|
||||
f"{'Причина':>45}"
|
||||
)
|
||||
|
||||
print("-" * 105)
|
||||
|
||||
for result in results:
|
||||
|
||||
print(
|
||||
f"{result['step']:>5}"
|
||||
f"{result['measured_eb_n0_db']:>7.1f} дБ"
|
||||
f"{result['filtered_eb_n0_db']:>8.2f} дБ"
|
||||
f"{result['naive_mode']:>11}"
|
||||
f"{result['stable_mode']:>13}"
|
||||
f"{str(result['mode_changed']):>10}"
|
||||
f" {result['reason']}"
|
||||
)
|
||||
|
||||
|
||||
print("\nЧисло переключений:")
|
||||
|
||||
print(
|
||||
"Наивный адаптер:",
|
||||
naive_switch_count,
|
||||
)
|
||||
|
||||
print(
|
||||
"Устойчивый адаптер:",
|
||||
stable_switch_count,
|
||||
)
|
||||
|
||||
print(
|
||||
"Предотвращено лишних переключений:",
|
||||
naive_switch_count - stable_switch_count,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Сохранение CSV
|
||||
# ============================================================
|
||||
|
||||
with CSV_PATH.open(
|
||||
"w",
|
||||
newline="",
|
||||
encoding="utf-8-sig",
|
||||
) as csv_file:
|
||||
|
||||
writer = DictWriter(
|
||||
csv_file,
|
||||
fieldnames=list(
|
||||
results[0].keys()
|
||||
),
|
||||
)
|
||||
|
||||
writer.writeheader()
|
||||
writer.writerows(results)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# График
|
||||
# ============================================================
|
||||
|
||||
steps = [
|
||||
result["step"]
|
||||
for result in results
|
||||
]
|
||||
|
||||
measured_values = [
|
||||
result["measured_eb_n0_db"]
|
||||
for result in results
|
||||
]
|
||||
|
||||
filtered_values = [
|
||||
result["filtered_eb_n0_db"]
|
||||
for result in results
|
||||
]
|
||||
|
||||
|
||||
figure, axes = plt.subplots(
|
||||
2,
|
||||
1,
|
||||
figsize=(12, 9),
|
||||
sharex=True,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Верхний график: Eb/N0
|
||||
# ------------------------------------------------------------
|
||||
|
||||
axes[0].plot(
|
||||
steps,
|
||||
measured_values,
|
||||
marker=".",
|
||||
label="Измеренное Eb/N0",
|
||||
)
|
||||
|
||||
axes[0].plot(
|
||||
steps,
|
||||
filtered_values,
|
||||
linewidth=2,
|
||||
label="Фильтрованное Eb/N0",
|
||||
)
|
||||
|
||||
axes[0].axhline(
|
||||
6.5,
|
||||
linestyle=":",
|
||||
label="Порог наивного адаптера",
|
||||
)
|
||||
|
||||
axes[0].axhline(
|
||||
8.5,
|
||||
linestyle=":",
|
||||
)
|
||||
|
||||
axes[0].axhline(
|
||||
9.5,
|
||||
linestyle=":",
|
||||
)
|
||||
|
||||
axes[0].set_ylabel(
|
||||
"Eb/N0, дБ"
|
||||
)
|
||||
|
||||
axes[0].set_title(
|
||||
"Фильтрация измерения качества канала"
|
||||
)
|
||||
|
||||
axes[0].grid(
|
||||
True
|
||||
)
|
||||
|
||||
axes[0].legend()
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Нижний график: режимы
|
||||
# ------------------------------------------------------------
|
||||
|
||||
axes[1].step(
|
||||
steps,
|
||||
naive_modes,
|
||||
where="mid",
|
||||
marker=".",
|
||||
label="Наивный адаптер",
|
||||
)
|
||||
|
||||
axes[1].step(
|
||||
steps,
|
||||
stable_modes,
|
||||
where="mid",
|
||||
marker="o",
|
||||
label="Гистерезис и фильтрация",
|
||||
)
|
||||
|
||||
axes[1].set_yticks(
|
||||
[
|
||||
0,
|
||||
128,
|
||||
512,
|
||||
1024,
|
||||
],
|
||||
[
|
||||
"IMAGE OFF",
|
||||
"128 B",
|
||||
"512 B",
|
||||
"1024 B",
|
||||
],
|
||||
)
|
||||
|
||||
axes[1].set_xlabel(
|
||||
"Шаг времени"
|
||||
)
|
||||
|
||||
axes[1].set_ylabel(
|
||||
"Режим"
|
||||
)
|
||||
|
||||
axes[1].set_title(
|
||||
"Сравнение переключений режимов"
|
||||
)
|
||||
|
||||
axes[1].grid(
|
||||
True
|
||||
)
|
||||
|
||||
axes[1].legend()
|
||||
|
||||
|
||||
figure.tight_layout()
|
||||
|
||||
figure.savefig(
|
||||
GRAPH_PATH,
|
||||
dpi=160,
|
||||
)
|
||||
|
||||
plt.close(
|
||||
figure
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Проверки
|
||||
# ============================================================
|
||||
|
||||
assert results
|
||||
|
||||
assert IMAGE_OFF in stable_modes
|
||||
|
||||
assert FRAGMENT_MODE_1024 in stable_modes
|
||||
|
||||
assert stable_switch_count < naive_switch_count
|
||||
|
||||
assert CSV_PATH.exists()
|
||||
|
||||
assert GRAPH_PATH.exists()
|
||||
|
||||
|
||||
print("\nCSV:")
|
||||
|
||||
print(CSV_PATH)
|
||||
|
||||
print("\nГрафик:")
|
||||
|
||||
print(GRAPH_PATH)
|
||||
|
||||
print(
|
||||
"\nПроверка пройдена: "
|
||||
"гистерезис уменьшил количество переключений."
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,172 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,620 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,875 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,925 +0,0 @@
|
||||
"""
|
||||
Lab027. Динамическое превью временного обновления BASE и ROI.
|
||||
|
||||
Скрипт не повторяет расчёт benchmark Lab027 и не изменяет его
|
||||
результаты. Он создаёт одно игнорируемое Git preview-видео с четырьмя
|
||||
фиксированными профилями. Для каждого профиля независимо удерживаются
|
||||
последние декодированные JPEG-состояния BASE и ROI.
|
||||
|
||||
Отдельные JPEG-файлы не сохраняются: кодирование и декодирование
|
||||
выполняются только в памяти средствами OpenCV.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
|
||||
|
||||
PREVIEW_DIRECTORY = Path("data/raw/lab027_previews")
|
||||
MP4_PREVIEW_PATH = (
|
||||
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.mp4"
|
||||
)
|
||||
AVI_PREVIEW_PATH = (
|
||||
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.avi"
|
||||
)
|
||||
|
||||
OUTPUT_FPS = 30.0
|
||||
PANEL_WIDTH = 640
|
||||
PANEL_HEIGHT = 360
|
||||
OUTPUT_WIDTH = PANEL_WIDTH * 2
|
||||
OUTPUT_HEIGHT = PANEL_HEIGHT * 2
|
||||
|
||||
ROI_X_MIN = 0.20
|
||||
ROI_X_MAX = 0.80
|
||||
ROI_Y_MIN = 0.42
|
||||
ROI_Y_MAX = 1.00
|
||||
|
||||
FRAME_TIME_EPSILON_SECONDS = 1e-9
|
||||
NEW_UPDATE_LABEL_SECONDS = 0.15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreviewProfile:
|
||||
"""
|
||||
Описывает один из четырёх фиксированных preview-профилей.
|
||||
"""
|
||||
|
||||
name: str
|
||||
base_width: int
|
||||
base_height: int
|
||||
base_fps: float
|
||||
base_quality: int
|
||||
roi_enabled: bool
|
||||
roi_width: int
|
||||
roi_height: int
|
||||
roi_fps: float
|
||||
roi_quality: int
|
||||
measured_payload_kbps: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileState:
|
||||
"""
|
||||
Хранит независимое временное состояние BASE и ROI профиля.
|
||||
"""
|
||||
|
||||
latest_base: np.ndarray | None = None
|
||||
latest_roi: np.ndarray | None = None
|
||||
next_base_time: float = 0.0
|
||||
next_roi_time: float = 0.0
|
||||
last_base_update_time: float = 0.0
|
||||
last_roi_update_time: float = 0.0
|
||||
base_update_count: int = 0
|
||||
roi_update_count: int = 0
|
||||
|
||||
|
||||
def read_video_metadata(
|
||||
source_path: Path,
|
||||
) -> tuple[int, int, float, int, float]:
|
||||
"""
|
||||
Читает и проверяет параметры исходного видео.
|
||||
"""
|
||||
|
||||
if not source_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Исходный видеофайл отсутствует: {source_path}"
|
||||
)
|
||||
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(
|
||||
f"OpenCV не смог открыть видео: {source_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise RuntimeError(
|
||||
"OpenCV вернул некорректное разрешение видео."
|
||||
)
|
||||
|
||||
if fps <= 0.0:
|
||||
raise RuntimeError("FPS исходного видео равен нулю.")
|
||||
|
||||
if frame_count <= 0:
|
||||
raise RuntimeError("Число кадров исходного видео равно нулю.")
|
||||
|
||||
duration_seconds = frame_count / fps
|
||||
|
||||
return width, height, fps, frame_count, duration_seconds
|
||||
|
||||
|
||||
def build_profiles() -> list[PreviewProfile]:
|
||||
"""
|
||||
Создаёт ровно четыре согласованных preview-профиля.
|
||||
"""
|
||||
|
||||
profiles = [
|
||||
PreviewProfile(
|
||||
name="baseline",
|
||||
base_width=320,
|
||||
base_height=180,
|
||||
base_fps=2.0,
|
||||
base_quality=40,
|
||||
roi_enabled=False,
|
||||
roi_width=0,
|
||||
roi_height=0,
|
||||
roi_fps=0.0,
|
||||
roi_quality=0,
|
||||
measured_payload_kbps=80.27,
|
||||
),
|
||||
PreviewProfile(
|
||||
name="ROI normal",
|
||||
base_width=240,
|
||||
base_height=135,
|
||||
base_fps=1.0,
|
||||
base_quality=25,
|
||||
roi_enabled=True,
|
||||
roi_width=320,
|
||||
roi_height=180,
|
||||
roi_fps=2.0,
|
||||
roi_quality=35,
|
||||
measured_payload_kbps=93.28,
|
||||
),
|
||||
PreviewProfile(
|
||||
name="ROI economy",
|
||||
base_width=160,
|
||||
base_height=90,
|
||||
base_fps=1.0,
|
||||
base_quality=25,
|
||||
roi_enabled=True,
|
||||
roi_width=320,
|
||||
roi_height=180,
|
||||
roi_fps=2.0,
|
||||
roi_quality=35,
|
||||
measured_payload_kbps=85.19,
|
||||
),
|
||||
PreviewProfile(
|
||||
name="ROI degraded channel",
|
||||
base_width=240,
|
||||
base_height=135,
|
||||
base_fps=1.0,
|
||||
base_quality=20,
|
||||
roi_enabled=True,
|
||||
roi_width=320,
|
||||
roi_height=180,
|
||||
roi_fps=1.0,
|
||||
roi_quality=30,
|
||||
measured_payload_kbps=50.49,
|
||||
),
|
||||
]
|
||||
|
||||
if len(profiles) != 4:
|
||||
raise RuntimeError(
|
||||
"Preview Lab027 должно содержать ровно четыре профиля."
|
||||
)
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def normalized_roi_to_pixels(
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Переводит фиксированные нормализованные координаты ROI в пиксели.
|
||||
|
||||
Результат задаёт полуоткрытый прямоугольник:
|
||||
x_min, y_min, x_max, y_max.
|
||||
"""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Размер кадра должен быть положительным.")
|
||||
|
||||
x_min = int(round(width * ROI_X_MIN))
|
||||
x_max = int(round(width * ROI_X_MAX))
|
||||
y_min = int(round(height * ROI_Y_MIN))
|
||||
y_max = int(round(height * ROI_Y_MAX))
|
||||
|
||||
x_min = min(max(x_min, 0), width - 1)
|
||||
x_max = min(max(x_max, x_min + 1), width)
|
||||
y_min = min(max(y_min, 0), height - 1)
|
||||
y_max = min(max(y_max, y_min + 1), height)
|
||||
|
||||
return x_min, y_min, x_max, y_max
|
||||
|
||||
|
||||
def should_update(
|
||||
current_time: float,
|
||||
next_update_time: float,
|
||||
epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наступление времени очередного обновления потока.
|
||||
"""
|
||||
|
||||
return (
|
||||
current_time + epsilon_seconds
|
||||
>= next_update_time
|
||||
)
|
||||
|
||||
|
||||
def encode_decode_grayscale_jpeg(
|
||||
gray_frame: np.ndarray,
|
||||
jpeg_quality: int,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Кодирует grayscale JPEG в памяти и декодирует его обратно.
|
||||
"""
|
||||
|
||||
if gray_frame.ndim != 2:
|
||||
raise RuntimeError(
|
||||
"JPEG preview должен получать grayscale-кадр."
|
||||
)
|
||||
|
||||
encoding_ok, encoded = cv2.imencode(
|
||||
".jpg",
|
||||
gray_frame,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
|
||||
)
|
||||
|
||||
if not encoding_ok or encoded is None or encoded.size == 0:
|
||||
raise RuntimeError("OpenCV не смог закодировать JPEG.")
|
||||
|
||||
decoded = cv2.imdecode(
|
||||
encoded,
|
||||
cv2.IMREAD_GRAYSCALE,
|
||||
)
|
||||
|
||||
if decoded is None or decoded.shape != gray_frame.shape:
|
||||
raise RuntimeError(
|
||||
"Декодированный JPEG имеет некорректный размер."
|
||||
)
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
def encode_decode_base(
|
||||
source_frame: np.ndarray,
|
||||
profile: PreviewProfile,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Формирует очередное декодированное состояние BASE.
|
||||
"""
|
||||
|
||||
source_gray = cv2.cvtColor(
|
||||
source_frame,
|
||||
cv2.COLOR_BGR2GRAY,
|
||||
)
|
||||
resized_base = cv2.resize(
|
||||
source_gray,
|
||||
(profile.base_width, profile.base_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
|
||||
return encode_decode_grayscale_jpeg(
|
||||
resized_base,
|
||||
profile.base_quality,
|
||||
)
|
||||
|
||||
|
||||
def encode_decode_roi(
|
||||
source_frame: np.ndarray,
|
||||
source_roi: tuple[int, int, int, int],
|
||||
profile: PreviewProfile,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Вырезает ROI исходного BGR-кадра и формирует JPEG-состояние.
|
||||
"""
|
||||
|
||||
if not profile.roi_enabled:
|
||||
raise RuntimeError(
|
||||
"Нельзя кодировать ROI для отключённого ROI-профиля."
|
||||
)
|
||||
|
||||
x_min, y_min, x_max, y_max = source_roi
|
||||
roi_bgr = source_frame[y_min:y_max, x_min:x_max]
|
||||
|
||||
if roi_bgr.size == 0:
|
||||
raise RuntimeError("Вырезана пустая область ROI.")
|
||||
|
||||
roi_gray = cv2.cvtColor(
|
||||
roi_bgr,
|
||||
cv2.COLOR_BGR2GRAY,
|
||||
)
|
||||
resized_roi = cv2.resize(
|
||||
roi_gray,
|
||||
(profile.roi_width, profile.roi_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
|
||||
return encode_decode_grayscale_jpeg(
|
||||
resized_roi,
|
||||
profile.roi_quality,
|
||||
)
|
||||
|
||||
|
||||
def reconstruct_panel(
|
||||
profile: PreviewProfile,
|
||||
state: ProfileState,
|
||||
panel_roi: tuple[int, int, int, int],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Восстанавливает одну grayscale-панель размером 640x360.
|
||||
"""
|
||||
|
||||
if state.latest_base is None:
|
||||
raise RuntimeError("BASE-состояние ещё не создано.")
|
||||
|
||||
reconstructed = cv2.resize(
|
||||
state.latest_base,
|
||||
(PANEL_WIDTH, PANEL_HEIGHT),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
if profile.roi_enabled:
|
||||
if state.latest_roi is None:
|
||||
raise RuntimeError("ROI-состояние ещё не создано.")
|
||||
|
||||
x_min, y_min, x_max, y_max = panel_roi
|
||||
resized_roi = cv2.resize(
|
||||
state.latest_roi,
|
||||
(x_max - x_min, y_max - y_min),
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
reconstructed[y_min:y_max, x_min:x_max] = resized_roi
|
||||
|
||||
return reconstructed
|
||||
|
||||
|
||||
def draw_text_line(
|
||||
image: np.ndarray,
|
||||
text: str,
|
||||
y_position: int,
|
||||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||||
) -> None:
|
||||
"""
|
||||
Рисует одну ASCII-строку служебной информации OpenCV.
|
||||
"""
|
||||
|
||||
cv2.putText(
|
||||
image,
|
||||
text,
|
||||
(10, y_position),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.46,
|
||||
text_color,
|
||||
1,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
|
||||
def draw_panel_information(
|
||||
reconstructed_gray: np.ndarray,
|
||||
profile: PreviewProfile,
|
||||
state: ProfileState,
|
||||
current_time: float,
|
||||
panel_roi: tuple[int, int, int, int],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Добавляет рамку ROI, параметры и индикаторы обновления панели.
|
||||
"""
|
||||
|
||||
panel = cv2.cvtColor(
|
||||
reconstructed_gray,
|
||||
cv2.COLOR_GRAY2BGR,
|
||||
)
|
||||
|
||||
if profile.roi_enabled:
|
||||
cv2.rectangle(
|
||||
panel,
|
||||
(panel_roi[0], panel_roi[1]),
|
||||
(panel_roi[2] - 1, panel_roi[3] - 1),
|
||||
(0, 255, 255),
|
||||
2,
|
||||
)
|
||||
|
||||
overlay = panel.copy()
|
||||
cv2.rectangle(
|
||||
overlay,
|
||||
(0, 0),
|
||||
(PANEL_WIDTH - 1, 142),
|
||||
(0, 0, 0),
|
||||
thickness=-1,
|
||||
)
|
||||
cv2.addWeighted(
|
||||
overlay,
|
||||
0.72,
|
||||
panel,
|
||||
0.28,
|
||||
0.0,
|
||||
panel,
|
||||
)
|
||||
|
||||
base_age = max(
|
||||
0.0,
|
||||
current_time - state.last_base_update_time,
|
||||
)
|
||||
roi_age = max(
|
||||
0.0,
|
||||
current_time - state.last_roi_update_time,
|
||||
)
|
||||
base_is_new = (
|
||||
base_age <= NEW_UPDATE_LABEL_SECONDS
|
||||
)
|
||||
roi_is_new = (
|
||||
profile.roi_enabled
|
||||
and roi_age <= NEW_UPDATE_LABEL_SECONDS
|
||||
)
|
||||
|
||||
draw_text_line(panel, profile.name, 20)
|
||||
draw_text_line(
|
||||
panel,
|
||||
(
|
||||
f"BASE: {profile.base_width}x{profile.base_height}, "
|
||||
f"{profile.base_fps:g} fps, Q{profile.base_quality}"
|
||||
),
|
||||
41,
|
||||
)
|
||||
|
||||
if profile.roi_enabled:
|
||||
roi_text = (
|
||||
f"ROI: {profile.roi_width}x{profile.roi_height}, "
|
||||
f"{profile.roi_fps:g} fps, Q{profile.roi_quality}"
|
||||
)
|
||||
roi_age_text = f"{roi_age:.3f} s"
|
||||
else:
|
||||
roi_text = "ROI: disabled"
|
||||
roi_age_text = "disabled"
|
||||
|
||||
draw_text_line(panel, roi_text, 62)
|
||||
draw_text_line(
|
||||
panel,
|
||||
(
|
||||
f"payload={profile.measured_payload_kbps:.2f} kbps, "
|
||||
f"source time={current_time:.3f} s"
|
||||
),
|
||||
83,
|
||||
)
|
||||
draw_text_line(
|
||||
panel,
|
||||
(
|
||||
f"since BASE={base_age:.3f} s, "
|
||||
f"since ROI={roi_age_text}"
|
||||
),
|
||||
104,
|
||||
)
|
||||
|
||||
update_labels: list[str] = []
|
||||
|
||||
if base_is_new:
|
||||
update_labels.append("NEW BASE")
|
||||
|
||||
if roi_is_new:
|
||||
update_labels.append("NEW ROI")
|
||||
|
||||
if update_labels:
|
||||
draw_text_line(
|
||||
panel,
|
||||
" | ".join(update_labels),
|
||||
130,
|
||||
text_color=(0, 255, 0),
|
||||
)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
def compose_grid(panels: list[np.ndarray]) -> np.ndarray:
|
||||
"""
|
||||
Объединяет четыре панели в сетку 2x2 размером 1280x720.
|
||||
"""
|
||||
|
||||
if len(panels) != 4:
|
||||
raise RuntimeError(
|
||||
"Для сетки preview требуется ровно четыре панели."
|
||||
)
|
||||
|
||||
for panel in panels:
|
||||
if panel.shape != (PANEL_HEIGHT, PANEL_WIDTH, 3):
|
||||
raise RuntimeError(
|
||||
f"Некорректный размер панели: {panel.shape}"
|
||||
)
|
||||
|
||||
top_row = np.hstack((panels[0], panels[1]))
|
||||
bottom_row = np.hstack((panels[2], panels[3]))
|
||||
grid = np.vstack((top_row, bottom_row))
|
||||
|
||||
if grid.shape != (OUTPUT_HEIGHT, OUTPUT_WIDTH, 3):
|
||||
raise RuntimeError(
|
||||
f"Некорректный размер сетки: {grid.shape}"
|
||||
)
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]:
|
||||
"""
|
||||
Открывает MP4 writer либо разрешённый fallback AVI/MJPG.
|
||||
"""
|
||||
|
||||
mp4_writer = cv2.VideoWriter(
|
||||
str(MP4_PREVIEW_PATH),
|
||||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||||
OUTPUT_FPS,
|
||||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||||
True,
|
||||
)
|
||||
|
||||
if mp4_writer.isOpened():
|
||||
return mp4_writer, MP4_PREVIEW_PATH, False
|
||||
|
||||
mp4_writer.release()
|
||||
|
||||
if MP4_PREVIEW_PATH.exists():
|
||||
MP4_PREVIEW_PATH.unlink()
|
||||
|
||||
avi_writer = cv2.VideoWriter(
|
||||
str(AVI_PREVIEW_PATH),
|
||||
cv2.VideoWriter_fourcc(*"MJPG"),
|
||||
OUTPUT_FPS,
|
||||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||||
True,
|
||||
)
|
||||
|
||||
if not avi_writer.isOpened():
|
||||
avi_writer.release()
|
||||
|
||||
if AVI_PREVIEW_PATH.exists():
|
||||
AVI_PREVIEW_PATH.unlink()
|
||||
|
||||
raise RuntimeError(
|
||||
"OpenCV не смог открыть ни MP4, ни AVI writer."
|
||||
)
|
||||
|
||||
return avi_writer, AVI_PREVIEW_PATH, True
|
||||
|
||||
|
||||
def create_preview(
|
||||
source_path: Path,
|
||||
profiles: list[PreviewProfile],
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
source_fps: float,
|
||||
expected_frame_count: int,
|
||||
) -> tuple[Path, bool, int, list[ProfileState]]:
|
||||
"""
|
||||
Создаёт preview-видео с одним выходным кадром на исходный кадр.
|
||||
"""
|
||||
|
||||
if len(profiles) != 4:
|
||||
raise RuntimeError(
|
||||
"Ожидалось ровно четыре preview-профиля."
|
||||
)
|
||||
|
||||
PREVIEW_DIRECTORY.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(
|
||||
f"OpenCV не смог открыть видео: {source_path}"
|
||||
)
|
||||
|
||||
writer, preview_path, fallback_used = open_preview_writer()
|
||||
source_roi = normalized_roi_to_pixels(
|
||||
source_width,
|
||||
source_height,
|
||||
)
|
||||
panel_roi = normalized_roi_to_pixels(
|
||||
PANEL_WIDTH,
|
||||
PANEL_HEIGHT,
|
||||
)
|
||||
states = [
|
||||
ProfileState()
|
||||
for _ in profiles
|
||||
]
|
||||
frame_index = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
frame_read, source_frame = capture.read()
|
||||
|
||||
if not frame_read:
|
||||
break
|
||||
|
||||
if source_frame is None:
|
||||
raise RuntimeError(
|
||||
f"Получен пустой кадр {frame_index}."
|
||||
)
|
||||
|
||||
if (
|
||||
source_frame.shape[1] != source_width
|
||||
or source_frame.shape[0] != source_height
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Размер кадра отличается от метаданных."
|
||||
)
|
||||
|
||||
current_time = frame_index / source_fps
|
||||
panels: list[np.ndarray] = []
|
||||
|
||||
for profile, state in zip(profiles, states):
|
||||
if should_update(
|
||||
current_time,
|
||||
state.next_base_time,
|
||||
):
|
||||
state.latest_base = encode_decode_base(
|
||||
source_frame,
|
||||
profile,
|
||||
)
|
||||
state.last_base_update_time = current_time
|
||||
state.next_base_time += 1.0 / profile.base_fps
|
||||
state.base_update_count += 1
|
||||
|
||||
if (
|
||||
profile.roi_enabled
|
||||
and should_update(
|
||||
current_time,
|
||||
state.next_roi_time,
|
||||
)
|
||||
):
|
||||
state.latest_roi = encode_decode_roi(
|
||||
source_frame,
|
||||
source_roi,
|
||||
profile,
|
||||
)
|
||||
state.last_roi_update_time = current_time
|
||||
state.next_roi_time += 1.0 / profile.roi_fps
|
||||
state.roi_update_count += 1
|
||||
|
||||
reconstructed = reconstruct_panel(
|
||||
profile,
|
||||
state,
|
||||
panel_roi,
|
||||
)
|
||||
panels.append(
|
||||
draw_panel_information(
|
||||
reconstructed,
|
||||
profile,
|
||||
state,
|
||||
current_time,
|
||||
panel_roi,
|
||||
)
|
||||
)
|
||||
|
||||
writer.write(compose_grid(panels))
|
||||
frame_index += 1
|
||||
|
||||
if (
|
||||
frame_index % 100 == 0
|
||||
or frame_index == expected_frame_count
|
||||
):
|
||||
print(
|
||||
f" Written frames: "
|
||||
f"{frame_index}/{expected_frame_count}"
|
||||
)
|
||||
except Exception:
|
||||
capture.release()
|
||||
writer.release()
|
||||
|
||||
if preview_path.exists():
|
||||
preview_path.unlink()
|
||||
|
||||
raise
|
||||
finally:
|
||||
capture.release()
|
||||
writer.release()
|
||||
|
||||
if frame_index != expected_frame_count:
|
||||
if preview_path.exists():
|
||||
preview_path.unlink()
|
||||
|
||||
raise RuntimeError(
|
||||
"Число записанных кадров не совпало с исходным: "
|
||||
f"{frame_index} != {expected_frame_count}."
|
||||
)
|
||||
|
||||
return (
|
||||
preview_path,
|
||||
fallback_used,
|
||||
frame_index,
|
||||
states,
|
||||
)
|
||||
|
||||
|
||||
def read_frame_at(
|
||||
capture: cv2.VideoCapture,
|
||||
frame_index: int,
|
||||
) -> tuple[bool, tuple[int, ...] | None]:
|
||||
"""
|
||||
Читает один кадр preview по индексу для итоговой проверки.
|
||||
"""
|
||||
|
||||
capture.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
frame_index,
|
||||
)
|
||||
frame_read, frame = capture.read()
|
||||
|
||||
if not frame_read or frame is None:
|
||||
return False, None
|
||||
|
||||
return True, frame.shape
|
||||
|
||||
|
||||
def verify_preview(
|
||||
preview_path: Path,
|
||||
source_frame_count: int,
|
||||
source_duration_seconds: float,
|
||||
) -> tuple[
|
||||
int,
|
||||
int,
|
||||
float,
|
||||
int,
|
||||
float,
|
||||
tuple[bool, tuple[int, ...] | None],
|
||||
tuple[bool, tuple[int, ...] | None],
|
||||
tuple[bool, tuple[int, ...] | None],
|
||||
]:
|
||||
"""
|
||||
Проверяет контейнер, геометрию, FPS, длительность и три кадра.
|
||||
"""
|
||||
|
||||
if not preview_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Preview отсутствует: {preview_path}"
|
||||
)
|
||||
|
||||
if preview_path.stat().st_size <= 0:
|
||||
raise RuntimeError("Preview имеет нулевой размер.")
|
||||
|
||||
capture = cv2.VideoCapture(str(preview_path))
|
||||
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(
|
||||
f"OpenCV не смог открыть preview: {preview_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
if fps <= 0.0:
|
||||
raise RuntimeError("FPS preview равен нулю.")
|
||||
|
||||
duration_seconds = frame_count / fps
|
||||
first_frame = read_frame_at(capture, 0)
|
||||
middle_frame = read_frame_at(
|
||||
capture,
|
||||
frame_count // 2,
|
||||
)
|
||||
last_frame = read_frame_at(
|
||||
capture,
|
||||
frame_count - 1,
|
||||
)
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
if width != OUTPUT_WIDTH or height != OUTPUT_HEIGHT:
|
||||
raise RuntimeError(
|
||||
f"Некорректное разрешение preview: {width}x{height}."
|
||||
)
|
||||
|
||||
if frame_count != source_frame_count:
|
||||
raise RuntimeError(
|
||||
"Число кадров preview не совпало с исходным."
|
||||
)
|
||||
|
||||
allowed_duration_difference = (
|
||||
1.0 / fps + FRAME_TIME_EPSILON_SECONDS
|
||||
)
|
||||
|
||||
if (
|
||||
abs(duration_seconds - source_duration_seconds)
|
||||
> allowed_duration_difference
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Длительность preview отличается более чем на один кадр."
|
||||
)
|
||||
|
||||
for label, frame_result in [
|
||||
("первый", first_frame),
|
||||
("средний", middle_frame),
|
||||
("последний", last_frame),
|
||||
]:
|
||||
if not frame_result[0]:
|
||||
raise RuntimeError(
|
||||
f"Не удалось прочитать {label} кадр preview."
|
||||
)
|
||||
|
||||
return (
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
frame_count,
|
||||
duration_seconds,
|
||||
first_frame,
|
||||
middle_frame,
|
||||
last_frame,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Создаёт и проверяет динамическое preview Lab027.
|
||||
"""
|
||||
|
||||
print("Reading source video metadata...")
|
||||
(
|
||||
source_width,
|
||||
source_height,
|
||||
source_fps,
|
||||
source_frame_count,
|
||||
source_duration_seconds,
|
||||
) = read_video_metadata(SOURCE_VIDEO_PATH)
|
||||
|
||||
print(f" Source: {SOURCE_VIDEO_PATH}")
|
||||
print(f" Resolution: {source_width}x{source_height}")
|
||||
print(f" FPS: {source_fps:.6f}")
|
||||
print(f" Frames: {source_frame_count}")
|
||||
print(f" Duration: {source_duration_seconds:.6f} s")
|
||||
|
||||
profiles = build_profiles()
|
||||
print("Creating dynamic preview...")
|
||||
(
|
||||
preview_path,
|
||||
fallback_used,
|
||||
written_frame_count,
|
||||
states,
|
||||
) = create_preview(
|
||||
source_path=SOURCE_VIDEO_PATH,
|
||||
profiles=profiles,
|
||||
source_width=source_width,
|
||||
source_height=source_height,
|
||||
source_fps=source_fps,
|
||||
expected_frame_count=source_frame_count,
|
||||
)
|
||||
|
||||
print("Verifying preview...")
|
||||
(
|
||||
preview_width,
|
||||
preview_height,
|
||||
preview_fps,
|
||||
preview_frame_count,
|
||||
preview_duration_seconds,
|
||||
first_frame,
|
||||
middle_frame,
|
||||
last_frame,
|
||||
) = verify_preview(
|
||||
preview_path,
|
||||
source_frame_count,
|
||||
source_duration_seconds,
|
||||
)
|
||||
|
||||
print("")
|
||||
print(f"Preview path: {preview_path}")
|
||||
print(f"Fallback used: {fallback_used}")
|
||||
print(f"File size: {preview_path.stat().st_size} bytes")
|
||||
print(
|
||||
f"Preview resolution: "
|
||||
f"{preview_width}x{preview_height}"
|
||||
)
|
||||
print(f"Preview FPS: {preview_fps:.6f}")
|
||||
print(f"Written frames: {written_frame_count}")
|
||||
print(f"Verified frames: {preview_frame_count}")
|
||||
print(
|
||||
f"Preview duration: "
|
||||
f"{preview_duration_seconds:.6f} s"
|
||||
)
|
||||
print(f"First frame: {first_frame}")
|
||||
print(f"Middle frame: {middle_frame}")
|
||||
print(f"Last frame: {last_frame}")
|
||||
print("")
|
||||
print("Profile update counts:")
|
||||
|
||||
for profile, state in zip(profiles, states):
|
||||
print(
|
||||
f" {profile.name}: "
|
||||
f"BASE={state.base_update_count}, "
|
||||
f"ROI={state.roi_update_count}"
|
||||
)
|
||||
|
||||
print("")
|
||||
print("Lab027 temporal preview completed successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,869 +0,0 @@
|
||||
"""
|
||||
Lab028. Packetization of synchronous BASE + ROI JPEG video objects.
|
||||
|
||||
The laboratory forms the selected Lab027E profile directly from the source
|
||||
video, keeps every JPEG and packet in memory, verifies the transport layer,
|
||||
and writes only aggregate CSV/report/plot artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import random
|
||||
from typing import Callable
|
||||
|
||||
import cv2
|
||||
import matplotlib
|
||||
import numpy as np
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from protocol.video_packet import (
|
||||
CompositeFrame,
|
||||
CompositeReassembler,
|
||||
HEADER_FORMAT,
|
||||
HEADER_SIZE,
|
||||
ObjectCRCError,
|
||||
ObjectType,
|
||||
PacketCRCError,
|
||||
VideoPacket,
|
||||
decode_packet,
|
||||
encode_packet,
|
||||
packetize_jpeg,
|
||||
)
|
||||
|
||||
|
||||
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab028")
|
||||
CSV_PATH = OUTPUT_DIRECTORY / "lab028_packet_payload_results.csv"
|
||||
REPORT_PATH = OUTPUT_DIRECTORY / "lab028_report.txt"
|
||||
OVERHEAD_PLOT_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab028_overhead_efficiency.png"
|
||||
)
|
||||
TRAFFIC_PLOT_PATH = (
|
||||
OUTPUT_DIRECTORY / "lab028_packets_wire_bitrate.png"
|
||||
)
|
||||
|
||||
COMPOSITE_FPS = 3.0
|
||||
BASE_WIDTH = 240
|
||||
BASE_HEIGHT = 135
|
||||
BASE_QUALITY = 23
|
||||
ROI_WIDTH = 320
|
||||
ROI_HEIGHT = 180
|
||||
ROI_QUALITY = 33
|
||||
ROI_X_MIN = 0.20
|
||||
ROI_X_MAX = 0.80
|
||||
ROI_Y_MIN = 0.42
|
||||
ROI_Y_MAX = 1.00
|
||||
PAYLOAD_LENGTHS = (64, 128, 256, 512, 1024)
|
||||
FRAME_TIME_EPSILON_SECONDS = 1e-9
|
||||
|
||||
CSV_FIELDS = [
|
||||
"max_payload_bytes",
|
||||
"composite_frames",
|
||||
"mean_base_packets_per_frame",
|
||||
"mean_roi_packets_per_frame",
|
||||
"mean_total_packets_per_frame",
|
||||
"max_total_packets_per_frame",
|
||||
"packets_per_second",
|
||||
"jpeg_payload_bytes",
|
||||
"jpeg_payload_bitrate_kbps",
|
||||
"header_bytes_per_second",
|
||||
"header_bitrate_kbps",
|
||||
"wire_bytes",
|
||||
"wire_bitrate_kbps",
|
||||
"service_data_percent",
|
||||
"efficiency_percent",
|
||||
"mean_wire_packet_bytes",
|
||||
"p95_wire_packet_bytes",
|
||||
"max_wire_packet_bytes",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMetadata:
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
frame_count: int
|
||||
duration_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncodedComposite:
|
||||
composite_frame_id: int
|
||||
source_frame_index: int
|
||||
base_jpeg: bytes
|
||||
roi_jpeg: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayloadMetrics:
|
||||
max_payload_bytes: int
|
||||
composite_frames: int
|
||||
mean_base_packets_per_frame: float
|
||||
mean_roi_packets_per_frame: float
|
||||
mean_total_packets_per_frame: float
|
||||
max_total_packets_per_frame: int
|
||||
packets_per_second: float
|
||||
jpeg_payload_bytes: int
|
||||
jpeg_payload_bitrate_kbps: float
|
||||
header_bytes_per_second: float
|
||||
header_bitrate_kbps: float
|
||||
wire_bytes: int
|
||||
wire_bitrate_kbps: float
|
||||
service_data_percent: float
|
||||
efficiency_percent: float
|
||||
mean_wire_packet_bytes: float
|
||||
p95_wire_packet_bytes: float
|
||||
max_wire_packet_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def normalized_roi_to_pixels(
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Use the normalized ROI coordinates from Lab027 through Lab027E."""
|
||||
|
||||
coordinates = (
|
||||
int(round(width * ROI_X_MIN)),
|
||||
int(round(height * ROI_Y_MIN)),
|
||||
int(round(width * ROI_X_MAX)),
|
||||
int(round(height * ROI_Y_MAX)),
|
||||
)
|
||||
x_min, y_min, x_max, y_max = coordinates
|
||||
if not (
|
||||
0 <= x_min < x_max <= width
|
||||
and 0 <= y_min < y_max <= height
|
||||
):
|
||||
raise RuntimeError("calculated ROI is outside the source frame")
|
||||
return coordinates
|
||||
|
||||
|
||||
def encode_grayscale_jpeg(image: np.ndarray, quality: int) -> bytes:
|
||||
"""Encode one grayscale image to an in-memory JPEG."""
|
||||
|
||||
encoded, buffer = cv2.imencode(
|
||||
".jpg",
|
||||
image,
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), int(quality)],
|
||||
)
|
||||
if not encoded:
|
||||
raise RuntimeError("OpenCV could not encode JPEG")
|
||||
jpeg = buffer.tobytes()
|
||||
if not jpeg:
|
||||
raise RuntimeError("OpenCV produced an empty JPEG")
|
||||
return jpeg
|
||||
|
||||
|
||||
def encode_composite(
|
||||
source_frame: np.ndarray,
|
||||
source_roi: tuple[int, int, int, int],
|
||||
composite_frame_id: int,
|
||||
source_frame_index: int,
|
||||
) -> EncodedComposite:
|
||||
"""Form synchronous BASE and ROI JPEGs from exactly one source frame."""
|
||||
|
||||
grayscale = cv2.cvtColor(source_frame, cv2.COLOR_BGR2GRAY)
|
||||
base = cv2.resize(
|
||||
grayscale,
|
||||
(BASE_WIDTH, BASE_HEIGHT),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
x_min, y_min, x_max, y_max = source_roi
|
||||
roi = grayscale[y_min:y_max, x_min:x_max]
|
||||
if roi.size == 0:
|
||||
raise RuntimeError("source ROI is empty")
|
||||
roi = cv2.resize(
|
||||
roi,
|
||||
(ROI_WIDTH, ROI_HEIGHT),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
return EncodedComposite(
|
||||
composite_frame_id=composite_frame_id,
|
||||
source_frame_index=source_frame_index,
|
||||
base_jpeg=encode_grayscale_jpeg(base, BASE_QUALITY),
|
||||
roi_jpeg=encode_grayscale_jpeg(roi, ROI_QUALITY),
|
||||
)
|
||||
|
||||
|
||||
def load_video_profile(
|
||||
source_path: Path,
|
||||
) -> tuple[VideoMetadata, list[EncodedComposite]]:
|
||||
"""Read the video sequentially and select synchronous updates at 3 fps."""
|
||||
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"source video is missing: {source_path}")
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(f"OpenCV could not open {source_path}")
|
||||
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
declared_frame_count = int(
|
||||
capture.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
)
|
||||
if width <= 0 or height <= 0 or fps <= 0.0:
|
||||
capture.release()
|
||||
raise RuntimeError("invalid source video metadata")
|
||||
|
||||
source_roi = normalized_roi_to_pixels(width, height)
|
||||
selected: list[EncodedComposite] = []
|
||||
source_frame_index = 0
|
||||
next_composite_time = 0.0
|
||||
try:
|
||||
while True:
|
||||
frame_read, source_frame = capture.read()
|
||||
if not frame_read or source_frame is None:
|
||||
break
|
||||
source_time = source_frame_index / fps
|
||||
if (
|
||||
source_time + FRAME_TIME_EPSILON_SECONDS
|
||||
>= next_composite_time
|
||||
):
|
||||
selected.append(
|
||||
encode_composite(
|
||||
source_frame,
|
||||
source_roi,
|
||||
len(selected),
|
||||
source_frame_index,
|
||||
)
|
||||
)
|
||||
next_composite_time += 1.0 / COMPOSITE_FPS
|
||||
source_frame_index += 1
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
if source_frame_index <= 0 or not selected:
|
||||
raise RuntimeError("source video did not yield frames")
|
||||
if (
|
||||
declared_frame_count > 0
|
||||
and source_frame_index != declared_frame_count
|
||||
):
|
||||
raise RuntimeError(
|
||||
"decoded frame count differs from video metadata: "
|
||||
f"{source_frame_index} != {declared_frame_count}"
|
||||
)
|
||||
metadata = VideoMetadata(
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
frame_count=source_frame_index,
|
||||
duration_seconds=source_frame_index / fps,
|
||||
)
|
||||
return metadata, selected
|
||||
|
||||
|
||||
def packets_for_composite(
|
||||
composite: EncodedComposite,
|
||||
max_payload_bytes: int,
|
||||
) -> tuple[list[bytes], list[bytes]]:
|
||||
"""Packetize BASE and ROI separately with one composite frame ID."""
|
||||
|
||||
base_packets = packetize_jpeg(
|
||||
composite.base_jpeg,
|
||||
composite.composite_frame_id,
|
||||
ObjectType.BASE,
|
||||
max_payload_bytes,
|
||||
)
|
||||
roi_packets = packetize_jpeg(
|
||||
composite.roi_jpeg,
|
||||
composite.composite_frame_id,
|
||||
ObjectType.ROI,
|
||||
max_payload_bytes,
|
||||
)
|
||||
return base_packets, roi_packets
|
||||
|
||||
|
||||
def calculate_payload_metrics(
|
||||
composites: list[EncodedComposite],
|
||||
duration_seconds: float,
|
||||
max_payload_bytes: int,
|
||||
) -> PayloadMetrics:
|
||||
"""Calculate actual packet and bitrate statistics for one payload limit."""
|
||||
|
||||
base_counts: list[int] = []
|
||||
roi_counts: list[int] = []
|
||||
total_counts: list[int] = []
|
||||
wire_packet_sizes: list[int] = []
|
||||
for composite in composites:
|
||||
base_packets, roi_packets = packets_for_composite(
|
||||
composite, max_payload_bytes
|
||||
)
|
||||
base_counts.append(len(base_packets))
|
||||
roi_counts.append(len(roi_packets))
|
||||
total_counts.append(len(base_packets) + len(roi_packets))
|
||||
wire_packet_sizes.extend(
|
||||
len(packet) for packet in base_packets + roi_packets
|
||||
)
|
||||
|
||||
jpeg_payload_bytes = sum(
|
||||
len(composite.base_jpeg) + len(composite.roi_jpeg)
|
||||
for composite in composites
|
||||
)
|
||||
packet_count = len(wire_packet_sizes)
|
||||
header_bytes = packet_count * HEADER_SIZE
|
||||
wire_bytes = jpeg_payload_bytes + header_bytes
|
||||
return PayloadMetrics(
|
||||
max_payload_bytes=max_payload_bytes,
|
||||
composite_frames=len(composites),
|
||||
mean_base_packets_per_frame=float(np.mean(base_counts)),
|
||||
mean_roi_packets_per_frame=float(np.mean(roi_counts)),
|
||||
mean_total_packets_per_frame=float(np.mean(total_counts)),
|
||||
max_total_packets_per_frame=max(total_counts),
|
||||
packets_per_second=packet_count / duration_seconds,
|
||||
jpeg_payload_bytes=jpeg_payload_bytes,
|
||||
jpeg_payload_bitrate_kbps=(
|
||||
jpeg_payload_bytes * 8.0 / duration_seconds / 1000.0
|
||||
),
|
||||
header_bytes_per_second=header_bytes / duration_seconds,
|
||||
header_bitrate_kbps=(
|
||||
header_bytes * 8.0 / duration_seconds / 1000.0
|
||||
),
|
||||
wire_bytes=wire_bytes,
|
||||
wire_bitrate_kbps=(
|
||||
wire_bytes * 8.0 / duration_seconds / 1000.0
|
||||
),
|
||||
service_data_percent=header_bytes / wire_bytes * 100.0,
|
||||
efficiency_percent=jpeg_payload_bytes / wire_bytes * 100.0,
|
||||
mean_wire_packet_bytes=float(np.mean(wire_packet_sizes)),
|
||||
p95_wire_packet_bytes=float(
|
||||
np.percentile(wire_packet_sizes, 95)
|
||||
),
|
||||
max_wire_packet_bytes=max(wire_packet_sizes),
|
||||
)
|
||||
|
||||
|
||||
def feed_packets(
|
||||
packets: list[bytes],
|
||||
reassembler: CompositeReassembler | None = None,
|
||||
) -> tuple[list[CompositeFrame], CompositeReassembler]:
|
||||
"""Feed packets and collect every atomically published frame."""
|
||||
|
||||
receiver = reassembler or CompositeReassembler()
|
||||
completed = []
|
||||
for packet in packets:
|
||||
frame = receiver.ingest(packet)
|
||||
if frame is not None:
|
||||
completed.append(frame)
|
||||
return completed, receiver
|
||||
|
||||
|
||||
def assert_frame_matches(
|
||||
frame: CompositeFrame,
|
||||
expected: EncodedComposite,
|
||||
) -> None:
|
||||
if frame.composite_frame_id != expected.composite_frame_id:
|
||||
raise AssertionError("composite frame ID differs")
|
||||
if frame.base_jpeg != expected.base_jpeg:
|
||||
raise AssertionError("BASE JPEG differs byte-for-byte")
|
||||
if frame.roi_jpeg != expected.roi_jpeg:
|
||||
raise AssertionError("ROI JPEG differs byte-for-byte")
|
||||
|
||||
|
||||
def run_functional_tests(
|
||||
composites: list[EncodedComposite],
|
||||
) -> list[TestResult]:
|
||||
"""Run header and all mandatory Lab028 transport checks."""
|
||||
|
||||
if len(composites) < 2:
|
||||
raise RuntimeError("functional checks need two video frames")
|
||||
first = composites[0]
|
||||
second = composites[1]
|
||||
base_packets, roi_packets = packets_for_composite(first, 256)
|
||||
all_packets = base_packets + roi_packets
|
||||
tests: list[tuple[str, Callable[[], str]]] = []
|
||||
|
||||
def header_round_trip() -> str:
|
||||
parsed = decode_packet(all_packets[0])
|
||||
rebuilt = encode_packet(
|
||||
VideoPacket(
|
||||
composite_frame_id=parsed.composite_frame_id,
|
||||
object_type=parsed.object_type,
|
||||
fragment_index=parsed.fragment_index,
|
||||
fragment_count=parsed.fragment_count,
|
||||
jpeg_size=parsed.jpeg_size,
|
||||
object_crc32=parsed.object_crc32,
|
||||
payload=parsed.payload,
|
||||
)
|
||||
)
|
||||
if rebuilt != all_packets[0]:
|
||||
raise AssertionError("serialized bytes changed after round trip")
|
||||
return f"fixed {HEADER_SIZE}-byte header round trip is exact"
|
||||
|
||||
def ordered_lossless() -> str:
|
||||
frames, _ = feed_packets(all_packets)
|
||||
if len(frames) != 1:
|
||||
raise AssertionError("ordered transfer did not emit one frame")
|
||||
assert_frame_matches(frames[0], first)
|
||||
return "BASE and ROI match original JPEG bytes"
|
||||
|
||||
def shuffled_packets() -> str:
|
||||
shuffled = list(all_packets)
|
||||
random.Random(28001).shuffle(shuffled)
|
||||
frames, _ = feed_packets(shuffled)
|
||||
if len(frames) != 1:
|
||||
raise AssertionError("shuffled transfer did not emit one frame")
|
||||
assert_frame_matches(frames[0], first)
|
||||
return "arbitrary packet order reconstructed correctly"
|
||||
|
||||
def duplicate_packets() -> str:
|
||||
duplicated = list(all_packets)
|
||||
duplicated.extend(
|
||||
[all_packets[0], all_packets[len(base_packets)]]
|
||||
)
|
||||
random.Random(28002).shuffle(duplicated)
|
||||
frames, receiver = feed_packets(duplicated)
|
||||
if len(frames) != 1:
|
||||
raise AssertionError("duplicates changed publication count")
|
||||
assert_frame_matches(frames[0], first)
|
||||
if receiver.duplicate_packets < 2:
|
||||
raise AssertionError("exact duplicates were not counted")
|
||||
return f"{receiver.duplicate_packets} exact duplicates ignored"
|
||||
|
||||
def packet_crc_corruption() -> str:
|
||||
corrupted = bytearray(all_packets[0])
|
||||
corrupted[-1] ^= 0x01
|
||||
receiver = CompositeReassembler()
|
||||
try:
|
||||
receiver.ingest(bytes(corrupted))
|
||||
except PacketCRCError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("corrupted packet passed packet CRC")
|
||||
frames, _ = feed_packets(all_packets[1:], receiver)
|
||||
if frames:
|
||||
raise AssertionError("frame emitted despite rejected packet")
|
||||
return "payload bit flip rejected; composite not emitted"
|
||||
|
||||
def missing_fragment() -> str:
|
||||
missing_packet = roi_packets[len(roi_packets) // 2]
|
||||
missing_index = decode_packet(missing_packet).fragment_index
|
||||
remaining = [
|
||||
packet
|
||||
for packet in all_packets
|
||||
if packet is not missing_packet
|
||||
]
|
||||
frames, receiver = feed_packets(remaining)
|
||||
if frames:
|
||||
raise AssertionError("frame emitted with a missing fragment")
|
||||
missing = receiver.missing_fragments(
|
||||
first.composite_frame_id, ObjectType.ROI
|
||||
)
|
||||
if missing is None or missing_index not in missing:
|
||||
raise AssertionError("missing fragment was not reported")
|
||||
return f"ROI fragment {missing_index} reported missing"
|
||||
|
||||
def object_crc_corruption() -> str:
|
||||
target_position = len(base_packets)
|
||||
parsed = decode_packet(all_packets[target_position])
|
||||
changed_payload = bytearray(parsed.payload)
|
||||
changed_payload[0] ^= 0x01
|
||||
altered = encode_packet(
|
||||
VideoPacket(
|
||||
composite_frame_id=parsed.composite_frame_id,
|
||||
object_type=parsed.object_type,
|
||||
fragment_index=parsed.fragment_index,
|
||||
fragment_count=parsed.fragment_count,
|
||||
jpeg_size=parsed.jpeg_size,
|
||||
object_crc32=parsed.object_crc32,
|
||||
payload=bytes(changed_payload),
|
||||
)
|
||||
)
|
||||
formally_valid = list(all_packets)
|
||||
formally_valid[target_position] = altered
|
||||
receiver = CompositeReassembler()
|
||||
emitted = []
|
||||
object_error_seen = False
|
||||
for packet in formally_valid:
|
||||
try:
|
||||
frame = receiver.ingest(packet)
|
||||
except ObjectCRCError:
|
||||
object_error_seen = True
|
||||
continue
|
||||
if frame is not None:
|
||||
emitted.append(frame)
|
||||
if not object_error_seen:
|
||||
raise AssertionError("object CRC did not detect changed JPEG")
|
||||
if emitted:
|
||||
raise AssertionError("frame emitted after object CRC failure")
|
||||
return "valid packet CRCs still failed full-object CRC"
|
||||
|
||||
def adjacent_frames_do_not_mix() -> str:
|
||||
first_packets = sum(packets_for_composite(first, 256), [])
|
||||
second_packets = sum(packets_for_composite(second, 256), [])
|
||||
interleaved = first_packets + second_packets
|
||||
random.Random(28003).shuffle(interleaved)
|
||||
frames, _ = feed_packets(interleaved)
|
||||
by_id = {frame.composite_frame_id: frame for frame in frames}
|
||||
if set(by_id) != {
|
||||
first.composite_frame_id,
|
||||
second.composite_frame_id,
|
||||
}:
|
||||
raise AssertionError("adjacent frames were lost or mixed")
|
||||
assert_frame_matches(by_id[first.composite_frame_id], first)
|
||||
assert_frame_matches(by_id[second.composite_frame_id], second)
|
||||
return "two interleaved frame IDs remained independent"
|
||||
|
||||
def base_only_is_not_atomic() -> str:
|
||||
frames, receiver = feed_packets(base_packets)
|
||||
if frames:
|
||||
raise AssertionError("BASE-only input emitted a composite")
|
||||
if not receiver.object_is_complete(
|
||||
first.composite_frame_id, ObjectType.BASE
|
||||
):
|
||||
raise AssertionError("complete BASE object was not retained")
|
||||
return "complete BASE retained while composite stayed unpublished"
|
||||
|
||||
tests.extend(
|
||||
[
|
||||
("header_serialization_round_trip", header_round_trip),
|
||||
("ordered_lossless_transfer", ordered_lossless),
|
||||
("random_packet_order", shuffled_packets),
|
||||
("exact_duplicate_packets", duplicate_packets),
|
||||
("packet_crc_corruption", packet_crc_corruption),
|
||||
("missing_fragment", missing_fragment),
|
||||
("object_crc_corruption", object_crc_corruption),
|
||||
("adjacent_frame_isolation", adjacent_frames_do_not_mix),
|
||||
("base_without_roi_atomicity", base_only_is_not_atomic),
|
||||
]
|
||||
)
|
||||
|
||||
results = []
|
||||
for name, test in tests:
|
||||
try:
|
||||
detail = test()
|
||||
except Exception as error:
|
||||
results.append(TestResult(name, False, str(error)))
|
||||
else:
|
||||
results.append(TestResult(name, True, detail))
|
||||
failed = [result for result in results if not result.passed]
|
||||
if failed:
|
||||
details = "; ".join(
|
||||
f"{result.name}: {result.detail}" for result in failed
|
||||
)
|
||||
raise RuntimeError(f"functional transport tests failed: {details}")
|
||||
return results
|
||||
|
||||
|
||||
def save_csv(metrics: list[PayloadMetrics]) -> None:
|
||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
for item in metrics:
|
||||
row = {}
|
||||
for field_name in CSV_FIELDS:
|
||||
value = getattr(item, field_name)
|
||||
row[field_name] = (
|
||||
f"{value:.6f}"
|
||||
if isinstance(value, float)
|
||||
else value
|
||||
)
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def save_plots(metrics: list[PayloadMetrics]) -> None:
|
||||
payloads = [item.max_payload_bytes for item in metrics]
|
||||
|
||||
figure, axis = plt.subplots(figsize=(9, 5.5))
|
||||
axis.plot(
|
||||
payloads,
|
||||
[item.service_data_percent for item in metrics],
|
||||
marker="o",
|
||||
linewidth=2,
|
||||
label="Service data (header / wire)",
|
||||
)
|
||||
axis.plot(
|
||||
payloads,
|
||||
[item.efficiency_percent for item in metrics],
|
||||
marker="s",
|
||||
linewidth=2,
|
||||
label="Efficiency (JPEG / wire)",
|
||||
)
|
||||
axis.set_xscale("log", base=2)
|
||||
axis.set_xticks(payloads)
|
||||
axis.set_xticklabels([str(value) for value in payloads])
|
||||
axis.set_xlabel("Maximum packet payload, bytes")
|
||||
axis.set_ylabel("Share, %")
|
||||
axis.set_title("Lab028 packet overhead and efficiency")
|
||||
axis.grid(True, alpha=0.3)
|
||||
axis.legend()
|
||||
figure.tight_layout()
|
||||
figure.savefig(OVERHEAD_PLOT_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
figure, packet_axis = plt.subplots(figsize=(9, 5.5))
|
||||
bitrate_axis = packet_axis.twinx()
|
||||
packet_line = packet_axis.plot(
|
||||
payloads,
|
||||
[item.packets_per_second for item in metrics],
|
||||
color="tab:blue",
|
||||
marker="o",
|
||||
linewidth=2,
|
||||
label="Packets/s",
|
||||
)
|
||||
bitrate_lines = bitrate_axis.plot(
|
||||
payloads,
|
||||
[item.wire_bitrate_kbps for item in metrics],
|
||||
color="tab:red",
|
||||
marker="s",
|
||||
linewidth=2,
|
||||
label="Wire bitrate",
|
||||
)
|
||||
bitrate_axis.plot(
|
||||
payloads,
|
||||
[item.jpeg_payload_bitrate_kbps for item in metrics],
|
||||
color="tab:green",
|
||||
linestyle="--",
|
||||
linewidth=2,
|
||||
label="JPEG payload bitrate",
|
||||
)
|
||||
packet_axis.set_xscale("log", base=2)
|
||||
packet_axis.set_xticks(payloads)
|
||||
packet_axis.set_xticklabels([str(value) for value in payloads])
|
||||
packet_axis.set_xlabel("Maximum packet payload, bytes")
|
||||
packet_axis.set_ylabel("Packets per second", color="tab:blue")
|
||||
bitrate_axis.set_ylabel("Bitrate, kbit/s", color="tab:red")
|
||||
packet_axis.set_title("Lab028 packet rate and wire bitrate")
|
||||
packet_axis.grid(True, alpha=0.3)
|
||||
lines = packet_line + bitrate_lines + bitrate_axis.lines[1:]
|
||||
packet_axis.legend(
|
||||
lines,
|
||||
[line.get_label() for line in lines],
|
||||
loc="best",
|
||||
)
|
||||
figure.tight_layout()
|
||||
figure.savefig(TRAFFIC_PLOT_PATH, dpi=160)
|
||||
plt.close(figure)
|
||||
|
||||
|
||||
def metrics_table(metrics: list[PayloadMetrics]) -> list[str]:
|
||||
lines = [
|
||||
(
|
||||
"payload | BASE pkt/frame | ROI pkt/frame | all pkt/frame | "
|
||||
"max pkt/frame | pkt/s | JPEG kbit/s | headers B/s | "
|
||||
"wire kbit/s | service % | efficiency % | packet mean/p95/max"
|
||||
),
|
||||
(
|
||||
"-------:|---------------:|--------------:|--------------:|"
|
||||
"--------------:|------:|------------:|------------:|"
|
||||
"------------:|----------:|-------------:|--------------------:"
|
||||
),
|
||||
]
|
||||
for item in metrics:
|
||||
lines.append(
|
||||
f"{item.max_payload_bytes} | "
|
||||
f"{item.mean_base_packets_per_frame:.3f} | "
|
||||
f"{item.mean_roi_packets_per_frame:.3f} | "
|
||||
f"{item.mean_total_packets_per_frame:.3f} | "
|
||||
f"{item.max_total_packets_per_frame} | "
|
||||
f"{item.packets_per_second:.3f} | "
|
||||
f"{item.jpeg_payload_bitrate_kbps:.3f} | "
|
||||
f"{item.header_bytes_per_second:.3f} | "
|
||||
f"{item.wire_bitrate_kbps:.3f} | "
|
||||
f"{item.service_data_percent:.3f} | "
|
||||
f"{item.efficiency_percent:.3f} | "
|
||||
f"{item.mean_wire_packet_bytes:.1f}/"
|
||||
f"{item.p95_wire_packet_bytes:.1f}/"
|
||||
f"{item.max_wire_packet_bytes}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def write_report(
|
||||
metadata: VideoMetadata,
|
||||
composites: list[EncodedComposite],
|
||||
metrics: list[PayloadMetrics],
|
||||
test_results: list[TestResult],
|
||||
) -> None:
|
||||
source_roi = normalized_roi_to_pixels(
|
||||
metadata.width, metadata.height
|
||||
)
|
||||
base_sizes = [len(item.base_jpeg) for item in composites]
|
||||
roi_sizes = [len(item.roi_jpeg) for item in composites]
|
||||
lines = [
|
||||
"Lab028. Пакетирование синхронного BASE + ROI",
|
||||
"",
|
||||
"Профиль и исходные данные",
|
||||
f"- Видео: {SOURCE_VIDEO_PATH}",
|
||||
(
|
||||
f"- Источник: {metadata.width}x{metadata.height}, "
|
||||
f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
|
||||
f"{metadata.duration_seconds:.6f} с."
|
||||
),
|
||||
(
|
||||
f"- ROI: x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, "
|
||||
f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}; "
|
||||
f"пиксели x={source_roi[0]}...{source_roi[2]}, "
|
||||
f"y={source_roi[1]}...{source_roi[3]}."
|
||||
),
|
||||
(
|
||||
f"- Синхронные составные кадры: {len(composites)} "
|
||||
f"при {COMPOSITE_FPS:.3f} fps."
|
||||
),
|
||||
(
|
||||
f"- BASE: {BASE_WIDTH}x{BASE_HEIGHT}, grayscale JPEG Q"
|
||||
f"{BASE_QUALITY}; средний размер {np.mean(base_sizes):.3f} B, "
|
||||
f"min/max {min(base_sizes)}/{max(base_sizes)} B."
|
||||
),
|
||||
(
|
||||
f"- ROI: {ROI_WIDTH}x{ROI_HEIGHT}, grayscale JPEG Q"
|
||||
f"{ROI_QUALITY}; средний размер {np.mean(roi_sizes):.3f} B, "
|
||||
f"min/max {min(roi_sizes)}/{max(roi_sizes)} B."
|
||||
),
|
||||
(
|
||||
"- BASE и ROI формируются из одного source frame и имеют "
|
||||
"общий composite_frame_id."
|
||||
),
|
||||
"",
|
||||
"Бинарный заголовок",
|
||||
f"- struct format: {HEADER_FORMAT}",
|
||||
f"- Размер: {HEADER_SIZE} байта; network byte order; padding нет.",
|
||||
(
|
||||
"- Layout: magic[4] @0, version:u8 @4, object_type:u8 @5, "
|
||||
"header_size:u16 @6, composite_frame_id:u32 @8, "
|
||||
"fragment_index:u16 @12, fragment_count:u16 @14, "
|
||||
"payload_length:u16 @16, flags:u16 @18, jpeg_size:u32 @20, "
|
||||
"object_crc32:u32 @24, packet_crc32:u32 @28."
|
||||
),
|
||||
"- object_type: 1=BASE, 2=ROI; flags зарезервирован и равен 0.",
|
||||
"",
|
||||
"CRC32",
|
||||
(
|
||||
"- object_crc32 = zlib.crc32(полный JPEG) & 0xFFFFFFFF; "
|
||||
"значение помещается во все фрагменты объекта и проверяется "
|
||||
"после полной сборки."
|
||||
),
|
||||
(
|
||||
"- packet_crc32: сначала поле packet_crc32 заголовка "
|
||||
"обнуляется, затем CRC считается как "
|
||||
"zlib.crc32(header_with_zero_crc + payload) & 0xFFFFFFFF."
|
||||
),
|
||||
"- Таким образом packet CRC защищает все поля заголовка и payload.",
|
||||
"",
|
||||
"Функциональные проверки",
|
||||
]
|
||||
lines.extend(
|
||||
f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}"
|
||||
for item in test_results
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Результаты размеров packet payload",
|
||||
*metrics_table(metrics),
|
||||
"",
|
||||
(
|
||||
"JPEG payload bitrate одинаков для всех строк, потому что "
|
||||
"исходные JPEG не меняются при выборе размера фрагмента."
|
||||
),
|
||||
(
|
||||
"Wire bitrate включает только JPEG payload и 32-байтные "
|
||||
"заголовки каждого пакета."
|
||||
),
|
||||
(
|
||||
"Wire bitrate пока НЕ включает FEC, преамбулу, "
|
||||
"синхронизацию, модуляцию, интервалы, повторные передачи, "
|
||||
"команды управления и телеметрию."
|
||||
),
|
||||
(
|
||||
"Лучший размер packet payload автоматически не выбирается: "
|
||||
"таблица показывает только транспортный компромисс."
|
||||
),
|
||||
"",
|
||||
"Артефакты",
|
||||
f"- CSV: {CSV_PATH}",
|
||||
f"- Overhead/efficiency: {OVERHEAD_PLOT_PATH}",
|
||||
f"- Packet count/wire bitrate: {TRAFFIC_PLOT_PATH}",
|
||||
(
|
||||
"- Промежуточные JPEG и пакеты сохранялись только в памяти; "
|
||||
"бинарные дампы не создавались."
|
||||
),
|
||||
"",
|
||||
]
|
||||
)
|
||||
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def validate_outputs(metrics: list[PayloadMetrics]) -> None:
|
||||
if len(metrics) != len(PAYLOAD_LENGTHS):
|
||||
raise RuntimeError("not all payload sizes were measured")
|
||||
if [item.max_payload_bytes for item in metrics] != list(
|
||||
PAYLOAD_LENGTHS
|
||||
):
|
||||
raise RuntimeError("payload result order differs")
|
||||
if any(
|
||||
abs(
|
||||
item.service_data_percent
|
||||
+ item.efficiency_percent
|
||||
- 100.0
|
||||
) > 1e-9
|
||||
for item in metrics
|
||||
):
|
||||
raise RuntimeError("overhead and efficiency do not sum to 100%")
|
||||
for path in (
|
||||
CSV_PATH,
|
||||
REPORT_PATH,
|
||||
OVERHEAD_PLOT_PATH,
|
||||
TRAFFIC_PLOT_PATH,
|
||||
):
|
||||
if not path.exists() or path.stat().st_size <= 0:
|
||||
raise RuntimeError(f"missing or empty output: {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Lab028: loading and JPEG-encoding the Lab027E profile...")
|
||||
metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
|
||||
print(
|
||||
f" source={metadata.width}x{metadata.height}, "
|
||||
f"{metadata.fps:.3f} fps, frames={metadata.frame_count}"
|
||||
)
|
||||
print(f" synchronous composite frames={len(composites)}")
|
||||
|
||||
print("Running functional transport checks...")
|
||||
test_results = run_functional_tests(composites)
|
||||
for result in test_results:
|
||||
print(f" PASS {result.name}: {result.detail}")
|
||||
|
||||
print("Measuring packet payload sizes...")
|
||||
metrics = [
|
||||
calculate_payload_metrics(
|
||||
composites,
|
||||
metadata.duration_seconds,
|
||||
payload_length,
|
||||
)
|
||||
for payload_length in PAYLOAD_LENGTHS
|
||||
]
|
||||
for item in metrics:
|
||||
print(
|
||||
f" payload={item.max_payload_bytes:4d} B: "
|
||||
f"{item.packets_per_second:.3f} packet/s, "
|
||||
f"wire={item.wire_bitrate_kbps:.3f} kbit/s, "
|
||||
f"service={item.service_data_percent:.3f}%"
|
||||
)
|
||||
|
||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
save_csv(metrics)
|
||||
save_plots(metrics)
|
||||
write_report(metadata, composites, metrics, test_results)
|
||||
validate_outputs(metrics)
|
||||
print(f"CSV: {CSV_PATH}")
|
||||
print(f"Report: {REPORT_PATH}")
|
||||
print(f"Plots: {OVERHEAD_PLOT_PATH}, {TRAFFIC_PLOT_PATH}")
|
||||
print("Lab028 completed successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,748 +0,0 @@
|
||||
"""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 tests.lab028_video_packetization import COMPOSITE_FPS
|
||||
from tests.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("tests/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
|
||||
lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
|
||||
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")
|
||||
|
||||
|
||||
def validate_outputs():
|
||||
for path in (SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH):
|
||||
with path.open(encoding="utf-8",newline="") as file: rows=list(csv.DictReader(file))
|
||||
if len(rows)!=18: raise AssertionError(f"{path} must contain 18 rows")
|
||||
if "Lab035" not in REPORT_PATH.read_text(encoding="utf-8"): raise AssertionError("invalid report")
|
||||
for path in PLOT_PATHS:
|
||||
image=cv2.imread(str(path),cv2.IMREAD_UNCHANGED)
|
||||
if image is None or image.size==0: raise AssertionError(f"OpenCV could not read {path}")
|
||||
|
||||
|
||||
def main():
|
||||
lab033=build_lab033_workload(); aligned=build_aligned_workload(lab033)
|
||||
frames=build_frame_groups(aligned); high=high_priority_packets(aligned)
|
||||
results=run_experiment(aligned,frames,high)
|
||||
tests=run_functional_tests(aligned,frames,high,results)
|
||||
save_csv(results); save_plots(results); write_report(aligned,results,tests); validate_outputs()
|
||||
print(f"Lab035 complete: {len(results)} scenarios, {len(tests)} checks")
|
||||
|
||||
|
||||
if __name__=="__main__": main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user