Lab042: validate Pluto image hardware loopback

This commit is contained in:
LittleSam129
2026-08-17 18:29:14 +03:00
parent fce14f038e
commit fa2e473c6d
11 changed files with 276 additions and 53 deletions

View File

@@ -538,3 +538,56 @@ Lab042: первая передача изображения через реал
## Следующий этап
Кабельная петля через аттенюатор 30 дБ после получения переходника папа-папа. Это и есть заявленный опыт Lab042 с контролируемыми уровнями, его результат станет основным для лабораторной. Затем Lab043 — разведение на два устройства.
---
# Запись 020
## Дата
11 августа 2026 года
## Тема
Lab042 завершена: кабельная петля через аттенюатор, зависимость доставки от мощности, устранение трёх дефектов отчётности.
## Схема опыта
Выход передатчика PlutoSDR через кабель SMA, аттенюатор AT30S 30 дБ, переходник и второй кабель на вход приёмника того же устройства. Антенны сняты. Это заявленный основной опыт лабораторной с контролируемыми уровнями, в отличие от вспомогательной эфирной проверки Записи 019.
## Зависимость доставки от мощности передатчика
Кабельный тракт при неизменной схеме исследован по усилению передатчика. Получен отчётливый переход от отказа к устойчивой передаче:
- TX минус 30 дБ: 0 из 10 фрагментов, контрольная сумма не сходится ни разу, средняя корреляция 0,936. Сигнал ниже порога уверенного приёма.
- TX минус 20 дБ: 8 из 10 фрагментов, средняя корреляция 0,992. Пограничный режим: часть кадров проходит.
- TX минус 10 дБ: 10 из 10 фрагментов, изображение собрано побайтово. Устойчивая передача.
Значения TX минус 30 и минус 20 дБ — исследование порога, повторять их не требуется. Рабочая точка кабельной петли: TX минус 10 дБ, RX плюс 20 дБ, аттенюатор 30 дБ, несущая 435 МГц.
Начальные умолчания кабельного режима TX минус 30 и RX плюс 20 дБ на железе прежде не проверялись; отказ первого прогона объяснялся именно этим, а не дефектом обработки.
## Результат рабочей точки
- Найдено 10 кадров из 10, контрольная сумма сошлась у всех десяти, восстановлены все фрагменты.
- Изображение собрано и совпадает с переданным. Совпадение подтверждено независимо: SHA-256 принятого файла сверен с SHA-256 переданного JPEG, пересобранного из исходника отдельно от прогона. Оба равны 751d7ea0d8711e2ef54fc5cf209a809cfee5bbb14916dd20aa201d5285b40286.
- Размер JPEG 4756 байт, качество 30, десять фрагментов.
- Длительность сигнала около 2,07 секунды, канальная скорость 18,404 кбит/с.
- Кадр 8 прошёл контрольную сумму при пониженной корреляции поиска 0,866 против 0,998 у остальных: низкая корреляция маркера не означает испорченных данных, фаза дискретизации была подобрана и кадр демодулировался чисто.
## Устранённые дефекты отчётности
Разбор кабельного прогона вскрыл три дефекта, при которых провал мог быть принят за успех. Все три устранены, каждый закрыт быстрым тестом в tests/test_lab042_reporting.py.
- Величина вектора ошибки нормировалась на единичный эталон, из-за чего при слабом сигнале становилась искусственно малой: провалившийся прогон показывал EVM меньше, чем успешный. Нормировка исправлена на мощность подогнанного сигнала. При невозможности вычислить величину возвращается признак невычислимости, а не ноль.
- Отчёт перечислял lab042_received.jpg среди созданных файлов безусловно, даже когда изображение не собрано и файла нет. Теперь файл указывается только при фактической сборке.
- Программа завершалась кодом ноль при полном отказе передачи. Введён критерий приёмки: при несобранном изображении код завершения ненулевой, чтобы автоматика не приняла провал за успех.
## Проверки
- Быстрые тесты ядра расширены с 125 до 135. Добавленные проверяют неуспешный путь: провал даёт ненулевой код, отсутствующий файл не объявляется созданным, невычислимая метрика не превращается в ноль.
- Окончательный кабельный прогон рабочей точки выполнен уже исправленной версией программы, поэтому итоговый отчёт сформирован ею.
## Следующий этап
Lab043: передаёт PlutoSDR, принимает RTL-SDR. Общий опорный генератор исчезает, впервые появляется реальное рассогласование несущей и частоты дискретизации двух независимых устройств. Здесь понадобится защищённая частотная коррекция, перенесённая в Записи 019.

View File

@@ -36,7 +36,7 @@
## 4. Текущее состояние
**Выполнено:** лабораторные Lab001Lab041. Пройден путь от структуры пакета до сеансов связи с защитой от пакетов предыдущего сеанса.
**Выполнено:** лабораторные Lab001Lab042. Пройден путь от структуры пакета до первой передачи изображения через реальный радиотракт.
Что реализовано и проверено:
@@ -45,11 +45,12 @@
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
- приоритетное обслуживание команд, телеметрии и видео;
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
- идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса.
- идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса;
- передача JPEG через настоящий радиотракт на одном PlutoSDR: сначала по эфиру на паре антенн, затем по кабелю через аттенюатор 30 дБ. Принятое изображение совпало с переданным побайтово (Записи 019 и 020).
**Что ещё не сделано:** передатчик ни разу не включался. Весь тракт проверен на моделях и на приёме, но не в эфире.
**Что ещё не сделано:** два независимых устройства пока не разведены. Все передачи выполнены в петле одного PlutoSDR, где передатчик и приёмник делят опорный генератор, поэтому рассогласования несущей и частоты дискретизации между сторонами почти нет.
**Ближайшая работа:** Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR. Техническое задание: [docs/lab042_hardware_loopback_spec.md](docs/lab042_hardware_loopback_spec.md).
**Ближайшая работа:** Lab043 — передача с PlutoSDR на приём RTL-SDR. Общий опорный генератор исчезает, впервые появляется реальное рассогласование двух устройств. Техническое задание на завершённую Lab042: [docs/lab042_hardware_loopback_spec.md](docs/lab042_hardware_loopback_spec.md).
Хронология решений и результатов: [PROJECT_LOG.md](PROJECT_LOG.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,11 +1,11 @@
index,packet_bytes,frame_bits,correlation_score,sample_phase,carrier_offset_hz,carrier_coherence,cfo_correction_applied,error_vector_magnitude,frame_found,header_parsed,crc_valid,fragment_recovered
0,536,4384,0.9995083676316812,15,-0.8161804653898344,0.9999521460387033,False,0.013939054015288657,True,True,True,True
1,536,4384,0.9994247234007716,49,0.034058565997241885,0.9999449532988285,False,0.007533311365459869,True,True,True,True
2,536,4384,0.9995067920215722,121,-0.4024654150100275,0.9999698768639075,False,0.007802389734928202,True,True,True,True
3,536,4384,0.9995973624847003,79,0.05360429263743072,0.9999563681724133,False,0.008067411996806204,True,True,True,True
4,536,4384,0.9995573203179389,37,-0.7726047795897841,0.999951969069221,False,0.008830283803028178,True,True,True,True
5,536,4384,0.9994972026358666,35,0.14518673176235097,0.9999740428064059,False,0.007829934600060385,True,True,True,True
6,536,4384,0.9994993201100448,33,-0.6575975246787777,0.9999581020309138,False,0.008192298206454165,True,True,True,True
7,536,4384,0.9995337736352732,97,-0.32146831121434616,0.9999654759558527,False,0.00773812855638587,True,True,True,True
8,536,4384,0.9995377136427829,90,-0.32692415971800415,0.9999706666958124,False,0.008197030505111991,True,True,True,True
9,172,1472,0.9994478378475372,53,-0.6175162038574342,0.9999730687021144,False,0.011683449034017782,True,True,True,True
0,536,4384,0.9991557547061216,19,-0.13990840791652204,0.9997935741467082,False,0.3400949142192574,True,True,True,True
1,536,4384,0.9986269709852709,51,-0.6704420071784511,0.999254107740255,False,0.1962803864132724,True,True,True,True
2,536,4384,0.9988503105697645,75,0.16739056727394086,0.9997145042457541,False,0.16392107712020013,True,True,True,True
3,536,4384,0.9984613689137739,69,0.12541328438695132,0.9993958830571172,False,0.18678168516977972,True,True,True,True
4,536,4384,0.9986165925127464,37,0.522370456657967,0.9996364225258064,False,0.15291774582164716,True,True,True,True
5,536,4384,0.9988383991200348,32,-0.1940305233482036,0.9995177607884775,False,0.1768680391093208,True,True,True,True
6,536,4384,0.9993940824417119,51,0.35917365385569155,0.9997260503953309,False,0.1442403116319193,True,True,True,True
7,536,4384,0.9986343538268945,75,0.2939902862233339,0.9996675003381007,False,0.17829502789805413,True,True,True,True
8,536,4384,0.9988608071024292,69,-0.7492708746413825,0.9995548745704333,False,0.15854008568562075,True,True,True,True
9,172,1472,0.9984590339381546,60,0.5753674284248689,0.9992453603986811,False,0.232235822035248,True,True,True,True
1 index packet_bytes frame_bits correlation_score sample_phase carrier_offset_hz carrier_coherence cfo_correction_applied error_vector_magnitude frame_found header_parsed crc_valid fragment_recovered
2 0 536 4384 0.9995083676316812 0.9991557547061216 15 19 -0.8161804653898344 -0.13990840791652204 0.9999521460387033 0.9997935741467082 False 0.013939054015288657 0.3400949142192574 True True True True
3 1 536 4384 0.9994247234007716 0.9986269709852709 49 51 0.034058565997241885 -0.6704420071784511 0.9999449532988285 0.999254107740255 False 0.007533311365459869 0.1962803864132724 True True True True
4 2 536 4384 0.9995067920215722 0.9988503105697645 121 75 -0.4024654150100275 0.16739056727394086 0.9999698768639075 0.9997145042457541 False 0.007802389734928202 0.16392107712020013 True True True True
5 3 536 4384 0.9995973624847003 0.9984613689137739 79 69 0.05360429263743072 0.12541328438695132 0.9999563681724133 0.9993958830571172 False 0.008067411996806204 0.18678168516977972 True True True True
6 4 536 4384 0.9995573203179389 0.9986165925127464 37 -0.7726047795897841 0.522370456657967 0.999951969069221 0.9996364225258064 False 0.008830283803028178 0.15291774582164716 True True True True
7 5 536 4384 0.9994972026358666 0.9988383991200348 35 32 0.14518673176235097 -0.1940305233482036 0.9999740428064059 0.9995177607884775 False 0.007829934600060385 0.1768680391093208 True True True True
8 6 536 4384 0.9994993201100448 0.9993940824417119 33 51 -0.6575975246787777 0.35917365385569155 0.9999581020309138 0.9997260503953309 False 0.008192298206454165 0.1442403116319193 True True True True
9 7 536 4384 0.9995337736352732 0.9986343538268945 97 75 -0.32146831121434616 0.2939902862233339 0.9999654759558527 0.9996675003381007 False 0.00773812855638587 0.17829502789805413 True True True True
10 8 536 4384 0.9995377136427829 0.9988608071024292 90 69 -0.32692415971800415 -0.7492708746413825 0.9999706666958124 0.9995548745704333 False 0.008197030505111991 0.15854008568562075 True True True True
11 9 172 1472 0.9994478378475372 0.9984590339381546 53 60 -0.6175162038574342 0.5753674284248689 0.9999730687021144 0.9992453603986811 False 0.011683449034017782 0.232235822035248 True True True True

View File

@@ -1,13 +1,11 @@
Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR
Режим прогона
- Канал: air.
- Трасса: две антенны по воздуху, расстояние 60 см, аттенюатора нет.
- Несущая 225.000 МГц — внутри целевого диапазона 200-250 МГц, закреплённого Записью 018.
- Усиление передатчика -10.0 дБ, приёмника 50.0 дБ, режим ручной.
- Антенны разнесены на 60 см. Аттенюатора в цепи нет: приёмник защищён ослаблением на трассе.
- Использованы штыри, рассчитанные на 108 МГц. На рабочей частоте они работают как полуволновые, согласование плохое, но для этой дистанции достаточное.
- Уровни в эфирной трассе не контролируются так же строго, как в кабельной: возможны переотражения от предметов в помещении. Прогон служит проверкой тракта устройства, а не измерением.
- Канал: cable.
- Трасса: кабель через аттенюатор 30 дБ.
- Несущая 435.000 МГц, одинаковая на передачу и приём. Сигнал в эфир не выходит, поэтому её значение произвольно.
- Усиление передатчика -10.0 дБ, приёмника 20.0 дБ, режим ручной.
- Антенны не подключались: они создали бы путь распространения параллельно кабельному.
Параметры тракта
- Символьная скорость 20000 симв/с, отсчётов на символ 128.
@@ -33,28 +31,28 @@ Lab042 — передача JPEG через кабельную петлю на
- Размер принятого изображения 4756 байт.
Качество сигнала
- Средняя нормированная корреляция при поиске кадра 0.99951.
- Средняя оценка ухода несущей -0.368 Гц.
- Средняя величина вектора ошибки 0.898%.
- Средняя нормированная корреляция при поиске кадра 0.99879.
- Средняя оценка ухода несущей 0.029 Гц.
- Средняя величина вектора ошибки 19.302%.
Скорость
- Длительность сигнала в эфире 2.067 с.
- Канальная скорость 18.404 кбит/с — полезные байты, делённые на время эфира.
- Полное время работы программы 16.638 с, что даёт 2.287 кбит/с.
- Полное время работы программы 16.648 с, что даёт 2.285 кбит/с.
- В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала.
Кадры
номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент
0 | 0.99951 | 15 | -0.816 | 0.99995 | 1.394 | да | да | да | да
1 | 0.99942 | 49 | 0.034 | 0.99994 | 0.753 | да | да | да | да
2 | 0.99951 | 121 | -0.402 | 0.99997 | 0.780 | да | да | да | да
3 | 0.99960 | 79 | 0.054 | 0.99996 | 0.807 | да | да | да | да
4 | 0.99956 | 37 | -0.773 | 0.99995 | 0.883 | да | да | да | да
5 | 0.99950 | 35 | 0.145 | 0.99997 | 0.783 | да | да | да | да
6 | 0.99950 | 33 | -0.658 | 0.99996 | 0.819 | да | да | да | да
7 | 0.99953 | 97 | -0.321 | 0.99997 | 0.774 | да | да | да | да
8 | 0.99954 | 90 | -0.327 | 0.99997 | 0.820 | да | да | да | да
9 | 0.99945 | 53 | -0.618 | 0.99997 | 1.168 | да | да | да | да
0 | 0.99916 | 19 | -0.140 | 0.99979 | 34.009 | да | да | да | да
1 | 0.99863 | 51 | -0.670 | 0.99925 | 19.628 | да | да | да | да
2 | 0.99885 | 75 | 0.167 | 0.99971 | 16.392 | да | да | да | да
3 | 0.99846 | 69 | 0.125 | 0.99940 | 18.678 | да | да | да | да
4 | 0.99862 | 37 | 0.522 | 0.99964 | 15.292 | да | да | да | да
5 | 0.99884 | 32 | -0.194 | 0.99952 | 17.687 | да | да | да | да
6 | 0.99939 | 51 | 0.359 | 0.99973 | 14.424 | да | да | да | да
7 | 0.99863 | 75 | 0.294 | 0.99967 | 17.830 | да | да | да | да
8 | 0.99886 | 69 | -0.749 | 0.99955 | 15.854 | да | да | да | да
9 | 0.99846 | 60 | 0.575 | 0.99925 | 23.224 | да | да | да | да
Функциональные проверки
- ПРОЙДЕНО — 01. Размер подготовленного JPEG: JPEG занимает 4756 байт при пределе 5000, качество 30

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -1,2 +1,2 @@
loopback_mode,path_description,antenna_distance_cm,source_image,image_width,image_height,jpeg_quality,jpeg_bytes,fragment_data_size,fragment_count,packet_bytes_max,packet_bytes_min,frame_bits_max,frame_bits_min,symbol_rate,samples_per_symbol,sample_rate_hz,carrier_hz,transmitted_samples,air_time_seconds,frames_received,frames_failed_crc,fragments_recovered,fragment_delivery_fraction,image_reassembled,image_identical,received_image_bytes,transfer_seconds,channel_bitrate_kbps,wall_clock_bitrate_kbps,mean_correlation_score,mean_carrier_offset_hz,mean_error_vector_magnitude,tx_gain_db,rx_gain_db,attenuator_db
air,"две антенны по воздуху, расстояние 60 см, аттенюатора нет",60.0,data/raw/lab011_scene.jpg,320,180,30,4756,512,10,536,172,4384,1472,20000,128,2560000,225000000,5292544,2.0674,10,0,10,1.0,True,True,4756,16.637855200009653,18.40379220276676,2.286833221146072,0.9995110413728169,-0.3681907269061185,0.00898132918175413,-10.0,50.0,0.0
cable,кабель через аттенюатор 30 дБ,0.0,data/raw/lab011_scene.jpg,320,180,30,4756,512,10,536,172,4384,1472,20000,128,2560000,435000000,5292544,2.0674,10,0,10,1.0,True,True,4756,16.64845450001303,18.40379220276676,2.285377300335609,0.9987897674116901,0.02900538637381942,0.193017509510432,-10.0,20.0,30.0
1 loopback_mode path_description antenna_distance_cm source_image image_width image_height jpeg_quality jpeg_bytes fragment_data_size fragment_count packet_bytes_max packet_bytes_min frame_bits_max frame_bits_min symbol_rate samples_per_symbol sample_rate_hz carrier_hz transmitted_samples air_time_seconds frames_received frames_failed_crc fragments_recovered fragment_delivery_fraction image_reassembled image_identical received_image_bytes transfer_seconds channel_bitrate_kbps wall_clock_bitrate_kbps mean_correlation_score mean_carrier_offset_hz mean_error_vector_magnitude tx_gain_db rx_gain_db attenuator_db
2 air cable две антенны по воздуху, расстояние 60 см, аттенюатора нет кабель через аттенюатор 30 дБ 60.0 0.0 data/raw/lab011_scene.jpg 320 180 30 4756 512 10 536 172 4384 1472 20000 128 2560000 225000000 435000000 5292544 2.0674 10 0 10 1.0 True True 4756 16.637855200009653 16.64845450001303 18.40379220276676 2.286833221146072 2.285377300335609 0.9995110413728169 0.9987897674116901 -0.3681907269061185 0.02900538637381942 0.00898132918175413 0.193017509510432 -10.0 50.0 20.0 0.0 30.0

View File

@@ -527,22 +527,31 @@ def _failed_result(plan: FramePlan, correlation: float = 0.0, phase: int = 0) ->
def _error_vector_magnitude(received: np.ndarray, reference: np.ndarray) -> float:
"""Среднеквадратичное отклонение принятых символов от переданных.
Нормируется на энергию переданных символов, поэтому не зависит от
общего усиления тракта.
Нормируется на мощность подогнанного сигнала, то есть на мощность
масштабированного эталона scale*reference, а не на единичный эталон.
Это принципиально: эталон здесь — единичные BPSK-символы мощностью 1,
и нормировка на него делала метрику тем меньше, чем слабее сигнал,
вплоть до почти нуля при непрошедшей контрольной сумме. Нормировка на
мощность подогнанного сигнала сохраняет независимость от общего
усиления тракта и при этом растёт с ухудшением отношения сигнал/шум.
Возвращает NaN, если величину вычислить нельзя: ноль означал бы
идеальный приём там, где сигнала нет.
"""
# Ноль здесь возвращать нельзя: он означал бы идеальный приём там, где
# величину вычислить не удалось. Такое значение уже один раз скрыло
# отказ приёмника, показав 0,000% при непрошедшей контрольной сумме.
if not len(received) or len(received) != len(reference):
return float("nan")
scale = np.vdot(reference, received) / np.vdot(reference, reference)
difference = received - scale * reference
reference_power = float(np.mean(np.abs(reference) ** 2))
if reference_power <= 0.0:
reference_energy = np.vdot(reference, reference)
if reference_energy == 0:
return float("nan")
return float(np.sqrt(np.mean(np.abs(difference) ** 2) / reference_power))
scale = np.vdot(reference, received) / reference_energy
difference = received - scale * reference
signal_power = float(np.abs(scale) ** 2 * np.mean(np.abs(reference) ** 2))
if signal_power <= 0.0:
return float("nan")
return float(np.sqrt(np.mean(np.abs(difference) ** 2) / signal_power))
# ==================================================================== прогон
@@ -886,6 +895,39 @@ def save_plots(metrics: TransferMetrics, results: tuple[FrameResult, ...], segme
plt.close(figure)
def created_files(metrics: TransferMetrics) -> tuple[Path, ...]:
"""Вернуть файлы, действительно созданные прогоном.
Принятое изображение попадает в список только если оно собрано:
объявлять несуществующий файл созданным нельзя.
"""
paths = [SUMMARY_CSV, FRAMES_CSV, REPORT_PATH, *PLOT_PATHS]
if metrics.image_reassembled:
paths.insert(0, RECEIVED_IMAGE_PATH)
return tuple(paths)
def transfer_meets_criteria(
metrics: TransferMetrics,
tests: tuple[FunctionalTestResult, ...],
) -> bool:
"""Проверить, достигнут ли критерий приёмки Lab042.
Критерий: пройдены все функциональные проверки, изображение собрано
побайтово идентично переданному и восстановлены все фрагменты. По этому
признаку `main` выбирает код завершения, чтобы автоматика не приняла
провал за успех.
"""
return (
bool(tests)
and all(item.passed for item in tests)
and metrics.image_identical
and metrics.fragments_recovered == metrics.fragment_count
)
def write_report(
metrics: TransferMetrics,
results: tuple[FrameResult, ...],
@@ -990,10 +1032,7 @@ def write_report(
"Созданные файлы",
]
)
lines.extend(
f"- {path.as_posix()}"
for path in (RECEIVED_IMAGE_PATH, SUMMARY_CSV, FRAMES_CSV, REPORT_PATH, *PLOT_PATHS)
)
lines.extend(f"- {path.as_posix()}" for path in created_files(metrics))
REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -1025,7 +1064,7 @@ def validate_outputs(metrics: TransferMetrics, results: tuple[FrameResult, ...],
assert not any(path.suffix.lower() in forbidden for path in actual)
def main() -> None:
def main() -> int:
parser = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю")
parser.add_argument(
"--loopback",
@@ -1078,13 +1117,20 @@ def main() -> None:
write_report(metrics, results, tests)
validate_outputs(metrics, results, tests)
success = transfer_meets_criteria(metrics, tests)
passed_tests = sum(item.passed for item in tests)
print(
f"Lab042 завершена, режим {metrics.loopback_mode}: "
f"фрагментов {metrics.fragments_recovered}/{metrics.fragment_count}, "
f"изображение {'идентично' if metrics.image_identical else 'НЕ совпадает'}, "
f"{len(tests)}/{len(tests)} проверок, два CSV и четыре PNG"
f"{passed_tests}/{len(tests)} проверок, два CSV и четыре PNG"
)
if not success:
print("Критерий Lab042 НЕ достигнут: изображение не собрано побайтово. Код завершения ненулевой.")
return 0 if success else 1
if __name__ == "__main__":
main()
raise SystemExit(main())

View File

@@ -0,0 +1,125 @@
"""Проверки отчётности Lab042: неуспешный путь не должен маскироваться.
Три дефекта, найденные при разборе кабельного прогона, закрыты правками в
experiments/lab042_pluto_image_loopback.py. Эти тесты фиксируют их, чтобы
провал лаборатории нельзя было принять за успех:
1. EVM не занижается искусственно при слабом сигнале и не превращается в
ноль, когда её нельзя вычислить;
2. отсутствующее принятое изображение не объявляется созданным файлом;
3. невыполнение критерия лаборатории даёт ненулевой код завершения.
"""
from __future__ import annotations
import math
from types import SimpleNamespace
import numpy as np
import pytest
from experiments.lab042_pluto_image_loopback import (
RECEIVED_IMAGE_PATH,
FunctionalTestResult,
_error_vector_magnitude,
created_files,
transfer_meets_criteria,
)
def _reference(length: int = 512) -> np.ndarray:
bits = np.random.default_rng(length).integers(0, 2, size=length).astype(np.float64)
return (2.0 * bits - 1.0).astype(np.complex128)
# ------------------------------------------------------------------ EVM
def test_evm_is_near_zero_for_a_clean_signal() -> None:
reference = _reference()
assert _error_vector_magnitude(reference.copy(), reference) < 1e-9
def test_evm_is_invariant_to_overall_gain() -> None:
"""Одно и то же отношение сигнал/шум при разной амплитуде — одна EVM."""
rng = np.random.default_rng(1)
reference = _reference()
noise = 0.1 * (rng.standard_normal(len(reference)) + 1j * rng.standard_normal(len(reference)))
strong = _error_vector_magnitude(reference + noise, reference)
weak = _error_vector_magnitude(0.001 * (reference + noise), reference)
assert math.isclose(strong, weak, rel_tol=1e-6)
def test_evm_grows_when_the_signal_is_weak_and_noisy() -> None:
"""Регрессия: прежняя нормировка на единичный эталон занижала EVM у
слабого сигнала почти до нуля. Теперь слабый шумный сигнал обязан дать
заметную EVM, а не подделку под идеальный приём.
"""
rng = np.random.default_rng(2)
reference = _reference()
noise = 0.5 * (rng.standard_normal(len(reference)) + 1j * rng.standard_normal(len(reference)))
weak_noisy = 0.001 * (reference + noise)
assert _error_vector_magnitude(weak_noisy, reference) > 0.1
def test_evm_is_not_a_number_when_it_cannot_be_computed() -> None:
reference = _reference()
assert math.isnan(_error_vector_magnitude(reference[:10], reference))
assert math.isnan(_error_vector_magnitude(np.zeros(0, dtype=complex), np.zeros(0, dtype=complex)))
# ------------------------------------------------------------------ состав файлов
def _metrics(**overrides) -> SimpleNamespace:
base = dict(
image_reassembled=True,
image_identical=True,
fragments_recovered=10,
fragment_count=10,
)
base.update(overrides)
return SimpleNamespace(**base)
def test_received_image_listed_only_when_reassembled() -> None:
with_image = created_files(_metrics(image_reassembled=True))
without_image = created_files(_metrics(image_reassembled=False, image_identical=False))
assert RECEIVED_IMAGE_PATH in with_image
assert RECEIVED_IMAGE_PATH not in without_image
# ------------------------------------------------------------------ критерий и код возврата
def _all_passed() -> tuple[FunctionalTestResult, ...]:
return tuple(FunctionalTestResult(f"{index:02d}", True, "ок") for index in range(11))
def test_criteria_met_on_a_clean_transfer() -> None:
assert transfer_meets_criteria(_metrics(), _all_passed()) is True
def test_criteria_failed_when_image_differs() -> None:
assert transfer_meets_criteria(_metrics(image_identical=False), _all_passed()) is False
def test_criteria_failed_when_a_fragment_is_missing() -> None:
metrics = _metrics(image_identical=False, fragments_recovered=9)
assert transfer_meets_criteria(metrics, _all_passed()) is False
def test_criteria_failed_when_a_functional_check_failed() -> None:
tests = _all_passed()[:-1] + (FunctionalTestResult("bad", False, "провал"),)
assert transfer_meets_criteria(_metrics(), tests) is False
def test_criteria_failed_without_any_tests() -> None:
assert transfer_meets_criteria(_metrics(), ()) is False