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 — разведение на два устройства. Кабельная петля через аттенюатор 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. Текущее состояние ## 4. Текущее состояние
**Выполнено:** лабораторные Lab001Lab041. Пройден путь от структуры пакета до сеансов связи с защитой от пакетов предыдущего сеанса. **Выполнено:** лабораторные Lab001Lab042. Пройден путь от структуры пакета до первой передачи изображения через реальный радиотракт.
Что реализовано и проверено: Что реализовано и проверено:
@@ -45,11 +45,12 @@
- блочное исправление стираний над GF(256), перемежение, подбор параметров; - блочное исправление стираний над 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). Хронология решений и результатов: [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 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 0,536,4384,0.9991557547061216,19,-0.13990840791652204,0.9997935741467082,False,0.3400949142192574,True,True,True,True
1,536,4384,0.9994247234007716,49,0.034058565997241885,0.9999449532988285,False,0.007533311365459869,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.9995067920215722,121,-0.4024654150100275,0.9999698768639075,False,0.007802389734928202,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.9995973624847003,79,0.05360429263743072,0.9999563681724133,False,0.008067411996806204,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.9995573203179389,37,-0.7726047795897841,0.999951969069221,False,0.008830283803028178,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.9994972026358666,35,0.14518673176235097,0.9999740428064059,False,0.007829934600060385,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.9994993201100448,33,-0.6575975246787777,0.9999581020309138,False,0.008192298206454165,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.9995337736352732,97,-0.32146831121434616,0.9999654759558527,False,0.00773812855638587,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.9995377136427829,90,-0.32692415971800415,0.9999706666958124,False,0.008197030505111991,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.9994478378475372,53,-0.6175162038574342,0.9999730687021144,False,0.011683449034017782,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 Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR
Режим прогона Режим прогона
- Канал: air. - Канал: cable.
- Трасса: две антенны по воздуху, расстояние 60 см, аттенюатора нет. - Трасса: кабель через аттенюатор 30 дБ.
- Несущая 225.000 МГц — внутри целевого диапазона 200-250 МГц, закреплённого Записью 018. - Несущая 435.000 МГц, одинаковая на передачу и приём. Сигнал в эфир не выходит, поэтому её значение произвольно.
- Усиление передатчика -10.0 дБ, приёмника 50.0 дБ, режим ручной. - Усиление передатчика -10.0 дБ, приёмника 20.0 дБ, режим ручной.
- Антенны разнесены на 60 см. Аттенюатора в цепи нет: приёмник защищён ослаблением на трассе. - Антенны не подключались: они создали бы путь распространения параллельно кабельному.
- Использованы штыри, рассчитанные на 108 МГц. На рабочей частоте они работают как полуволновые, согласование плохое, но для этой дистанции достаточное.
- Уровни в эфирной трассе не контролируются так же строго, как в кабельной: возможны переотражения от предметов в помещении. Прогон служит проверкой тракта устройства, а не измерением.
Параметры тракта Параметры тракта
- Символьная скорость 20000 симв/с, отсчётов на символ 128. - Символьная скорость 20000 симв/с, отсчётов на символ 128.
@@ -33,28 +31,28 @@ Lab042 — передача JPEG через кабельную петлю на
- Размер принятого изображения 4756 байт. - Размер принятого изображения 4756 байт.
Качество сигнала Качество сигнала
- Средняя нормированная корреляция при поиске кадра 0.99951. - Средняя нормированная корреляция при поиске кадра 0.99879.
- Средняя оценка ухода несущей -0.368 Гц. - Средняя оценка ухода несущей 0.029 Гц.
- Средняя величина вектора ошибки 0.898%. - Средняя величина вектора ошибки 19.302%.
Скорость Скорость
- Длительность сигнала в эфире 2.067 с. - Длительность сигнала в эфире 2.067 с.
- Канальная скорость 18.404 кбит/с — полезные байты, делённые на время эфира. - Канальная скорость 18.404 кбит/с — полезные байты, делённые на время эфира.
- Полное время работы программы 16.638 с, что даёт 2.287 кбит/с. - Полное время работы программы 16.648 с, что даёт 2.285 кбит/с.
- В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала. - В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала.
Кадры Кадры
номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент
0 | 0.99951 | 15 | -0.816 | 0.99995 | 1.394 | да | да | да | да 0 | 0.99916 | 19 | -0.140 | 0.99979 | 34.009 | да | да | да | да
1 | 0.99942 | 49 | 0.034 | 0.99994 | 0.753 | да | да | да | да 1 | 0.99863 | 51 | -0.670 | 0.99925 | 19.628 | да | да | да | да
2 | 0.99951 | 121 | -0.402 | 0.99997 | 0.780 | да | да | да | да 2 | 0.99885 | 75 | 0.167 | 0.99971 | 16.392 | да | да | да | да
3 | 0.99960 | 79 | 0.054 | 0.99996 | 0.807 | да | да | да | да 3 | 0.99846 | 69 | 0.125 | 0.99940 | 18.678 | да | да | да | да
4 | 0.99956 | 37 | -0.773 | 0.99995 | 0.883 | да | да | да | да 4 | 0.99862 | 37 | 0.522 | 0.99964 | 15.292 | да | да | да | да
5 | 0.99950 | 35 | 0.145 | 0.99997 | 0.783 | да | да | да | да 5 | 0.99884 | 32 | -0.194 | 0.99952 | 17.687 | да | да | да | да
6 | 0.99950 | 33 | -0.658 | 0.99996 | 0.819 | да | да | да | да 6 | 0.99939 | 51 | 0.359 | 0.99973 | 14.424 | да | да | да | да
7 | 0.99953 | 97 | -0.321 | 0.99997 | 0.774 | да | да | да | да 7 | 0.99863 | 75 | 0.294 | 0.99967 | 17.830 | да | да | да | да
8 | 0.99954 | 90 | -0.327 | 0.99997 | 0.820 | да | да | да | да 8 | 0.99886 | 69 | -0.749 | 0.99955 | 15.854 | да | да | да | да
9 | 0.99945 | 53 | -0.618 | 0.99997 | 1.168 | да | да | да | да 9 | 0.99846 | 60 | 0.575 | 0.99925 | 23.224 | да | да | да | да
Функциональные проверки Функциональные проверки
- ПРОЙДЕНО — 01. Размер подготовленного JPEG: JPEG занимает 4756 байт при пределе 5000, качество 30 - ПРОЙДЕНО — 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 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: 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): if not len(received) or len(received) != len(reference):
return float("nan") return float("nan")
scale = np.vdot(reference, received) / np.vdot(reference, reference) reference_energy = np.vdot(reference, reference)
difference = received - scale * reference if reference_energy == 0:
reference_power = float(np.mean(np.abs(reference) ** 2))
if reference_power <= 0.0:
return float("nan") 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) 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( def write_report(
metrics: TransferMetrics, metrics: TransferMetrics,
results: tuple[FrameResult, ...], results: tuple[FrameResult, ...],
@@ -990,10 +1032,7 @@ def write_report(
"Созданные файлы", "Созданные файлы",
] ]
) )
lines.extend( lines.extend(f"- {path.as_posix()}" for path in created_files(metrics))
f"- {path.as_posix()}"
for path in (RECEIVED_IMAGE_PATH, SUMMARY_CSV, FRAMES_CSV, REPORT_PATH, *PLOT_PATHS)
)
REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") 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) 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 = argparse.ArgumentParser(description="Lab042: передача JPEG через кабельную петлю")
parser.add_argument( parser.add_argument(
"--loopback", "--loopback",
@@ -1078,13 +1117,20 @@ def main() -> None:
write_report(metrics, results, tests) write_report(metrics, results, tests)
validate_outputs(metrics, results, tests) validate_outputs(metrics, results, tests)
success = transfer_meets_criteria(metrics, tests)
passed_tests = sum(item.passed for item in tests)
print( print(
f"Lab042 завершена, режим {metrics.loopback_mode}: " f"Lab042 завершена, режим {metrics.loopback_mode}: "
f"фрагментов {metrics.fragments_recovered}/{metrics.fragment_count}, " f"фрагментов {metrics.fragments_recovered}/{metrics.fragment_count}, "
f"изображение {'идентично' if metrics.image_identical else 'НЕ совпадает'}, " 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__": 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