Lab055: audit received signal energy across symbol rates

This commit is contained in:
LittleSam129
2026-09-09 15:07:11 +03:00
parent 0368c6d3e9
commit 5add95ecb8
7 changed files with 10441 additions and 0 deletions

View File

@@ -2163,3 +2163,58 @@ Lab053 git add/commit и push не выполнялись; индекс пуст
Следующий этап: STOP, рассмотрение программных результатов ChatGPT. Следующий этап: STOP, рассмотрение программных результатов ChatGPT.
Не переходить автоматически к 10 fps, QPSK, 180 МГц, новому FEC, Не переходить автоматически к 10 fps, QPSK, 180 МГц, новому FEC,
аппаратным 500 ксимв/с или любому RF-эксперименту. аппаратным 500 ксимв/с или любому RF-эксперименту.
# Запись 046
## Дата
9 сентября 2026 года
## Тема
Lab055 — энергетический аудит сохранённых аппаратных Lab047 и Lab054
## Выполнено
По заданию пользователя сравнили цифровую мощность TX, согласованную компоненту
RX, отдельный фон и остаток модели. Новых аппаратных команд не было, RF TX=0.
SHA и размеры исходных аппаратных записей проверены до и после расчёта;
Lab047 анализируется по записи единственного опыта, восстановленной позднее
независимым программным приёмом, а не по программному loopback JPEG.
Исторический Lab054 NOT_A, 0/16 пакетов, сохранён без изменений.
Созданы experiments/lab055_energy_audit.py, tests/test_lab055_energy_audit.py,
docs/lab055_current_state.md и отдельные малые результаты data/processed/lab055.
Итоговый файл — energy_audit_v2.json. Предварительный energy_audit.json сохранён
как заменённый: исправлены недовзвешивание guard при Welch TX и попадание
проверочных отсчётов в поиск задержки. Verifier воспроизвёл утечку, после
исправления проверил её закрытие и независимо пересчитал мощности/PSD/МНК.
Средняя цифровая мощность активного TX 1950,308/1950,598 код², разница
+0,000645 дБ: обе формы нормированы к RMS44. Цифровая энергия символа
уменьшилась на13,9788 дБ, но одинаковый TX VGA10 дБ не доказывает одинаковую
выходную RF-мощность. Медианная согласованная RX-компонента модели упала
с0,202732 до0,00582916 код², на15,413 дБ. Медианный фон в одинаковой полосе
675кГц сопоставим:0,117815/0,112066 код². Ватты и потери подключения неизвестны.
Чередующиеся плитки внутри каждого кадра разделяют обучение и проверку модели.
Остаток содержит шум и искажения; после RRC он превышает отдельный фон
примерно в2,06/1,11 раза. Условное разложение Es/N0 с эквивалентной плотностью
фона даёт−26,224 дБ, отношение к эквивалентному остатку — изменение−23,554 дБ;
это разные показатели. Условия постоянной принятой мощности и N0 не
подтверждены, утверждение «14дБ уже объяснены скоростью» не принимается.
Описан только будущий кабельный/экранированный контроль с измерением мощности,
нагрузкой50Ом и известным ослаблением. Усилитель не назначен. Проверены
официальные HackRF и ITU RR2024: п.5.340 запрещает излучения1400–1427МГц;
будущая схема и допустимая частота требуют отдельного согласования.
Архитектура сохраняется: RadioMaster/ExpressLRS/CRSF — управление, SDR — видео,
цель минимум5 свежих изображений/с, перспектива видеотракта около180МГц.
Проверки:9 целевых тестов PASS; quick_gate823 теста/28 функциональных/13
штатных пропусков PASS; pytest823 passed,89,15с; diff check PASS.
Предупреждение pytest касается только кеша. Verifier: остаточных находок нет.
HEAD прежний0368c6d3e930f4d245e056223fd4e5c4f70e6b49, main; предыдущие
изменения Lab054 сохранены. Git add/commit/push/pull/fetch не выполнялись.
Следующий этап: STOP и согласование измерительной схемы, приборов и допустимой
частоты; никакого автоматического аппаратного продолжения.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -389,6 +389,23 @@
- **Далее:** рассмотрение результатов ChatGPT. Lab053 git add/commit и push - **Далее:** рассмотрение результатов ChatGPT. Lab053 git add/commit и push
не выполнялись. STOP. не выполнялись. STOP.
## Lab055 — энергетический аудит сохранённых Lab047 и Lab054
- **Режим:** только файлы, RF TX=0; HackRF не запускался, Lab054 NOT_A сохранён.
- **Результат:** цифровая мощность активного TX практически одинакова
(1950,308/1950,598 код²), согласованная компонента RX в модели уменьшилась
на15,413дБ. Фон в одинаковой полосе675кГц сопоставим. Выходная RF-мощность
без калибровки неизвестна; одинаковый TX VGA не является её измерением.
- **Границы:** условная потеря13,98дБ из-за скорости требует постоянных
принятой мощности и N0; эти условия не подтверждены. Остаток модели
содержит шум и искажения. Описан только план кабельного измерения.
- **Проверки:**9 целевых тестов; pytest823 passed; quick_gate823/28/13 PASS;
verifier без остаточных находок. Исходные IQ и результаты неизменны.
- **Отчёт:** [состояние](lab055_current_state.md),
[итоговый расчёт](../data/processed/lab055/energy_audit_v2.json), журнал046.
- **Далее:** STOP. Отдельное согласование измерительной схемы и допустимой
частоты; усилитель не назначен, Git add/commit/push/pull/fetch не выполнялись.
## Правило хранения данных ## Правило хранения данных
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные

View File

@@ -0,0 +1,372 @@
# Lab055 — энергетический аудит сохранённых Lab047 и Lab054
Дата: 9 сентября 2026 года. Только обработка файлов, RF TX = 0.
## Что установлено простыми словами
1. **Цифровая амплитуда передачи практически не изменилась.** Средняя мощность
активной части TX равна 1950,308 и 1950,598 код²: различие всего +0,000645 дБ.
RMS обоих полных файлов около 44 кодов, ограничения отсчётов нет.
2. **Согласованная с TX компонента принятого сигнала существенно ослабла.**
Её медианная мощность в модели канала уменьшилась с 0,202732 до
0,00582916 код², то есть на 15,413 дБ, примерно в 34,8 раза. Это оценка
компоненты в цифровом RX, описываемой моделью, а не измерение ватт на разъёме.
3. **Фон в одинаковой широкой полосе сопоставим.** В полосе 675 кГц медианы
0,117815 и 0,112066 код². Признака роста фона на десятки децибел нет.
Фон меняется между окнами и до/после передачи. Остаток после вычитания
модели содержит также искажения: особенно в Lab047 он выше отдельного фона.
4. **Подтверждено падение цифровой энергии TX на символ на 13,9788 дБ**, поскольку
измеренная средняя цифровая мощность сохранилась, а скорость выросла в 25 раз.
Это не доказывает, что 13,98 дБ ухудшения приёма вызваны только скоростью:
условие неизменной полезной принятой мощности не выполнено даже в модели.
5. **Без измерительного прибора не установлены** выходная RF-мощность обеих
передач, мощность на входе RX, потери конкретного подключения, фактическая
аналоговая характеристика и разложение ослабления между этими причинами.
Одинаковое TX VGA = 10 дБ не доказывает одинаковую выходную мощность.
6. **Внешний усилитель пока преждевременен.** Для ближайшей диагностики нужен
контролируемый кабельный/экранированный стенд, известное ослабление и измерение
уровней. По одному SNR назначать мощность усилителя в ваттах нельзя.
Исторический Lab054 остаётся **NOT_A, 0/16 пакетов**. TX reference используется
после независимого решения только для диагностики. Нового декодирования,
изменения порогов, частот, усилений, PHY/FEC/CRC или радиопередачи не было.
## Артефакты и происхождение данных
Основной результат: [energy_audit_v2.json](../data/processed/lab055/energy_audit_v2.json).
Анализатор: [lab055_energy_audit.py](../experiments/lab055_energy_audit.py).
Тесты: [test_lab055_energy_audit.py](../tests/test_lab055_energy_audit.py).
Первый `energy_audit.json` сохранён как предварительный и заменён в выводах V2:
Welch недовзвешивал защитные края TX, а окно поиска задержки задевало проверочную
плитку. В V2 для TX применена полная прямоугольная периодограмма каждого кадра;
поиск задержки защищён внутренними отступами. Сценарий утечки воспроизведён
verifier, превращён в тест и после исправления повторно пройден. Первичные
аппаратные записи и результаты обоих опытов не перезаписывались.
Lab047: `C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747`.
Это аппаратная RX-запись единственного сеанса 3 сентября, а не программный
loopback JPEG. Первичная обработка была незавершена из-за лимита кандидатов;
позднейший независимый анализ именно этой записи принял 36 кадров и восстановил
точный JPEG. Основания: `docs/lab047_current_state.md`, `capture_result.json`,
`independent_reprocess_result_corrected.json` и `post_verdict_diagnostics.json`.
Lab054: `data/raw/lab054`, единственный сеанс 9 сентября. Официальная сводка:
`data/processed/lab054/rf_summary.json`; независимо принято 0/16.
| Параметр | Lab047 | Lab054 | Основание |
|---|---:|---:|---|
| Формат | signed int8 I,Q | signed int8 I,Q | Метаданные, чётный размер, декодирование байтов |
| TX, байт | 166060800 | 2952192 | Фактические файлы |
| RX, байт | 240000000 | 48000000 | Фактические файлы |
| TX, с | 10,3788 | 0,184512 | Число IQ/Fs |
| RX, с | 15 | 3 | Число IQ/Fs |
| Частота дискретизации | 8 Мвыб/с | 8 Мвыб/с | TX/RX stderr и метаданные |
| Центральная RF-частота | 1400 МГц | 1400 МГц | TX/RX stderr |
| Скорость | 20000 симв/с | 500000 симв/с | Метаданные и формирователи |
| Отсчётов/символ | 400 | 16 | Fs/Rs, код и длины кадров |
| Цифровая несущая | +50 кГц | +50 кГц | Метаданные и формирователи |
| TX VGA | 10 дБ | 10 дБ | Метаданные и команды runner; аппаратного readback нет |
| RX LNA/VGA | 24/24 дБ | 24/24 дБ | Метаданные и команды runner; аппаратного readback нет |
| RF amplifier/Bias-Tee | OFF/OFF | OFF/OFF | Метаданные, команды, stderr |
| RRC | α=0,35, span=10 | α=0,35, span=10 | Восстановлено из кода |
| Аналоговый baseband-фильтр | UNKNOWN | UNKNOWN | Явного `-b` и readback полосы в артефактах нет |
| Выходная мощность, дБм | UNKNOWN | UNKNOWN | Измеренной калибровки нет |
| Кабели/антенны/ослабление/расстояние | Не подтверждены как неизменные | Не подтверждены как неизменные | Разные сеансы |
В обоих журналах одна пара HackRF r9: TX serial заканчивается `323b97d3`,
RX — `2e5327d3`; firmware/libhackrf 2024.02.1. Идентичность платы и настроек
не устанавливает неизменность аналогового тракта. Аппаратуру сейчас не опрашивали.
SHA-256 проверены чтением блоками и совпали с историческими метаданными:
```text
Lab047 TX 001a0c11128d18b2b8e3e67a3e8416a84174ed0e383887836186a10aa45beded
Lab047 RX 8521bf1b574cd1fe4e0a4f980c00c8664f8bb51551a241e46449e1eb2c77e635
Lab054 TX e84bbce227426c0aac13a48ded1286ca4a44fc6e3efce01f6e5a513217c87ff5
Lab054 RX 641886879964250c315dd399d469503808aae43fb319bef05e0bf2a78bf14585
```
JSON содержит SHA всех входных файлов в обоих аппаратных каталогах и
`data/processed/lab054`. После расчёта их хеши проверены повторно: неизменны.
## Метод, окна и единицы
Для комплексного цифрового отсчёта x=I+jQ:
```text
μ = mean(x)
P = mean(|x|²) [код²]
P_AC = mean(|x−μ|²) [код²]
RMS = sqrt(P) [код]
PAPR = 10 log10(max(|x|²)/P) [дБ]
Es_digital = P/Rs [код²·с]
```
Код TX и код RX не переводятся в ватты и не считаются одной калиброванной
физической шкалой. Пиковое значение цифрового кода не является RF-мощностью.
Весь TX анализируется покадрово: 2306400 отсчётов/кадр у Lab047 и 92256 у
Lab054. «Активная часть» определена одинаково: центральное окно после удаления
21·SPS отсчётов с каждого края, то есть 5724 символьных интервала. Guard=16
символов плюс половина RRC span=5. Полный файл включает все защитные интервалы
и хвосты. Это фиксированное структурное окно, не амплитудный порог.
Статистика моментов суммируется по всем отсчётам. TX-полоса интегрируется по
прямоугольной периодограмме каждого полного или активного окна; интеграл
по всей частотной оси удовлетворяет Парсевалю. Точка спектрального максимума
вместо интегральной мощности нигде не используется.
Фон: отдельные окна по 0,1 с, из каждого удаляется собственное комплексное
среднее. Начала окон в секундах:
```text
Lab047: 0.05,0.20,0.35,0.50; 12.05,12.4,12.75,13.1,13.45,13.8
Lab054: 0.02,0.12,0.22,0.32; 1.0,1.25,1.5,1.75,2.0,2.5,2.8
```
Все эти окна находятся вне диагностически найденной передачи. PSD фона:
двусторонний Welch комплексного сигнала, periodic Hann, 65536 отсчётов,
перекрытие 32768, без дополнительного detrend; шаг 122,0703125 Гц. Каждое
фоновое окно содержит 23 сегмента Welch. Мощность полосы — сумма PSD·Δf;
средняя плотность в полосе — эта мощность, делённая на её ширину. Все сравниваемые
абсолютные полосы центрированы на +50 кГц: 27, 675 и 1000 кГц.
Модель RX: `y[n] = d + g*x[n−τ]*exp(j2πf*n/Fs) + r[n]`.
Это плоский комплексный коэффициент, постоянная составляющая, целочисленная
задержка и постоянный CFO внутри кадра. Частотно-зависимый канал, дробная
задержка, изменение усиления внутри кадра, нелинейность и все помехи остаются
в r. Поэтому мощность модели — оценка согласованной компоненты, не гарантированная
полная мощность всех полезных RF-компонент.
Активный кадр делится на 8 плиток. Чётные 0,2,4,6 обучают CFO/g/DC, нечётные
1,3,5,7 проверяют модель. Длительность плитки: 35,775 мс и 1,431 мс.
Задержка обучается только внутри плитки 0: reference `[2m:L−2m]`, поиск RX
`[m:L−m]`, m=SPS. При любом выбранном сдвиге ±m поиск остаётся внутри итоговой
обучающей плитки. Исходные начала Lab047 берутся из принятого независимого
результата; Lab054 — из завершённой диагностики 16 известных следов. Это
намеренное использование TX после verdict, не независимый поиск кадров.
Проверочная мощность модели — `mean(|g*x_corrected|²)`, остаток —
`mean(|y−d−g*x_corrected|²)`. Сохраняется перекрёстный член, поскольку вне
обучающей выборки нельзя автоматически записывать P_RX=P_model+P_residual.
Медианный перекрёстный член: 0,0000904 и 0,0002491 код². Подгонка по конечному
шумному обучающему набору может завышать |g|²; проверка на других плитках
уменьшает оптимизм остатка, но не устраняет всю неопределённость модели.
PSD остатка считается для каждой проверочной плитки отдельно, затем усредняется;
разорванные окна не склеиваются. Welch использует min(65536, длина плитки),
поэтому у Lab054 спектральная сетка остатка грубее, чем у фоновых окон.
Мощности интегрируются, сравнение отдельных пиков не проводится.
Чтение — read-only memmap; в обработке один кадр/одно фоновое окно, а не несколько
копий полной 240-МБ записи. Наибольший исходный кадр содержит 2,3064 млн IQ.
## Цифровая передача
| Метрика | Lab047 | Lab054 |
|---|---:|---:|
| Mean I, весь TX, код | −0,00005353 | −0,0852262 |
| Mean Q, весь TX, код | −0,00003583 | +0,2849923 |
| Mean I/Q, active, код | −0,00005730 / −0,00054603 | −0,0883308 / +0,2855104 |
| P весь TX, код² | 1936,234201 | 1936,512534 |
| P active, код² | 1950,308025 | 1950,597617 |
| RMS весь TX, код | 44,002661 | 44,005824 |
| RMS active, код | 44,162292 | 44,165570 |
| Максимальный модуль IQ, код | 70,17834 | 70,00000 |
| Максимум abs(I)/abs(Q), код | 69 / 70 | 67 / 70 |
| Ограниченные компоненты/отсчёты | 0 / 0 | 0 / 0 |
| PAPR весь TX, дБ | 4,05448 | 4,03176 |
| PAPR active, дБ | 4,02303 | 4,00028 |
| P в собственной полосе, весь TX, код², среднее кадров | 1935,91206 | 1936,20139 |
| P в собственной полосе, active, код², среднее кадров | 1949,94924 | 1950,24632 |
| Es весь TX, код²·с | 0,096811710 | 0,0038730251 |
| Es active, код²·с | 0,097515401 | 0,0039011952 |
Собственная расчётная полоса `(1+α)Rs`: 27 и 675 кГц. Границы относительно
центра HackRF: +36,5…+63,5 и −287,5…+387,5 кГц. Небольшая ненулевая средняя
Lab054 относится к конечной широкополосной цифровой форме, пересекающей ноль.
Формирователь сначала сворачивает импульсную последовательность с RRC единичной
энергии и умножает на 0,70. На этой промежуточной ступени энергия импульса в
сумме квадратов почти постоянна, а средняя мощность масштабируется как 1/SPS.
Затем и старый `_scale_and_carrier`, и параметрический
`build_parameterized_tx_complex` **нормируют всю форму к RMS 44** и квантуют CS8.
Следовательно, итоговый инвариант — средняя цифровая мощность всего кадра;
пиковая амплитуда зависит от последовательности, энергия на символ не постоянна.
Источник: `lab044_hackrf_bpsk_packet.py:189`, `lab049_bpsk_symbol_rate.py:153`.
## RX: сигнал, фон и остаток
Тройки означают min / median / max по 36 или 16 кадрам либо по 10/11 фоновым
окнам. Полные покадровые и пооконные значения находятся в JSON.
| Метрика, единицы | Lab047 | Lab054 |
|---|---:|---:|
| DC фона I, код | 0,93661 / 0,94647 / 0,95317 | 0,94343 / 0,95945 / 0,96513 |
| DC фона Q, код | 1,82984 / 1,83810 / 1,85304 | 1,81475 / 1,82897 / 1,84521 |
| DC активного RX I/Q, среднее кадров, код | 0,95030 / 1,82697 | 0,95036 / 1,83411 |
| Фон после DC, вся цифровая полоса, код² | 0,80744 / 0,90063 / 2,40830 | 0,76492 / 0,77637 / 1,05093 |
| Фон в 27 кГц, код² | 0,004791 / 0,009997 / 0,011033 | 0,006595 / 0,007428 / 0,009188 |
| Фон в 675 кГц, код² | 0,076705 / 0,117815 / 0,141878 | 0,101860 / 0,112066 / 0,136654 |
| Фон в 1 МГц, код² | 0,101003 / 0,142208 / 0,178637 | 0,133711 / 0,144025 / 0,175794 |
| Плотность фона в 27 кГц, код²/Гц | 1,775e−7 / 3,703e−7 / 4,086e−7 | 2,442e−7 / 2,751e−7 / 3,403e−7 |
| Плотность фона в 675 кГц, код²/Гц | 1,136e−7 / 1,745e−7 / 2,102e−7 | 1,509e−7 / 1,660e−7 / 2,025e−7 |
| Согласованная компонента RX, код² | 0,18848 / 0,20273 / 0,22969 | 0,004902 / 0,005829 / 0,007671 |
| Модуль коэффициента g, RX-код/TX-код | 0,009833 / 0,010195 / 0,010854 | 0,001585 / 0,001729 / 0,001984 |
| Остаток на обучении, вся полоса, код² | 0,82150 / 0,97775 / 1,50614 | 0,72737 / 0,75433 / 0,95130 |
| Остаток на проверке, вся полоса, код² | 0,83542 / 0,96131 / 1,85690 | 0,73922 / 0,82244 / 1,03050 |
| Остаток проверки в 27 кГц, код² | 0,017821 / 0,019407 / 0,024829 | 0,006205 / 0,008370 / 0,009905 |
| Остаток проверки в 675 кГц, код² | 0,122124 / 0,132075 / 0,171027 | 0,111024 / 0,121966 / 0,149865 |
В собственной полосе фон вырос с 0,009997 до 0,112066 код² главным образом
из-за изменения ширины и положения полосы. Эта строка не означает рост
спектральной плотности шума приёмника. В одинаковых абсолютных полосах
увеличения такой величины нет.
Фон нестационарен. Медианный эквивалентный N0 Lab047 до передачи равен
2,1901e−7, после — 4,0330e−7 код²/Гц. У Lab054 — 1,9005e−7 и 1,8744e−7.
Единственный максимум 2,4083 код² широкополосного фона Lab047 не используется
как типичный уровень. Нельзя переносить фон до/после TX на время передачи
без дополнительного предположения о стационарности.
## Согласованный фильтр и условная энергетическая модель
Конвенция комплексного белого шума: `E|w|²=σ²`, дисперсия каждой из I и Q
равна σ²/2, двусторонняя комплексная PSD `N0=σ²/Fs`. Дополнительного множителя
2 в интеграле нет. Реальный фон окрашен и включает помехи/квантование;
его плотность не объявляется измеренным тепловым N0.
```text
Pn_out = ∫ Sbackground(f) |H(f−fc)|² df
ENBW = Fs·Σ|h[n]|² / |Σh[n]|²
N0_equivalent = Pn_out / (Fs·Σ|h[n]|²)
Es/N0_equivalent = Pmodel / (Rs·N0_equivalent) [условная оценка]
```
N0_equivalent — плотность белого комплексного шума, который дал бы ту же
выходную мощность данного RRC. Она зависит от выбранного фильтра и положения
в окрашенном спектре. Поэтому различие N0_equivalent между режимами включает
изменение спектрального взвешивания, а не только физическое изменение шума.
| Параметр | Lab047 | Lab054 |
|---|---:|---:|
| RRC taps | 4001 | 161 |
| Σh² | ≈1 | ≈1 |
| Задержка одного RRC, отсчёты | 2000 | 80 |
| Усиление мощности на центре полосы | 396,55892 | 15,87720 |
| ENBW, Гц | 20173,547 | 503867,304 |
| Фон после RRC, код², min/median/max | 1,48735 / 3,11017 / 3,37378 | 1,34855 / 1,49956 / 1,82704 |
| N0_equivalent, код²/Гц, min/median/max | 1,859e−7 / 3,888e−7 / 4,217e−7 | 1,686e−7 / 1,874e−7 / 2,284e−7 |
| Остаток проверки после RRC, код², min/median/max | 5,87405 / 6,41205 / 8,13390 | 1,48590 / 1,67147 / 2,01445 |
| Условное Es/N0_equivalent по медианам, дБ | +14,1620 | −12,0624 |
Формальное разложение для выбранных сопоставимых определений:
```text
ΔPmodel = −15,41316 дБ
10log10(Rs054/Rs047) = +13,97940 дБ
ΔN0_equivalent = −3,16822 дБ
Δ(Es/N0_equivalent) = −15,41316 − 13,97940 − (−3,16822)
= −26,22434 дБ
```
Это арифметика модели по медианам, **не установленное распределение физических
причин потерь**. Полезная модельная мощность не постоянна; фон нестационарен;
изменилось спектральное взвешивание. Полная полезная RF-мощность вне модели
не измерена. Условие «полезная принятая мощность и N0 неизменны» не подтверждено.
Фраза «14 дБ уже объяснены скоростью, осталось 9 дБ» для этих данных некорректна.
Если в том же выражении вместо фонового Pn_out поставить фильтрованный остаток
проверки, получаются +11,0199 и −12,5337 дБ, изменение −23,5536 дБ.
Это **отношение к эквивалентному остатку**, не Es/N0. Остаток/фон после RRC
равен примерно 2,06 у Lab047 и 1,11 у Lab054. Так видна причина различия между
условной моделью и прежними измерениями SNR преамбулы (+10,85/−12,32 дБ).
Метрики измеряют разные величины на разных окнах; прежние результаты не исправлялись.
## Уход от нулевой частоты — только расчёт
Полоса Lab054 пересекает ноль; смещение +500 кГц поместило бы её в
+162,5…+837,5 кГц. Взвешивание сохранённого фона перенесённым RRC даёт медианные
выигрыши 3,06/3,21/3,21 дБ для +500/+750/+1000 кГц относительно +50 кГц.
Предполагается одинаковая передача полезной компоненты через аналоговый тракт,
что не измерено. Цифровая перестройка старой записи не является испытанием
другого аналогового режима. Реальная несущая не менялась.
## Только план следующего измерения
Никакие действия из этого раздела не выполнены и не разрешаются этим отчётом.
Наличие измерителя мощности, анализатора спектра, калиброванных ослабителей,
нагрузки 50 Ом и экранированного корпуса пока не установлено.
1. Зафиксировать отдельно допустимую частоту, схему и имеющиеся приборы.
Использовать соединение TX → измеряемый отвод/измеритель → известный
ослабитель → RX, с согласованными 50-омными портами; измерение TX сначала
выполнить на эквиваленте нагрузки. Исключить антенны и неконтролируемое излучение.
2. Измерить реальную выходную мощность обеих цифровых форм на одной частоте,
при одинаковом TX VGA и неизменных кабелях/нагрузке. Зафиксировать среднюю
мощность, полосу, погрешность прибора и соответствующие цифровые RMS/PAPR.
3. Проверить ослабление и потери кабелей в рабочей полосе. Вычислять входной
уровень RX по измеренной мощности TX и измеренным потерям; не соединять
порты напрямую. Предусмотреть запас ниже аппаратного предела и отдельно
проверить отсутствие насыщения АЦП при выбранных RX-усилениях.
4. Измерить фон RX с согласованной нагрузкой и при выключенном TX на той же
схеме. Получить калиброванную плотность входного шума и проверить аналоговую
полосу. Требуемый уровень для выбранного критерия приёма затем рассчитывать
как `P_required = N0_input + 10log10(Rs) + (Es/N0)_required` в согласованных
единицах. Сам порог требуется определить программными тестами/контрольным
измерением, а не назначить по неудачному кадру.
5. После отдельного согласования проверить обе скорости с одинаковой
**измеренной** входной мощностью RX и неизменной схемой. Только такой контроль
проверит условную потерю энергии на символ при постоянной мощности.
Документация HackRF указывает максимальную входную мощность −5 дБм; это предел
защиты, не целевой рабочий уровень и не чувствительность. Чувствительность
конкретного режима требует измерения. [Официальная документация HackRF](https://hackrf.readthedocs.io/en/latest/hackrf_one.html).
На 1400–1427 МГц запрещены все излучения по п. 5.340 Регламента радиосвязи ITU.
Разрешения на новый эфир в этой полосе нет; план требует допустимой частоты
и отдельно согласованной измерительной схемы. Проверен официальный документ,
п. 5.340, PDF стр. 115:
[ITU Radio Regulations, Volume 1, 2024](https://www.itu.int/en/ITU-R/seminars/rrs/rrs-25-africa/Plenaries/0.%20ITU-R%20Regulatory%20Documents/1.%20Radio%20Regulations%20%202024%20and%20Rules%20of%20Procedure/Radio%20Regulations%20Ed%202024/RR_Ed-2024_Vol%201-E.pdf).
Архитектура сохранена: RadioMaster/ExpressLRS/CRSF — отдельное управление;
SDR — видео с целью не менее 5 свежих изображений/с; будущий видеодиапазон
около 180 МГц. Этот аудит не подтверждает работоспособность будущего диапазона
и не назначает мощность внешнего усилителя.
## Проверки и воспроизведение
```powershell
python -m pytest tests/test_lab055_energy_audit.py -q
python experiments/lab055_energy_audit.py --output data/processed/lab055/recheck_01.json
python tools/quick_gate.py
python -m pytest tests/ -q
git diff --check
```
Анализатор отказывается перезаписывать существующий выход и сохраняет новый
JSON только внутри `data/processed/lab055`. `recheck_01.json` должен быть новым.
Для воспроизведения нужны локальные исходные IQ по указанным путям; тесты
этого требования не имеют. Проверка не запускает оборудование.
Целевые тесты: **9 passed**. Verifier независимо проверил целочисленные суммы
TX, PSD через отдельные FFT, Парсеваль, взвешенную регрессию CFO, комплексный
МНК и проверочные мощности. Найденная утечка устранена; остаточных находок нет.
Полные шлюзы: `quick_gate` PASS — 823 теста, 28 функциональных проверок,
13 штатных пропусков; отдельный `pytest tests/ -q` — **823 passed**, 89,15 с.
Единственное предупреждение pytest относится к недоступной записи его кеша;
проверки завершились с кодом 0. `git diff --check` — PASS.
HEAD: `0368c6d3e930f4d245e056223fd4e5c4f70e6b49`, ветка `main`.
Существующие изменения Lab054 сохранены. Изменения Lab055: отдельные анализатор,
тесты, JSON, этот отчёт; добавляются только новая запись журнала и ссылка индекса.
```text
RF TX = 0
HACKRF = NOT STARTED
LAB054 HISTORICAL RESULT = NOT_A, 0/16
GIT ADD / COMMIT / PUSH / PULL / FETCH = NOT PERFORMED
STOP. Следующий этап — согласование измерительной схемы и приборов;
никакого автоматического аппаратного продолжения.
```

View File

@@ -0,0 +1,384 @@
"""Lab055: энергия сохранённых IQ; аппаратных зависимостей и команд нет."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import json
from pathlib import Path
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from scipy import signal
from protocol.bpsk_radio import root_raised_cosine_taps
SAMPLE_RATE = 8_000_000
CARRIER = 50_000
SPECTRUM_SIZE = 65536
PROJECT_ROOT = Path(__file__).resolve().parents[1]
OLD_DIRECTORY = Path(r"C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747")
NEW_DIRECTORY = PROJECT_ROOT / "data/raw/lab054"
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def sha256(path: Path) -> str:
"""Хеш без загрузки файла целиком."""
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
class Cs8File:
"""Только чтение interleaved signed IQ; в память извлекается одно окно."""
def __init__(self, path: Path):
if path.stat().st_size == 0 or path.stat().st_size % 2:
raise ValueError("Нужен непустой CS8-файл с полными IQ-парами")
self.values = np.memmap(path, mode="r", dtype=np.int8)
self.size = self.values.size // 2
def read(self, start: int, count: int) -> np.ndarray:
if start < 0 or count <= 0 or start + count > self.size:
raise ValueError("Окно вне сохранённой записи")
values = self.values[2 * start:2 * (start + count)]
return values[::2].astype(np.float32) + 1j * values[1::2].astype(np.float32)
@dataclass
class Moments:
"""Точные накопители моментов цифровых отсчётов, без массивов всей записи."""
count: int = 0
total: complex = 0j
energy: float = 0.0
peak_power: float = 0.0
max_i: float = 0.0
max_q: float = 0.0
clipped_components: int = 0
clipped_samples: int = 0
def add(self, values: np.ndarray) -> None:
power = values.real.astype(float)**2 + values.imag.astype(float)**2
clip_i = (values.real == -128) | (values.real == 127)
clip_q = (values.imag == -128) | (values.imag == 127)
self.count += values.size
self.total += complex(np.sum(values, dtype=np.complex128))
self.energy += float(np.sum(power))
self.peak_power = max(self.peak_power, float(np.max(power)))
self.max_i = max(self.max_i, float(np.max(abs(values.real))))
self.max_q = max(self.max_q, float(np.max(abs(values.imag))))
self.clipped_components += int(np.sum(clip_i) + np.sum(clip_q))
self.clipped_samples += int(np.sum(clip_i | clip_q))
def result(self, symbol_rate: int) -> dict:
mean = self.total / self.count
power = self.energy / self.count
return {"samples": self.count, "mean_i": mean.real, "mean_q": mean.imag,
"power_code2": power, "ac_power_code2": power - abs(mean)**2,
"rms_code": float(np.sqrt(power)), "peak_magnitude_code": float(np.sqrt(self.peak_power)),
"max_abs_i": self.max_i, "max_abs_q": self.max_q,
"papr_db": float(10 * np.log10(self.peak_power / power)),
"clipped_components_fraction": self.clipped_components / (2 * self.count),
"clipped_samples_fraction": self.clipped_samples / self.count,
"symbol_energy_code2_seconds": power / symbol_rate}
def spectrum(values: np.ndarray, sample_rate: int = SAMPLE_RATE) -> tuple[np.ndarray, np.ndarray]:
"""Двусторонняя PSD комплексного сигнала; интеграл имеет единицы код²."""
size = min(SPECTRUM_SIZE, values.size)
frequencies, density = signal.welch(values, fs=sample_rate, window="hann",
nperseg=size, noverlap=size // 2, detrend=False, return_onesided=False, scaling="density")
return frequencies, density.astype(float)
def band_power(frequencies: np.ndarray, density: np.ndarray, low: float, high: float) -> float:
width = abs(float(frequencies[1] - frequencies[0]))
return float(np.sum(density[(frequencies >= low) & (frequencies < high)]) * width)
def spread(values: list[float]) -> dict:
return {"min": float(np.min(values)), "median": float(np.median(values)),
"max": float(np.max(values)), "mean": float(np.mean(values))}
def filter_metrics(samples_per_symbol: int) -> tuple[np.ndarray, dict]:
taps = root_raised_cosine_taps(.35, samples_per_symbol, 10)
energy = float(np.sum(abs(taps)**2))
dc_gain = float(abs(np.sum(taps))**2)
return taps, {"rolloff": .35, "span_symbols": 10, "taps": len(taps),
"group_delay_samples": (len(taps) - 1) / 2,
"energy": energy, "dc_power_gain": dc_gain,
"equivalent_noise_bandwidth_hz": SAMPLE_RATE * energy / dc_gain}
def matched_noise(frequencies: np.ndarray, density: np.ndarray, taps: np.ndarray,
carrier: float = CARRIER) -> float:
"""Интеграл PSD*|H|² для фактического единично-энергетического RRC."""
shifted = taps * np.exp(2j * np.pi * carrier * np.arange(len(taps)) / SAMPLE_RATE)
response = np.fft.fft(shifted, len(frequencies))
return float(np.sum(density * abs(response)**2) * SAMPLE_RATE / len(frequencies))
def align_training(reference: np.ndarray, received: np.ndarray, margin: int) -> int:
"""Задержка по обучающему окну; проверочные участки не участвуют."""
reference = reference - reference.mean()
received = received - received.mean()
correlation = signal.fftconvolve(received, np.conj(reference[::-1]), mode="valid")
energy = abs(received)**2
cumulative = np.concatenate(([0.], np.cumsum(energy, dtype=float)))
windows = cumulative[len(reference):] - cumulative[:-len(reference)]
scores = abs(correlation) / np.sqrt(np.maximum(windows, 1e-30))
return int(np.argmax(scores)) - margin
def fit_channel(reference: np.ndarray, received: np.ndarray, sample_rate: int,
train_parity: int = 0, spectral_taps: np.ndarray | None = None) -> dict:
"""Модель y=d+g*x*exp(j2πft); 8 плиток, чередующиеся обучение/проверка.
Частота сначала оценивается по фазе четырёх обучающих проекций.
g и d оцениваются только по обучающим отсчётам. Проверочный остаток
содержит шум, частотную/временную ошибку и прочие искажения модели.
"""
count = len(reference)
if count != len(received) or count < 128:
raise ValueError("Несопоставимые или слишком короткие окна")
boundaries = np.linspace(0, count, 9, dtype=int)
train = np.zeros(count, dtype=bool)
times, gains, weights = [], [], []
for tile in range(train_parity, 8, 2):
low, high = boundaries[tile:tile + 2]
train[low:high] = True
x = reference[low:high].astype(np.complex128)
y = received[low:high].astype(np.complex128)
x -= x.mean()
y -= y.mean()
energy = float(np.vdot(x, x).real)
gain = np.vdot(x, y) / max(energy, 1e-30)
gains.append(gain)
times.append((low + high - 1) / (2 * sample_rate))
weights.append(abs(gain)**2 * energy)
times = np.asarray(times)
matrix = np.column_stack((np.ones(4), times - times.mean()))
roots = np.sqrt(np.maximum(weights, 1e-30))
phase = np.unwrap(np.angle(gains))
fit = np.linalg.lstsq(matrix * roots[:, None], phase * roots, rcond=None)[0]
frequency = float(fit[1] / (2 * np.pi))
model_reference = reference * np.exp(2j * np.pi * frequency * np.arange(count) / sample_rate)
x = model_reference[train]
y = received[train].astype(np.complex128)
x_mean, y_mean = x.mean(), y.mean()
gain = np.vdot(x - x_mean, y - y_mean) / max(float(np.sum(abs(x - x_mean)**2)), 1e-30)
dc = y_mean - gain * x_mean
result = {"cfo_hz": frequency, "gain_abs": float(abs(gain)),
"dc_i": float(dc.real), "dc_q": float(dc.imag)}
for label, mask in (("train", train), ("heldout", ~train)):
predicted = gain * model_reference[mask]
actual = received[mask] - dc
residual = actual - predicted
result[label + "_signal_power"] = float(np.mean(abs(predicted)**2))
result[label + "_residual_power"] = float(np.mean(abs(residual)**2))
result[label + "_actual_ac_power"] = float(np.mean(abs(actual)**2))
result[label + "_cross_power"] = float(2 * np.mean((predicted.conj() * residual).real))
if spectral_taps is not None:
# Отдельные плитки: не склеиваем разорванные окна перед оценкой PSD.
spectral_rows = []
for tile in range(1 - train_parity, 8, 2):
low, high = boundaries[tile:tile + 2]
residual = received[low:high] - dc - gain * model_reference[low:high]
frequencies, density = spectrum(residual, sample_rate)
spectral_rows.append({
**{f"heldout_residual_band_{width}_power": band_power(
frequencies, density, CARRIER - width / 2, CARRIER + width / 2)
for width in (27000, 675000, 1000000)},
"heldout_residual_matched_power": matched_noise(frequencies, density, spectral_taps)})
result.update({key: float(np.mean([row[key] for row in spectral_rows])) for key in spectral_rows[0]})
return result
def analyze_tx(path: Path, frame_samples: int, frames: int, symbol_rate: int) -> dict:
source = Cs8File(path)
samples_per_symbol = SAMPLE_RATE // symbol_rate
if source.size != frame_samples * frames:
raise ValueError("Размер TX не соответствует метаданным")
moments = {"whole": Moments(), "active": Moments()}
rows = []
for frame in range(frames):
values = source.read(frame * frame_samples, frame_samples)
active = values[21 * samples_per_symbol:-21 * samples_per_symbol]
row = {"frame": frame + 1}
for label, array in (("whole", values), ("active", active)):
moments[label].add(array)
local = Moments()
local.add(array)
row[label] = local.result(symbol_rate)
# Прямоугольная периодограмма полного окна сохраняет Parseval,
# включая защитные интервалы. Welch недовзвешивает края файла.
frequencies, density = signal.periodogram(array, fs=SAMPLE_RATE,
window="boxcar", detrend=False, return_onesided=False, scaling="density")
row[label]["occupied_band_power_code2"] = band_power(
frequencies, density, CARRIER - .675 * symbol_rate, CARRIER + .675 * symbol_rate)
rows.append(row)
return {"whole": moments["whole"].result(symbol_rate),
"active": moments["active"].result(symbol_rate), "frames": rows,
"occupied_band_power_whole": spread([r["whole"]["occupied_band_power_code2"] for r in rows]),
"occupied_band_power_active": spread([r["active"]["occupied_band_power_code2"] for r in rows])}
def analyze_background(source: Cs8File, seconds: list[float], samples_per_symbol: int) -> dict:
taps, metrics = filter_metrics(samples_per_symbol)
rows = []
for start_seconds in seconds:
values = source.read(round(start_seconds * SAMPLE_RATE), SAMPLE_RATE // 10)
moments = Moments()
moments.add(values)
row = {"start_seconds": start_seconds, **moments.result(SAMPLE_RATE // samples_per_symbol)}
values -= values.mean()
frequencies, density = spectrum(values)
for width in (27000, 675000, 1000000):
power = band_power(frequencies, density, CARRIER - width / 2, CARRIER + width / 2)
row[f"band_{width}_power"] = power
row[f"band_{width}_density"] = power / width
row["matched_noise_power"] = matched_noise(frequencies, density, taps)
row["equivalent_n0_code2_per_hz"] = row["matched_noise_power"] / (SAMPLE_RATE * metrics["energy"])
row["carrier_noise_model"] = {str(carrier): matched_noise(frequencies, density, taps, carrier)
for carrier in (50000, 250000, 500000, 750000, 1000000)}
rows.append(row)
return {"window_seconds": .1, "rrc": metrics, "windows": rows,
"summary": {key: spread([r[key] for r in rows]) for key in (
"mean_i", "mean_q", "ac_power_code2", "band_27000_power", "band_675000_power",
"band_1000000_power", "band_27000_density", "band_675000_density",
"matched_noise_power", "equivalent_n0_code2_per_hz")}}
def analyze_rx(tx: Cs8File, rx: Cs8File, starts: list[int], frame_samples: int,
samples_per_symbol: int) -> dict:
rows = []
taps, _ = filter_metrics(samples_per_symbol)
for frame, start in enumerate(starts):
reference = tx.read(frame * frame_samples, frame_samples)
trim = 21 * samples_per_symbol
training_count = (frame_samples - 2 * trim) // 8
margin = samples_per_symbol
# Поиск остаётся внутри итоговой обучающей плитки при любом lag±margin.
if training_count <= 4 * margin:
raise ValueError("Обучающая плитка слишком коротка для защищённого поиска")
train_reference = reference[trim + 2 * margin:trim + training_count - 2 * margin]
train_received = rx.read(start + trim + margin, training_count - 2 * margin)
offset = align_training(train_reference, train_received, margin)
start += offset
received = rx.read(start, frame_samples)
active_reference = reference[trim:-trim]
active_received = received[trim:-trim]
# Задержка обучена на плитке 0: только чётные плитки обучают основную модель.
fitted = fit_channel(active_reference, active_received, SAMPLE_RATE, spectral_taps=taps)
moments = Moments()
moments.add(active_received)
row = {"frame": frame + 1, "start": start, "alignment_adjustment": offset,
"rx_active": moments.result(SAMPLE_RATE // samples_per_symbol), **fitted}
row["effective_signal_to_residual_db"] = float(10 * np.log10(
fitted["heldout_signal_power"] / fitted["heldout_residual_power"]))
rows.append(row)
print(f"RX frame {frame + 1}/{len(starts)}", flush=True)
return {"frames": rows, "summary": {key: spread([r[key] for r in rows]) for key in (
"alignment_adjustment", "cfo_hz", "gain_abs", "heldout_signal_power",
"train_residual_power", "heldout_residual_power", "heldout_actual_ac_power",
"heldout_cross_power", "effective_signal_to_residual_db", "heldout_residual_band_27000_power",
"heldout_residual_band_675000_power", "heldout_residual_band_1000000_power",
"heldout_residual_matched_power")}}
def audit(old_directory: Path = OLD_DIRECTORY, new_directory: Path = NEW_DIRECTORY) -> dict:
"""Все входы читаются; результат возвращается отдельно от исторических файлов."""
old_meta = read_json(old_directory / "tx_metadata.json")
new_meta = read_json(new_directory / "tx_metadata.json")
old_capture = read_json(old_directory / "capture_result.json")
official_path = PROJECT_ROOT / "data/processed/lab054/rf_summary.json"
official = read_json(official_path)
protected = sorted(set([p for directory in (old_directory, new_directory,
official_path.parent) for p in directory.iterdir() if p.is_file()]))
hashes = {str(path): sha256(path) for path in protected}
expected = {old_directory / "tx_stream.cs8": old_meta["tx_stream"]["sha256"],
old_directory / "rx_stream.cs8": old_capture["rx_sha256"],
new_directory / "tx_stream.cs8": new_meta["tx_sha256"],
new_directory / "rx_stream.cs8": official["artifact_sha256"]["rx_stream.cs8"]}
for path, expected_hash in expected.items():
if hashes[str(path)] != expected_hash:
raise ValueError(f"SHA не совпал: {path}")
if official["result"] != "NOT_A" or official["exact_packets"] != 0:
raise ValueError("Историческое состояние Lab054 изменилось; нужен новый аудит")
accepted = [r for r in read_json(old_directory / "independent_reprocess_result_corrected.json")["strict"]["outcomes"] if r["accepted"]]
old_starts = [r["refined_start_sample"] for r in accepted]
# Только диагностические исходные положения из завершённого анализа Lab054.
new_starts = [4161351, 4253606, 4345863, 4438119, 4530375, 4622631, 4714887, 4807143,
4899399, 4991655, 5083911, 5176167, 5268423, 5360679, 5452935, 5545191]
specifications = [("Lab047", old_directory, 20000, old_starts,
[.05, .20, .35, .50, 12.05, 12.4, 12.75, 13.1, 13.45, 13.8]),
("Lab054", new_directory, 500000, new_starts,
[.02, .12, .22, .32, 1., 1.25, 1.5, 1.75, 2., 2.5, 2.8])]
result = {"rf_tx": 0, "official_lab054": "NOT_A", "independent_receive": False,
"sample_rate_hz": SAMPLE_RATE, "carrier_hz": CARRIER,
"input_sha256": hashes, "experiments": {}}
for name, directory, symbol_rate, starts, background_seconds in specifications:
frame_samples = 5766 * (SAMPLE_RATE // symbol_rate)
tx, rx = Cs8File(directory / "tx_stream.cs8"), Cs8File(directory / "rx_stream.cs8")
print(name, "TX", flush=True)
tx_result = analyze_tx(directory / "tx_stream.cs8", frame_samples, len(starts), symbol_rate)
background = analyze_background(rx, background_seconds, SAMPLE_RATE // symbol_rate)
rx_result = analyze_rx(tx, rx, starts, frame_samples, SAMPLE_RATE // symbol_rate)
power = rx_result["summary"]["heldout_signal_power"]["median"]
n0 = background["summary"]["equivalent_n0_code2_per_hz"]["median"]
result["experiments"][name] = {"directory": str(directory), "symbol_rate": symbol_rate,
"frame_samples": frame_samples, "tx_bytes": tx.size * 2, "rx_bytes": rx.size * 2,
"tx": tx_result, "background": background, "rx": rx_result,
"conditional_es_n0_equivalent_db": float(10 * np.log10(power / symbol_rate / n0))}
old, new = result["experiments"].values()
ratio_db = lambda new_value, old_value: float(10 * np.log10(new_value / old_value))
result["changes_db"] = {
"tx_active_power": ratio_db(new["tx"]["active"]["power_code2"], old["tx"]["active"]["power_code2"]),
"rx_fitted_signal_power": ratio_db(new["rx"]["summary"]["heldout_signal_power"]["median"], old["rx"]["summary"]["heldout_signal_power"]["median"]),
"rate_penalty": ratio_db(new["symbol_rate"], old["symbol_rate"]),
"equivalent_n0": ratio_db(new["background"]["summary"]["equivalent_n0_code2_per_hz"]["median"], old["background"]["summary"]["equivalent_n0_code2_per_hz"]["median"])}
result["changes_db"]["conditional_es_n0_equivalent"] = new["conditional_es_n0_equivalent_db"] - old["conditional_es_n0_equivalent_db"]
changed = [str(path) for path in protected if sha256(path) != hashes[str(path)]]
if changed:
raise RuntimeError(f"Исходные артефакты изменились: {changed}")
result["source_integrity_after_analysis"] = True
return result
def run_functional_tests() -> None:
"""Минимальная проверка единиц и моментов без экспериментальных данных."""
moments = Moments()
moments.add(np.full(32, 3 + 4j))
assert moments.result(5)["power_code2"] == 25
assert moments.result(5)["symbol_energy_code2_seconds"] == 5
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=PROJECT_ROOT / "data/processed/lab055/energy_audit.json")
arguments = parser.parse_args()
target = arguments.output.resolve()
allowed = (PROJECT_ROOT / "data/processed/lab055").resolve()
if not target.is_relative_to(allowed) or target.exists():
raise ValueError("Нужен новый файл только внутри data/processed/lab055")
result = audit()
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("x", encoding="utf-8") as stream:
json.dump(result, stream, ensure_ascii=False, indent=2, allow_nan=False)
stream.write("\n")
print(json.dumps(result["changes_db"], indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,117 @@
"""Lab055: единицы энергии, комплексный шум и раздельная проверка модели."""
from __future__ import annotations
import numpy as np
import pytest
from experiments import lab055_energy_audit as lab
def test_complex_power_and_chunk_accumulation():
values = np.array([3 + 4j, -128 + 127j, 0j, -4 - 3j], dtype=np.complex64)
whole, chunks = lab.Moments(), lab.Moments()
whole.add(values)
chunks.add(values[:1])
chunks.add(values[1:])
assert whole.result(20) == chunks.result(20)
result = whole.result(20)
assert result["power_code2"] == (25 + 128**2 + 127**2 + 25) / 4
assert result["clipped_samples_fraction"] == .25
assert result["clipped_components_fraction"] == .25
assert result["symbol_energy_code2_seconds"] == result["power_code2"] / 20
def test_cs8_signed_layout_and_bounds(tmp_path):
path = tmp_path / "input.cs8"
path.write_bytes(np.array([-128, 127, 3, -4], dtype=np.int8).tobytes())
source = lab.Cs8File(path)
np.testing.assert_array_equal(source.read(0, 2), [-128 + 127j, 3 - 4j])
with pytest.raises(ValueError):
source.read(1, 2)
with pytest.raises(ValueError):
source.read(-1, 1)
path = tmp_path / "odd.cs8"
path.write_bytes(b"123")
with pytest.raises(ValueError):
lab.Cs8File(path)
@pytest.mark.parametrize("samples_per_symbol", [16, 400])
def test_complex_white_noise_convention_and_enbw(samples_per_symbol):
taps, metrics = lab.filter_metrics(samples_per_symbol)
variance = .7
frequencies = np.fft.fftfreq(lab.SPECTRUM_SIZE, 1 / lab.SAMPLE_RATE)
density = np.full(frequencies.size, variance / lab.SAMPLE_RATE)
filtered = lab.matched_noise(frequencies, density, taps)
assert filtered == pytest.approx(variance * sum(abs(taps)**2), rel=1e-10)
assert filtered / (lab.SAMPLE_RATE * metrics["energy"]) == pytest.approx(variance / lab.SAMPLE_RATE)
assert metrics["equivalent_noise_bandwidth_hz"] == pytest.approx(
lab.SAMPLE_RATE * sum(abs(taps)**2) / abs(sum(taps))**2)
def test_psd_integrates_complex_power_without_factor_two():
sample_rate = 65536
values = 3 * np.exp(2j * np.pi * 4096 * np.arange(sample_rate) / sample_rate)
frequencies, density = lab.spectrum(values, sample_rate)
assert lab.band_power(frequencies, density, 4000, 4200) == pytest.approx(9, rel=1e-10)
assert lab.band_power(frequencies, density, -4200, -4000) < 1e-20
def test_training_alignment_known_delay():
random_generator = np.random.default_rng(5501)
reference = random_generator.normal(size=1000) + 1j * random_generator.normal(size=1000)
received = np.concatenate((np.zeros(12), reference, np.zeros(8))) + 4 + 2j
assert lab.align_training(reference, received, 10) == 2
def test_channel_recovers_known_gain_frequency_and_heldout_noise():
random_generator = np.random.default_rng(5502)
count, sample_rate = 80000, 80000
reference = random_generator.choice([-1., 1.], count) * np.exp(2j * np.pi * .1 * np.arange(count))
gain, dc, frequency, variance = .3 * np.exp(.7j), 2 + 3j, .17, .02
received = dc + gain * reference * np.exp(2j * np.pi * frequency * np.arange(count) / sample_rate)
received += np.sqrt(variance / 2) * (random_generator.normal(size=count) + 1j * random_generator.normal(size=count))
result = lab.fit_channel(reference, received, sample_rate)
assert result["gain_abs"] == pytest.approx(abs(gain), rel=.02)
assert result["cfo_hz"] == pytest.approx(frequency, abs=.01)
assert result["heldout_signal_power"] == pytest.approx(.09, rel=.04)
assert result["heldout_residual_power"] == pytest.approx(variance, rel=.04)
assert result["heldout_actual_ac_power"] == pytest.approx(
result["heldout_signal_power"] + result["heldout_residual_power"] + result["heldout_cross_power"], abs=1e-10)
def test_heldout_corruption_does_not_train_channel():
random_generator = np.random.default_rng(5503)
reference = random_generator.normal(size=8000) + 1j * random_generator.normal(size=8000)
received = .2j * reference + 2
clean = lab.fit_channel(reference, received, 8000)
for tile in (1, 3, 5, 7):
received[tile * 1000:(tile + 1) * 1000] += 5
damaged = lab.fit_channel(reference, received, 8000)
assert damaged["gain_abs"] == clean["gain_abs"]
assert damaged["cfo_hz"] == clean["cfo_hz"]
assert damaged["train_residual_power"] < 1e-20
assert damaged["heldout_residual_power"] == pytest.approx(25)
def test_pipeline_timing_search_does_not_read_heldout_boundary():
class MemorySource:
def __init__(self, values):
self.values = values
def read(self, start, count):
return self.values[start:start + count].copy()
random_generator = np.random.default_rng(55055)
count, samples_per_symbol, start = 2048, 16, 200
reference = random_generator.normal(size=count) + 1j * random_generator.normal(size=count)
received = np.zeros(count + 2 * start, dtype=complex)
received[start:start + count] = reference
clean = lab.analyze_rx(MemorySource(reference), MemorySource(received), [start], count, samples_per_symbol)
first_heldout = start + 21 * samples_per_symbol + (count - 42 * samples_per_symbol) // 8
received[first_heldout] += 10000
damaged = lab.analyze_rx(MemorySource(reference), MemorySource(received), [start], count, samples_per_symbol)
assert damaged["frames"][0]["alignment_adjustment"] == clean["frames"][0]["alignment_adjustment"] == 0
assert damaged["frames"][0]["gain_abs"] == pytest.approx(1)
assert damaged["frames"][0]["cfo_hz"] == pytest.approx(0)