Lab058: characterize symbol quality in controlled ABAB recording

This commit is contained in:
LittleSam129
2026-09-11 11:49:48 +03:00
parent 5773136ebf
commit 82f17499ac
15 changed files with 3408 additions and 0 deletions

View File

@@ -2498,3 +2498,40 @@ CSV/JSON, все комплексные статистики CFO, созвезд
новых успешных пакетов Lab054 нет. Коммит и push не выполнялись.
Следующий этап: STOP и отдельное согласование OFFLINE-оценки качества символов
сохранённой ABAB-записи Lab056; Lab058 и новые RF-сеансы автоматически не запускать.
# Запись 052
## Дата
10 сентября 2026 года
## Тема
Lab058 — OFFLINE-анализ качества символов сохранённого ABAB-сеанса Lab056
## Выполнено
После фиксации Lab057 коммитом 5773136ebfa8bafdffc344462d0ce6b56838d3af выполнен
только офлайн-анализ исходных TX/RX Lab056 с повторной проверкой размеров и SHA-256.
Исторические Lab054/Lab056 не изменялись, оборудование не опрашивалось, RF TX=0.
A1/B1/A2/B2 обработаны отдельно. Receiver-like использует штатные RRC/timing/carrier и
только известную приёмнику 1280-символьную преамбулу; oracle использует TX truth с заранее
разделёнными 64 плитками train/validation и защитными краями. Средний oracle BER A/B
0%/3,4959%, RMS EVM 0,3174/0,8453, matched/residual 9,9685/1,4648 дБ. Receiver-like BER A/B
0,0178%/52,4169%: у B штатный CFO около +4,7 Гц вместо близкого к нулю oracle, что
локализует сильный дополнительный сбой carrier synchronization.
Eye opening A/B 1,9979/1,9935, но p01 signed margin 0,4231/−0,8195. Фиксированный ISI
−5…+5 дал adjacent/main −33,3195/−30,1006 дБ. Усреднённая H(f) дала робастный ripple
0,4067/5,4714 дБ; источник внутри общего OTA TX–канал–RX пути не разделён, group-delay
variation неустойчива. TX control BER 0/0; AWGN control при одинаковой sampled noise PSD
ухудшил EVM B/A на 13,17 дБ в ожидаемую сторону; background дал BER около 50%.
Результат MIXED: энергия символа и широкополосная неравномерность ухудшают физическое
качество B, а текущая синхронизация ухудшает его ещё сильнее. Lab054 остаётся NOT_A,
0/16. Lab058 не коммитилась, push не выполнялся.
Следующий этап: отдельно согласовать полностью офлайн-проверку исправленного preamble-only
CFO/carrier estimator на сохранённых Lab056, Lab054 и контролях, без payload truth в
оценивателе и без запуска Lab059 автоматически.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

View File

@@ -0,0 +1,9 @@
label,symbol_rate,analysis_mode,errors,symbols,ber,plus_mean,plus_std,minus_mean,minus_std,cloud_separation_sigma,evm_rms,matched_component_power,residual_power,matched_residual_db,margin_median,margin_p10,margin_p01,timing_fraction,cfo_hz,eye_opening,isi_ratio_db,channel_ripple_db
A1,20000,receiver_like,1,2816,0.0003551136363636364,0.9880681326979883,0.15648583583715736,-0.9856267799709301,0.16945752044704543,12.101082783161676,0.31642307417060406,127.30175120186958,12.745904762311103,9.994637088848515,1.0140433795840895,0.7899864118520403,0.4377447808978921,0.005,0.05578903039015779,,,
A1,20000,oracle,0,1536,0.0,1.0012684809963817,0.1591520779959967,-1.0097505840001473,0.16641220325175363,12.350981551919041,0.3145802767715763,124.70914553865235,12.34131064091916,10.045370196097327,1.027809668261911,0.8134464053956763,0.4432253875556976,0.0,-0.01750874609532253,2.011019064996529,-34.48481011797861,0.42744563312360384
B1,500000,receiver_like,53110,101120,0.5252175632911392,-0.0454563230499973,0.8741108017316369,0.04662327857775051,0.8735221665255701,-0.10537635542147922,1.627914243358283,5.039845113113856,13.356117643515095,-4.232630460483268,-0.0749068280072387,-1.1522860266119301,-1.8219752970381689,0.0,4.668990755640429,,,
B1,500000,oracle,1625,50688,0.03205886994949495,1.0038798532842312,0.529822170298403,-1.0014451905936077,0.5182158275348242,3.826582798530514,0.8150734836811827,4.750902189471545,3.1562370879202715,1.776064705725458,1.0383194984792974,0.44558459778809906,-0.7473909193349014,-0.03125,-0.061664567120971146,2.0221327626996053,-30.266350888049665,4.532105058230293
A2,20000,receiver_like,0,2816,0.0,1.0021698877382776,0.17517393918188917,-1.0024512759126043,0.16704088981966111,11.712264400188428,0.33212139607349384,119.16645960129603,13.144611249201235,9.574062903730379,1.030798396020773,0.7973344156274578,0.39794039729378256,0.0,-0.13866849385025318,,,
A2,20000,oracle,0,1536,0.0,0.9977253051585244,0.16898140247082657,-0.9871010298668412,0.1679864412015912,11.780457513231198,0.32020059920935173,121.29032528645918,12.435705865808165,9.89155719392642,1.0233283127955501,0.7809803024446145,0.40296058277799685,0.0,0.007241046431241284,1.9848263350253657,-32.154237627215615,0.38600804813078327
B2,500000,receiver_like,52898,101120,0.5231210443037975,-0.03965927594451565,0.9153949378638604,0.037608559281639885,0.9219659530864524,-0.08410687467817614,1.6786285118041953,4.662839201920867,13.138918837022253,-4.499091908176917,-0.06971458856811955,-1.1852259871865864,-1.9044511604526417,-0.0625,4.742179029785673,,,
B2,500000,oracle,1919,50688,0.037859059343434344,0.9970549669625041,0.5721636737651704,-1.0002893453921478,0.5792017459003083,3.469458023525076,0.8756260665627654,4.62511430417931,3.5461723034699646,1.153626367484854,1.0295281840215331,0.40799559678633845,-0.8916404606042253,-0.03125,-0.018668146565430317,1.9648355420847208,-29.934890653519595,6.41065794417576
1 label symbol_rate analysis_mode errors symbols ber plus_mean plus_std minus_mean minus_std cloud_separation_sigma evm_rms matched_component_power residual_power matched_residual_db margin_median margin_p10 margin_p01 timing_fraction cfo_hz eye_opening isi_ratio_db channel_ripple_db
2 A1 20000 receiver_like 1 2816 0.0003551136363636364 0.9880681326979883 0.15648583583715736 -0.9856267799709301 0.16945752044704543 12.101082783161676 0.31642307417060406 127.30175120186958 12.745904762311103 9.994637088848515 1.0140433795840895 0.7899864118520403 0.4377447808978921 0.005 0.05578903039015779
3 A1 20000 oracle 0 1536 0.0 1.0012684809963817 0.1591520779959967 -1.0097505840001473 0.16641220325175363 12.350981551919041 0.3145802767715763 124.70914553865235 12.34131064091916 10.045370196097327 1.027809668261911 0.8134464053956763 0.4432253875556976 0.0 -0.01750874609532253 2.011019064996529 -34.48481011797861 0.42744563312360384
4 B1 500000 receiver_like 53110 101120 0.5252175632911392 -0.0454563230499973 0.8741108017316369 0.04662327857775051 0.8735221665255701 -0.10537635542147922 1.627914243358283 5.039845113113856 13.356117643515095 -4.232630460483268 -0.0749068280072387 -1.1522860266119301 -1.8219752970381689 0.0 4.668990755640429
5 B1 500000 oracle 1625 50688 0.03205886994949495 1.0038798532842312 0.529822170298403 -1.0014451905936077 0.5182158275348242 3.826582798530514 0.8150734836811827 4.750902189471545 3.1562370879202715 1.776064705725458 1.0383194984792974 0.44558459778809906 -0.7473909193349014 -0.03125 -0.061664567120971146 2.0221327626996053 -30.266350888049665 4.532105058230293
6 A2 20000 receiver_like 0 2816 0.0 1.0021698877382776 0.17517393918188917 -1.0024512759126043 0.16704088981966111 11.712264400188428 0.33212139607349384 119.16645960129603 13.144611249201235 9.574062903730379 1.030798396020773 0.7973344156274578 0.39794039729378256 0.0 -0.13866849385025318
7 A2 20000 oracle 0 1536 0.0 0.9977253051585244 0.16898140247082657 -0.9871010298668412 0.1679864412015912 11.780457513231198 0.32020059920935173 121.29032528645918 12.435705865808165 9.89155719392642 1.0233283127955501 0.7809803024446145 0.40296058277799685 0.0 0.007241046431241284 1.9848263350253657 -32.154237627215615 0.38600804813078327
8 B2 500000 receiver_like 52898 101120 0.5231210443037975 -0.03965927594451565 0.9153949378638604 0.037608559281639885 0.9219659530864524 -0.08410687467817614 1.6786285118041953 4.662839201920867 13.138918837022253 -4.499091908176917 -0.06971458856811955 -1.1852259871865864 -1.9044511604526417 -0.0625 4.742179029785673
9 B2 500000 oracle 1919 50688 0.037859059343434344 0.9970549669625041 0.5721636737651704 -1.0002893453921478 0.5792017459003083 3.469458023525076 0.8756260665627654 4.62511430417931 3.5461723034699646 1.153626367484854 1.0295281840215331 0.40799559678633845 -0.8916404606042253 -0.03125 -0.018668146565430317 1.9648355420847208 -29.934890653519595 6.41065794417576

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -476,3 +476,22 @@
[график](../data/processed/lab057/comparison.png), [проверки](../data/processed/lab057/acceptance.json).
- **STOP:** исторический Lab054 NOT_A, 0/16 сохранён. Lab057 не коммитилась,
push не выполнялся; следующий OFFLINE-анализ Lab056 требует отдельного согласования.
## Lab058 — качество символов сохранённого ABAB Lab056
- **Запись 052; только сохранённые файлы, RF TX=0.** Lab057 зафиксирована коммитом
`5773136ebfa8bafdffc344462d0ce6b56838d3af`; Lab058 не закоммичена.
- **Oracle A/B:** BER 0%/3,4959%, EVM 0,3174/0,8453, matched/residual
9,9685/1,4648 дБ. Энергия символа B/A из Lab056 −13,870505 дБ.
- **Receiver-like A/B:** BER 0,0178%/52,4169%; B оценивает CFO примерно +4,7 Гц,
тогда как oracle получает значение около нуля. Carrier synchronization — отдельный
подтверждённый практический предел текущего receiver.
- **Диагностика тракта:** eye opening 1,9979/1,9935; ISI ratio −33,3195/−30,1006 дБ;
H(f) ripple p95−p05 0,4067/5,4714 дБ. OTA не разделяет TX, канал и RX; group-delay
variation не объявлена устойчивой.
- **Контроли:** TX BER 0/0; synthetic AWGN движет EVM в ожидаемую сторону при одинаковой
noise PSD; background BER около 50%. Итог `MIXED`; Lab054 остаётся NOT_A, 0/16.
- [Постановка](lab058_current_state.md), [отчёт](lab058_offline_result.md),
[JSON](../data/processed/lab058/summary.json),
[CSV](../data/processed/lab058/segment_metrics.csv),
[H(f)](../data/processed/lab058/channel_transfer.png). STOP; push не выполнялся.

View File

@@ -0,0 +1,82 @@
# Lab058 — исходное состояние и заранее зафиксированный план
## Назначение и границы
Lab058 измеряет качество BPSK-символов в уже сохранённом единственном OTA ABAB-сеансе
Lab056. Аппаратное оборудование не опрашивается, HackRF Plus не запускается, передача
не выполняется. Исторические данные Lab054 и Lab056 не изменяются. Все новые результаты
пишутся только в `data/processed/lab058/`.
Исходный Git HEAD после отдельной фиксации Lab057:
`5773136ebfa8bafdffc344462d0ce6b56838d3af`.
## Проверяемые исходные данные
- `data/raw/lab056/tx_abab.cs8`: ожидаемый SHA-256
`5b34bbe1a8e6d037d2d6d367a74e9eb84e8940db7da61600ffaa7f7497d4f600`;
- `data/raw/lab056/rx_stream.cs8`: ожидаемый SHA-256
`e336dd653fa554058052fcf851e9f2462f66b8cf7c710815ca2b9a09ffc23ad4`;
- Fs = 8 Мвыб/с, цифровая несущая +50 кГц, RRC roll-off 0,35, span 10 символов,
guard 16 символов;
- A1/A2: 20 ксимв/с, 4096 полезных символов, 400 отсчётов/символ;
- B1/B2: 500 ксимв/с, 102400 полезных символов, 16 отсчётов/символ;
- границы burst и их уточнённые RX-start берутся из зафиксированного
`data/processed/lab056/rf_analysis.json`.
Перед расчётом программа повторно проверяет существование, размер и SHA-256 обоих raw
файлов. Она также проверяет, что первые 1280 символов каждого burst точно совпадают с
действующей штатной PRBS7-преамбулой.
## Два раздельных режима
**Receiver-like.** Используется существующий RRC matched filter и действующие алгоритмы
timing/CFO/комплексного gain. Их оценки получают только 1280 известных приёмнику символов
преамбулы. Правильная полная TX-последовательность подключается после замораживания оценок
и служит только для подсчёта ошибок и диагностических метрик. Переданные границы burst,
унаследованные от Lab056, означают, что это анализ качества текущего приёмного тракта при
известной сегментации, а не независимый поиск пакета в трёхсекундном потоке.
**Oracle diagnostic.** Известная полная TX PRBS разрешена для физической диагностики.
Timing выбирается на заранее фиксированной сетке из 33 точек от −0,5 до +0,5 символа.
Сеанс делится на 64 последовательные временные плитки: чётные плитки оценивают timing,
CFO и комплексный `h`, нечётные являются отложенной проверкой. По восемь символов у
каждой границы плитки заранее исключаются из обеих выборок, чтобы опоры RRC и sinc не
пересекали train/validation. Oracle matched filter оценивает постоянную составляющую
только по сырым отсчётам защищённых чётных плиток; нечётные плитки не участвуют в этой
nuisance-оценке.
Первичные BER, EVM,
matched/residual, decision margin и статистика облаков берутся с нечётных плиток.
Oracle не считается результатом штатного receiver.
## Формулы и фиксированные критерии
- `EVM_RMS = sqrt(mean(|r - h*s|^2) / mean(|h*s|^2))` на отложенных символах.
- `matched/residual = 10 log10(Pmatched / Presidual)`; это не объявляется Eb/N0.
- Signed decision margin равен `s * Re(r/h)`; отрицательное значение означает ошибку.
Публикуются median, p10 и p01.
- Eye opening — расстояние между средними действительной оси для `+1` и `−1` после
нормировки `h`. Временная ось задаётся в долях символа. Чувствительность — изменение
opening при offsets −0,10 и +0,10 символа относительно оптимального момента.
- Диагностический ISI-отклик оценивается одним и тем же МНК-базисом taps −5…+5 для A и B;
публикуется `10 log10(sum(|h[k!=0]|^2)/|h[0]|^2)`. Эквалайзер не применяется.
- `H(f)` получается как `Sxy/Sxx` из Hann-сегментов длиной 8192 с 50% overlap.
Используются только частоты внутри RRC-полосы и bins с TX PSD не ниже −20 dB от пика.
Публикуются p95−p05 ripple, max−min, линейный наклон, остаточная фаза после удаления
линейной задержки и variation group delay, только если число bins и coherence достаточны.
- A1, B1, A2, B2 считаются независимо; средние A/B строятся только после этого.
## Контроли
1. Сохранённый TX проходит тот же RRC/oracle тракт; ожидается BER = 0 в обоих режимах.
2. Воспроизводимый synthetic AWGN использует seed 5801 и комплексную дисперсию
400 код² на отсчёт при Fs = 8 Мвыб/с и одинаковую среднюю TX-мощность. Инвариант — одна и
та же sampled noise PSD; при росте скорости и падении энергии символа метрики должны
ухудшаться в ожидаемую сторону. Этот контроль не подгоняет реальную запись.
3. Фоновое RX-окно до ABAB проверяется против BPSK-эталона и не должно выглядеть как
качественный BPSK-сигнал.
Результат Lab056 `B/A energy per symbol = −13,870505 dB` используется только как известное
энергетическое сравнение. Lab058 не предполагает заранее AWGN-канал и не объявляет
частотно-зависимое искажение или многолучёвость доказанными без устойчивых оценок.
RF TX = 0. Lab058 до результата не коммитируется. Push не выполняется.

View File

@@ -0,0 +1,105 @@
# Lab058 — качество BPSK-символов в сохранённом ABAB-сеансе Lab056
## Что произошло простыми словами
Передатчик в Lab056 держал почти одинаковую среднюю цифровую мощность для 20 и 500 ксимв/с. При 500 ксимв/с за ту же секунду передавалось в 25 раз больше символов, поэтому на один символ пришлось примерно на 13,87 дБ меньше измеренной энергии. Полная принятая мощность при этом практически не изменилась.
В сохранённой записи отдельные символы 500 ксимв/с действительно различаются хуже: после точного диагностического согласования BER вырос с 0% до 3,4959%, RMS EVM — с 0,317 до 0,845, а matched/residual снизился с 9,97 до 1,46 дБ. Но штатный приёмный тракт ухудшает B намного сильнее — до 52,42% BER. Он оценил остаточный CFO примерно как +4,7 Гц вместо близкого к нулю значения. За burst 0,2048 с эта ошибка поворачивает фазу почти на полный оборот и перемешивает решения.
Установлены два разных эффекта. Физическое качество широкополосного B хуже и совместимо с меньшей энергией символа; кроме того, штатная синхронизация B в этой записи явно не удерживает фазу. Оценка H(f) показывает большее широкополосное изменение амплитуды, но OTA-тракт включает обе платы, кабели, антенны и распространение. По нему нельзя приписать эффект одному HackRF Plus. Абсолютная RF-мощность в dBm, шумовая фигура и истинный Eb/N0 требуют калиброванного измерительного тракта; приведённые значения — цифровые диагностические метрики сохранённых IQ.
## Целостность и границы
| Файл | Размер | SHA-256 | Результат |
|---|---:|---|---|
| `tx_abab.cs8` | 21 177 088 байт | `5b34bbe1a8e6d037d2d6d367a74e9eb84e8940db7da61600ffaa7f7497d4f600` | совпал |
| `rx_stream.cs8` | 48 000 000 байт | `e336dd653fa554058052fcf851e9f2462f66b8cf7c710815ca2b9a09ffc23ad4` | совпал |
Использованы сохранённые RX-start A1/B1/A2/B2: 5 254 791 / 7 709 991 / 10 149 062 / 12 604 263. Исторические файлы Lab054/Lab056 не изменялись. Оборудование не опрашивалось, RF TX = 0.
## Receiver-like и oracle
Receiver-like применяет существующие RRC, timing и carrier estimates. Для оценки ему доступны только 1280 символов штатной преамбулы. Полный TX truth подключается затем лишь для подсчёта ошибок. Границы burst унаследованы от Lab056, поэтому независимый поиск пакета здесь не проверяется.
Oracle использует известный TX для диагностического согласования. 64 временные плитки разделены заранее: чётные оценивают timing/CFO/h, нечётные проверяют результат. По восемь символов с каждой стороны каждой плитки исключены, чтобы опоры RRC и sinc не пересекали train/validation. Постоянная составляющая оценивается только по защищённым чётным участкам raw. Timing выбирается на фиксированной сетке −0,5…+0,5 символа с шагом 1/32. Основные физические метрики относятся к защищённым нечётным плиткам.
| Сегмент | Режим | Ошибки / символы | BER | CFO, Гц | Timing, символа | RMS EVM | Matched/residual, дБ |
|---|---|---:|---:|---:|---:|---:|---:|
| A1 | receiver-like | 1 / 2816 | 0,0355% | +0,0558 | +0,0050 | 0,3164 | +9,9946 |
| A1 | oracle | 0 / 1536 | 0% | −0,0175 | 0 | 0,3146 | +10,0454 |
| B1 | receiver-like | 53110 / 101120 | 52,5218% | +4,6690 | 0 | 1,6279 | −4,2326 |
| B1 | oracle | 1625 / 50688 | 3,2059% | −0,0617 | −0,03125 | 0,8151 | +1,7761 |
| A2 | receiver-like | 0 / 2816 | 0% | −0,1387 | 0 | 0,3321 | +9,5741 |
| A2 | oracle | 0 / 1536 | 0% | +0,0072 | 0 | 0,3202 | +9,8916 |
| B2 | receiver-like | 52898 / 101120 | 52,3121% | +4,7422 | −0,0625 | 1,6786 | −4,4991 |
| B2 | oracle | 1919 / 50688 | 3,7859% | −0,0187 | −0,03125 | 0,8756 | +1,1536 |
Средний receiver-like BER A/B равен 0,0178% / 52,4169%; средний oracle BER — 0% / 3,4959%. Разрыв receiver-like–oracle у B около 48,92 процентного пункта. Oracle не является packet PASS и не меняет исторический результат Lab054.
## Решения, EVM и margin
Использована модель `r = h*s + e` и формула `EVM_RMS = sqrt(mean(|e|²) / mean(|h*s|²))`. Matched/residual — отношение мощности модельной компоненты к остатку, содержащему шум, ISI и ошибки модели; это не Eb/N0.
| Метрика oracle | A1 | A2 | A mean | B1 | B2 | B mean | B−A |
|---|---:|---:|---:|---:|---:|---:|---:|
| BER | 0% | 0% | 0% | 3,2059% | 3,7859% | 3,4959% | +3,4959 п.п. |
| RMS EVM | 0,3146 | 0,3202 | 0,3174 | 0,8151 | 0,8756 | 0,8453 | +0,5280 |
| Matched/residual, дБ | 10,0454 | 9,8916 | 9,9685 | 1,7761 | 1,1536 | 1,4648 | −8,5036 |
| Margin median | 1,0278 | 1,0233 | 1,0256 | 1,0383 | 1,0295 | 1,0339 | +0,0084 |
| Margin p10 | 0,8134 | 0,7810 | 0,7972 | 0,4456 | 0,4080 | 0,4268 | −0,3704 |
| Margin p01 | 0,4432 | 0,4030 | 0,4231 | −0,7474 | −0,8916 | −0,8195 | −1,2426 |
| Eye opening | 2,0110 | 1,9848 | 1,9979 | 2,0221 | 1,9648 | 1,9935 | −0,0044 |
| ISI ratio, дБ | −34,4848 | −32,1542 | −33,3195 | −30,2664 | −29,9349 | −30,1006 | +3,2189 |
| H(f) ripple p95−p05, дБ | 0,4274 | 0,3860 | 0,4067 | 4,5321 | 6,4107 | 5,4714 | +5,0647 |
Центры облаков остаются около ±1, поэтому median margin и среднее eye opening почти одинаковы. У B намного шире хвосты: p01 становится отрицательным, что непосредственно соответствует ошибочным решениям. Разделение центров относительно pooled standard deviation снижается с 12,07σ в среднем у A до 3,65σ у B.
![Созвездия A/B](../data/processed/lab058/constellation_a_vs_b.png)
![BER, EVM и matched/residual](../data/processed/lab058/symbol_quality_comparison.png)
## Eye и чувствительность timing
Время нормировано в долях символа, поэтому 400 и 16 отсчётов/символ сравниваются в общей шкале. Среднее падение opening при −0,1/+0,1 символа составляет около 1,98%/1,87% у A и 1,72%/1,78% у B. Сильного дополнительного закрытия eye именно от малого timing offset не видно. У B основная проблема проявляется в разбросе точек и фазе, а не в среднем расстоянии между центрами.
![Eye A](../data/processed/lab058/eye_a.png)
![Eye B](../data/processed/lab058/eye_b.png)
## H(f) и диагностический ISI
H(f) рассчитана как усреднённое `Sxy/Sxx` по Hann-окнам 8192 отсчёта с 50% overlap. Сохранены только bins внутри RRC-полосы и не ниже −20 дБ по TX PSD. У A осталось 27 bins с median coherence 0,903; у B — 516 bins с median coherence 0,551. Робастный p95−p05 amplitude ripple равен 0,407 дБ для A и 5,471 дБ для B. Полный max−min равен 0,791 и 22,962 дБ, но эта крайняя величина чувствительна к отдельным bins, поэтому основным показателем выбран p95−p05. Остаточная phase ripple p95−p05 составляет 0,042 и 0,306 rad. Производная фазы оказалась недостаточно устойчивой по заранее заданному критерию, поэтому variation group delay не объявляется результатом.
Эти данные показывают широкополосную неравномерность всего OTA-тракта B, но средняя coherence B умеренная и тракт не разделён на TX, propagation и RX. Это свидетельство дополнительного частотно-зависимого эффекта, а не доказательство многолучёвости или аппаратной потери HackRF.
Фиксированный МНК-отклик −5…+5 символов дал adjacent/main −33,32 дБ у A и −30,10 дБ у B. ISI у B выше на 3,22 дБ, но абсолютная сумма соседних taps остаётся примерно на 30 дБ ниже главного. Большой адаптивный equalizer не применялся.
![Диагностическая H(f)](../data/processed/lab058/channel_transfer.png)
![Диагностические ISI taps](../data/processed/lab058/diagnostic_isi.png)
## Контроли и энергетическое сравнение
- Сохранённый TX после того же RRC/oracle тракта дал BER 0 для A и B. Остаток цифрового TX-контроля RMS EVM равен 0,01105 / 0,00998; он включает конечные RRC/transient/model effects и не приписывается исключительно int8-квантованию.
- Synthetic AWGN использовал seed 5801, одинаковые Fs, TX RMS и комплексную дисперсию шума 400 код²/отсчёт. BER остался 0, но EVM ухудшился от 0,02503 до 0,11406, то есть на 13,17 дБ; matched/residual снизился с 32,03 до 18,86 дБ. Направление и масштаб совместимы с падением энергии символа −13,870505 дБ. Это контроль формул, не модель, подогнанная к OTA, и не доказательство AWGN-природы реального канала.
- Фоновое окно до ABAB дало receiver-like/oracle BER 50,31% / 50,35% и oracle training coherence `4,50e−5`: оно не выглядит как качественный BPSK.
В реальной записи ухудшение EVM B/A равно 8,51 дБ, меньше энергетических 13,87 дБ, поскольку A уже имеет собственный остаточный floor. По одной OTA-записи нельзя разложить остаток на шум и искажения единственным способом.
## Повторяемость и вывод
A1/A2 устойчивы по главным метрикам: BER 0/0, EVM различается на 0,0056 (0,15 дБ), matched/residual — на 0,15 дБ, eye opening — на 0,114 дБ. Диагностический ISI ratio расходится на 2,33 дБ при малом абсолютном уровне соседних taps.
B1/B2 повторяют основной эффект: oracle BER 3,2059% / 3,7859%, EVM отличается на 0,0606 (0,62 дБ), matched/residual — на 0,62 дБ, eye opening — на 0,25 дБ, ISI ratio — на 0,331 дБ. Ripple H(f) расходится на 1,88 дБ, что ограничивает точность его единственной OTA-оценки, но оба повтора намного менее плоские, чем A.
Основная классификация — **MIXED**:
1. Уменьшение энергии символа совместимо с ухудшением EVM и matched/residual B.
2. В широком B-диапазоне видна дополнительная неравномерность всего OTA-тракта, однако её источник и точная доля не локализованы.
3. Самая большая практическая потеря возникает в текущей carrier synchronization: receiver-like B почти случаен, тогда как oracle B сохраняет BER 3,5%.
Следующий один этап рекомендуется сделать полностью офлайн: проверить исправленный preamble-only CFO/carrier estimator на сохранённых Lab056 A1/B1/A2/B2, Lab054 и TX/AWGN/background controls, требуя приблизить receiver-like B к oracle без регрессии A и без использования payload truth в оценивателе. Lab059 в этой работе не создаётся.
Машинные данные: [summary.json](../data/processed/lab058/summary.json), [segment_metrics.csv](../data/processed/lab058/segment_metrics.csv), [channel_response.csv](../data/processed/lab058/channel_response.csv).
Исторический Lab054 остаётся `NOT_A, 0/16`. RF TX = 0. Lab058 не закоммичена, push не выполнялся.

View File

@@ -0,0 +1,549 @@
"""Lab058: офлайн-анализ качества BPSK-символов записи Lab056."""
from __future__ import annotations
import csv
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 matplotlib.pyplot as plt
import numpy as np
from scipy import signal
from experiments import lab043_pluto_to_rtlsdr as shaping
from experiments import lab057_symbol_diagnosis as lab057
PROJECT_ROOT = Path(__file__).resolve().parents[1]
RAW = PROJECT_ROOT / "data/raw/lab056"
LAB056 = PROJECT_ROOT / "data/processed/lab056"
OUTPUT = PROJECT_ROOT / "data/processed/lab058"
TX_PATH = RAW / "tx_abab.cs8"
RX_PATH = RAW / "rx_stream.cs8"
TX_SHA256 = "5b34bbe1a8e6d037d2d6d367a74e9eb84e8940db7da61600ffaa7f7497d4f600"
RX_SHA256 = "e336dd653fa554058052fcf851e9f2462f66b8cf7c710815ca2b9a09ffc23ad4"
SAMPLE_RATE = 8_000_000
CARRIER_HZ = 50_000
TILE_COUNT = 64
TIMING_GRID = np.linspace(-0.5, 0.5, 33)
ISI_OFFSETS = np.arange(-5, 6)
AWGN_SEED = 5801
AWGN_COMPLEX_VARIANCE = 400.0
EXPECTED_ENERGY_DIFFERENCE_DB = -13.870505
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
class Cs8File:
"""Read-only interleaved signed-I/Q file."""
def __init__(self, path: Path):
self.path = path
self.raw = np.memmap(path, np.int8, mode="r")
if self.raw.size % 2:
raise ValueError(f"Нечётный размер cs8: {path}")
self.size = self.raw.size // 2
def read(self, start: int, count: int) -> np.ndarray:
if start < 0 or count < 0 or start + count > self.size:
raise ValueError(f"Окно вне {self.path}: {start}+{count}>{self.size}")
values = self.raw[2 * start:2 * (start + count)]
return values[::2].astype(np.float32) + 1j * values[1::2].astype(np.float32)
def expected_symbols(symbol_rate: int) -> np.ndarray:
count = 4096 * (symbol_rate // 20_000)
bits = shaping.pilot_prbs7(count)
if not np.array_equal(bits[:1280], lab057.PREAMBLE):
raise AssertionError("PRBS Lab056 не совпала со штатной преамбулой")
return 2.0 * bits.astype(np.float64) - 1.0
def tile_ids(count: int) -> np.ndarray:
return np.minimum(np.arange(count, dtype=np.int64) * TILE_COUNT // count, TILE_COUNT - 1)
def training_mask(count: int) -> np.ndarray:
return tile_ids(count) % 2 == 0
def guarded_tile_masks(count: int, margin_symbols: int = 8) -> tuple[np.ndarray, np.ndarray]:
"""Return disjoint even/odd tile interiors outside FIR/interpolation support."""
train = np.zeros(count, dtype=bool)
validation = np.zeros(count, dtype=bool)
for identifier in range(TILE_COUNT):
low = (identifier * count + TILE_COUNT - 1) // TILE_COUNT + margin_symbols
high = ((identifier + 1) * count + TILE_COUNT - 1) // TILE_COUNT - margin_symbols
if low >= high:
raise ValueError("Плитка слишком коротка для защитного края")
(train if identifier % 2 == 0 else validation)[low:high] = True
return train, validation
def oracle_matched_filter(values: np.ndarray, taps: np.ndarray,
symbol_rate: int, symbol_count: int) -> np.ndarray:
"""Matched filter whose DC nuisance estimate reads only guarded TRAIN intervals."""
samples_per_symbol = SAMPLE_RATE // symbol_rate
train, _ = guarded_tile_masks(symbol_count)
centers = (16 + 5) * samples_per_symbol + np.arange(symbol_count) * samples_per_symbol
raw_train = np.zeros(len(values), dtype=bool)
for index in np.flatnonzero(train):
raw_train[centers[index]:centers[index] + samples_per_symbol] = True
dc = np.mean(values[raw_train])
indexes = np.arange(len(values))
baseband = (values - dc) * np.exp(-2j * np.pi * CARRIER_HZ * indexes / SAMPLE_RATE)
return signal.oaconvolve(baseband, taps, mode="full")
def fit_cfo_gain(samples: np.ndarray, expected: np.ndarray, indexes: np.ndarray,
symbol_rate: int, total_symbol_count: int | None = None) -> tuple[float, complex, float]:
"""Fit one CFO and h using only the supplied indexes."""
corrected_sign = samples * expected
if total_symbol_count is None:
total_symbol_count = len(expected)
identifiers = np.minimum(indexes * TILE_COUNT // total_symbol_count, TILE_COUNT - 1)
block_values, block_times = [], []
for identifier in np.unique(identifiers):
select = identifiers == identifier
value = np.mean(corrected_sign[select])
if abs(value) > 0:
block_values.append(value)
block_times.append(float(np.mean((indexes[select] + 0.5) / symbol_rate)))
block_values = np.asarray(block_values)
block_times = np.asarray(block_times)
if len(block_times) == 0:
return 0.0, 0j, 0.0
phase = np.unwrap(np.angle(block_values))
weights = np.maximum(abs(block_values), 1e-12)
matrix = np.column_stack((np.ones(len(block_times)), block_times - block_times[0]))
coefficients = np.linalg.lstsq(matrix * weights[:, None], phase * weights, rcond=None)[0]
cfo_hz = float(coefficients[1] / (2 * np.pi))
times = (indexes + 0.5) / symbol_rate
h = complex(np.mean(corrected_sign * np.exp(-2j * np.pi * cfo_hz * times)))
coherence = float(abs(h) ** 2 / max(float(np.mean(abs(samples) ** 2)), 1e-30))
return cfo_hz, h, coherence
def oracle_estimate(matched: np.ndarray, nominal: int, expected: np.ndarray,
symbol_rate: int) -> dict:
samples_per_symbol = SAMPLE_RATE // symbol_rate
train = np.flatnonzero(guarded_tile_masks(len(expected))[0])
stride = max(1, len(train) // 8192)
probe = train[::stride]
candidates = []
for fraction in TIMING_GRID:
positions = nominal + fraction * samples_per_symbol + probe * samples_per_symbol
samples = lab057.interpolate(matched, positions)
cfo_hz, h, coherence = fit_cfo_gain(samples, expected[probe], probe, symbol_rate,
len(expected))
candidates.append((coherence, float(fraction), cfo_hz, h))
_, fraction, _, _ = max(candidates, key=lambda row: row[0])
positions = nominal + fraction * samples_per_symbol + np.arange(len(expected)) * samples_per_symbol
symbols = lab057.interpolate(matched, positions)
train_samples = symbols[train]
cfo_hz, h, coherence = fit_cfo_gain(train_samples, expected[train], train, symbol_rate,
len(expected))
return {"timing_fraction": fraction, "timing_samples": fraction * samples_per_symbol,
"cfo_hz": cfo_hz, "h": h, "train_coherence": coherence,
"symbols": symbols}
def receiver_like(frontend: lab057.Frontend, values: np.ndarray,
expected: np.ndarray) -> dict:
window = frontend.extract(values, len(expected))
if not window.passed:
raise RuntimeError(f"Штатное извлечение не выполнено: {window.reason}")
matched = frontend.filter(values)
start = frontend.nominal + window.timing_offset_samples
symbols = matched[start:start + len(expected) * frontend.samples_per_symbol:
frontend.samples_per_symbol].astype(np.complex128)
carrier = window.carrier
h = carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
return {"timing_fraction": window.timing_offset_samples / frontend.samples_per_symbol,
"timing_samples": float(window.timing_offset_samples),
"cfo_hz": float(carrier["cfo_hz"]), "h": complex(h),
"train_coherence": None, "symbols": symbols, "matched": matched}
def evaluate(estimate: dict, expected: np.ndarray, symbol_rate: int,
selected: np.ndarray) -> dict:
indexes = np.flatnonzero(selected)
symbols = estimate["symbols"][selected]
truth = expected[selected]
times = (indexes + 0.5) / symbol_rate
derotated = symbols * np.exp(-2j * np.pi * estimate["cfo_hz"] * times)
h = estimate["h"]
normalized = derotated / h
predicted = h * truth
residual = derotated - predicted
decisions = normalized.real >= 0
bits = truth > 0
errors = int(np.count_nonzero(decisions != bits))
matched_power = float(np.mean(abs(predicted) ** 2))
residual_power = float(np.mean(abs(residual) ** 2))
plus, minus = normalized.real[truth > 0], normalized.real[truth < 0]
pooled = float(np.sqrt((np.var(plus) + np.var(minus)) / 2))
separation = (float("inf") if pooled == 0 else
float((np.mean(plus) - np.mean(minus)) / pooled))
margin = truth * normalized.real
return {"errors": errors, "symbols": int(len(indexes)), "ber": errors / len(indexes),
"plus_mean": float(np.mean(plus)), "plus_std": float(np.std(plus)),
"minus_mean": float(np.mean(minus)), "minus_std": float(np.std(minus)),
"cloud_separation_sigma": separation,
"evm_rms": float(np.sqrt(residual_power / matched_power)),
"matched_component_power": matched_power, "residual_power": residual_power,
"matched_residual_db": (float("inf") if residual_power == 0 else
float(10 * np.log10(matched_power / residual_power))),
"margin_median": float(np.median(margin)),
"margin_p10": float(np.percentile(margin, 10)),
"margin_p01": float(np.percentile(margin, 1)),
"normalized": normalized, "truth": truth, "indexes": indexes}
def eye_metrics(matched: np.ndarray, nominal: int, estimate: dict, expected: np.ndarray,
symbol_rate: int) -> tuple[dict, np.ndarray, np.ndarray]:
samples_per_symbol = SAMPLE_RATE // symbol_rate
valid = np.flatnonzero(guarded_tile_masks(len(expected))[1])
valid = valid[(valid >= 2) & (valid < len(expected) - 2)]
if len(valid) > 4096:
valid = valid[np.linspace(0, len(valid) - 1, 4096, dtype=int)]
phases = np.linspace(-0.5, 0.5, 41)
openings, traces = [], []
for phase in phases:
positions = (nominal + (estimate["timing_fraction"] + phase) * samples_per_symbol
+ valid * samples_per_symbol)
values = lab057.interpolate(matched, positions)
times = (valid + 0.5 + phase) / symbol_rate
normalized = values * np.exp(-2j * np.pi * estimate["cfo_hz"] * times) / estimate["h"]
truth = expected[valid]
openings.append(float(np.mean(normalized.real[truth > 0]) -
np.mean(normalized.real[truth < 0])))
traces.append(normalized.real)
openings = np.asarray(openings)
center = int(np.argmin(abs(phases)))
left = int(np.argmin(abs(phases + 0.1)))
right = int(np.argmin(abs(phases - 0.1)))
metrics = {"opening": float(openings[center]),
"opening_at_minus_0p1": float(openings[left]),
"opening_at_plus_0p1": float(openings[right]),
"drop_minus_0p1_percent": float(100 * (1 - openings[left] / openings[center])),
"drop_plus_0p1_percent": float(100 * (1 - openings[right] / openings[center]))}
return metrics, phases, np.asarray(traces).T
def isi_metrics(estimate: dict, expected: np.ndarray, symbol_rate: int) -> tuple[dict, np.ndarray]:
indexes = np.arange(5, len(expected) - 5)
train = guarded_tile_masks(len(expected))[0][indexes]
indexes = indexes[train]
if len(indexes) > 50000:
indexes = indexes[np.linspace(0, len(indexes) - 1, 50000, dtype=int)]
times = (indexes + 0.5) / symbol_rate
y = estimate["symbols"][indexes] * np.exp(-2j * np.pi * estimate["cfo_hz"] * times)
matrix = np.column_stack([expected[indexes - offset] for offset in ISI_OFFSETS])
taps = np.linalg.lstsq(matrix, y, rcond=None)[0]
main = float(abs(taps[5]) ** 2)
adjacent = float(np.sum(abs(np.delete(taps, 5)) ** 2))
return {"main_tap_power": main, "adjacent_taps_power": adjacent,
"isi_ratio_db": float(10 * np.log10(adjacent / main)),
"offsets": ISI_OFFSETS.tolist(),
"taps": [{"real": float(x.real), "imag": float(x.imag)} for x in taps]}, taps
def channel_metrics(tx: np.ndarray, rx: np.ndarray, symbol_rate: int,
cfo_hz: float) -> tuple[dict, dict]:
indexes = np.arange(len(tx))
carrier = np.exp(-2j * np.pi * CARRIER_HZ * indexes / SAMPLE_RATE)
x = (tx - np.mean(tx)) * carrier
y = (rx - np.mean(rx)) * carrier * np.exp(-2j * np.pi * cfo_hz * indexes / SAMPLE_RATE)
frequency, pxx = signal.welch(x, fs=SAMPLE_RATE, window="hann", nperseg=8192,
noverlap=4096, return_onesided=False)
_, pyy = signal.welch(y, fs=SAMPLE_RATE, window="hann", nperseg=8192,
noverlap=4096, return_onesided=False)
_, pxy = signal.csd(x, y, fs=SAMPLE_RATE, window="hann", nperseg=8192,
noverlap=4096, return_onesided=False)
order = np.argsort(frequency)
frequency, pxx, pyy, pxy = frequency[order], pxx[order], pyy[order], pxy[order]
transfer = pxy / np.maximum(pxx, 1e-30)
coherence = abs(pxy) ** 2 / np.maximum(pxx * pyy, 1e-30)
bandwidth = symbol_rate * (1 + 0.35) / 2
mask = ((abs(frequency) <= bandwidth) &
(pxx >= np.max(pxx[abs(frequency) <= bandwidth]) * 0.01))
f, h, coh = frequency[mask], transfer[mask], coherence[mask]
gain_db = 20 * np.log10(np.maximum(abs(h), 1e-30))
phase = np.unwrap(np.angle(h))
phase_fit = np.polyfit(f, phase, 1)
phase_residual = phase - np.polyval(phase_fit, f)
group_delay = -np.gradient(phase, f) / (2 * np.pi)
stable = bool(len(f) >= 20 and np.median(coh) >= 0.2)
group_delay_stable = bool(len(f) >= 64 and np.median(coh) >= 0.9)
metrics = {"stable": stable, "bins": int(len(f)),
"median_coherence": float(np.median(coh)),
"amplitude_ripple_p95_p05_db": float(np.percentile(gain_db, 95) - np.percentile(gain_db, 5)),
"max_min_gain_db": float(np.max(gain_db) - np.min(gain_db)),
"gain_slope_db_per_mhz": float(np.polyfit(f / 1e6, gain_db, 1)[0]),
"common_group_delay_seconds": float(-phase_fit[0] / (2 * np.pi)),
"residual_phase_ripple_p95_p05_rad": float(np.percentile(phase_residual, 95) - np.percentile(phase_residual, 5)),
"group_delay_stable": group_delay_stable,
"group_delay_variation_p95_p05_seconds": float(np.percentile(group_delay, 95) - np.percentile(group_delay, 5)) if group_delay_stable else None,
"band_edge_hz": bandwidth, "tx_psd_threshold_db": -20.0}
arrays = {"frequency": f, "gain_db": gain_db, "phase_residual": phase_residual,
"coherence": coh}
return metrics, arrays
def serializable(value):
if isinstance(value, dict):
return {key: serializable(item) for key, item in value.items()
if key not in ("matched", "normalized", "truth", "indexes")}
if isinstance(value, list):
return [serializable(item) for item in value]
if isinstance(value, complex):
return {"real": float(value.real), "imag": float(value.imag)}
if isinstance(value, np.generic):
return value.item()
return value
def analyze_values(values: np.ndarray, expected: np.ndarray, symbol_rate: int) -> tuple[dict, dict]:
frontend = lab057.Frontend(symbol_rate)
receiver = receiver_like(frontend, values, expected)
receiver_select = np.arange(len(expected)) >= 1280
receiver_result = evaluate(receiver, expected, symbol_rate, receiver_select)
oracle_matched = oracle_matched_filter(values, frontend.taps, symbol_rate, len(expected))
oracle = oracle_estimate(oracle_matched, frontend.nominal, expected, symbol_rate)
validation = guarded_tile_masks(len(expected))[1]
oracle_result = evaluate(oracle, expected, symbol_rate, validation)
eye, phases, traces = eye_metrics(oracle_matched, frontend.nominal, oracle,
expected, symbol_rate)
isi, taps = isi_metrics(oracle, expected, symbol_rate)
receiver_public = {key: value for key, value in receiver.items() if key not in ("symbols", "matched")}
oracle_public = {key: value for key, value in oracle.items() if key != "symbols"}
result = {"receiver_like": {**serializable(receiver_public), "metrics": serializable(receiver_result)},
"oracle": {**serializable(oracle_public), "metrics": serializable(oracle_result),
"eye": eye, "isi": isi}}
working = {"frontend": frontend, "receiver": receiver, "oracle": oracle,
"oracle_matched": oracle_matched,
"receiver_result": receiver_result, "oracle_result": oracle_result,
"eye_phases": phases, "eye_traces": traces, "isi_taps": taps}
return result, working
def geometric_db(new: float, old: float) -> float:
return float(20 * np.log10(new / old))
def run_functional_tests() -> None:
"""Быстрые проверки формул без raw IQ и оборудования."""
expected = expected_symbols(20_000)
indexes = np.arange(len(expected))
h = 0.4 + 0.3j
estimate = {"symbols": h * expected, "h": h, "cfo_hz": 0.0}
metrics = evaluate(estimate, expected, 20_000, np.ones(len(expected), bool))
if metrics["errors"] != 0 or metrics["evm_rms"] > 1e-12:
raise AssertionError("Точный BPSK control не прошёл")
isi, _ = isi_metrics(estimate, expected, 20_000)
if isi["adjacent_taps_power"] > isi["main_tap_power"] * 1e-20:
raise AssertionError("Точный одно-tap control ошибочно содержит ISI")
def make_plots(rows: list[dict], work: dict[str, dict]) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
for axis, mode in zip(axes, ("A", "B")):
for label in (mode + "1", mode + "2"):
metric = work[label]["oracle_result"]
values, truth = metric["normalized"], metric["truth"]
select = np.linspace(0, len(values) - 1, min(5000, len(values)), dtype=int)
axis.scatter(values.real[select], values.imag[select], s=2, alpha=.2, label=label)
axis.set(title=f"{mode}: oracle, held-out symbols", xlabel="I / h", ylabel="Q / h", xlim=(-4, 4), ylim=(-4, 4))
axis.grid(True, alpha=.25); axis.legend()
fig.tight_layout(); fig.savefig(OUTPUT / "constellation_a_vs_b.png", dpi=160); plt.close(fig)
for mode in ("A", "B"):
fig, axis = plt.subplots(figsize=(8, 4.5))
for label in (mode + "1", mode + "2"):
phases, traces = work[label]["eye_phases"], work[label]["eye_traces"]
select = np.linspace(0, len(traces) - 1, min(180, len(traces)), dtype=int)
axis.plot(phases, traces[select].T, alpha=.05, color="C0" if label.endswith("1") else "C1")
axis.axvline(0, color="black", lw=.8); axis.axhline(0, color="black", lw=.8)
axis.set(title=f"Eye diagram {mode}, oracle alignment", xlabel="Смещение, доли символа", ylabel="Re(r/h)")
axis.grid(True, alpha=.2); fig.tight_layout(); fig.savefig(OUTPUT / f"eye_{mode.lower()}.png", dpi=160); plt.close(fig)
labels = [row["label"] for row in rows]
figure, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].bar(labels, [100 * row["oracle"]["metrics"]["ber"] for row in rows]); axes[0].set_ylabel("BER, %")
axes[1].bar(labels, [row["oracle"]["metrics"]["evm_rms"] for row in rows]); axes[1].set_ylabel("RMS EVM")
axes[2].bar(labels, [row["oracle"]["metrics"]["matched_residual_db"] for row in rows]); axes[2].set_ylabel("Matched/residual, dB")
for axis in axes: axis.grid(True, axis="y", alpha=.25)
figure.tight_layout(); figure.savefig(OUTPUT / "symbol_quality_comparison.png", dpi=160); plt.close(figure)
fig, axes = plt.subplots(2, 2, figsize=(12, 7), sharex="col")
for column, mode in enumerate(("A", "B")):
for label in (mode + "1", mode + "2"):
arrays = work[label]["channel_arrays"]
axes[0, column].plot(arrays["frequency"] / 1000, arrays["gain_db"], alpha=.8, label=label)
axes[1, column].plot(arrays["frequency"] / 1000, arrays["phase_residual"], alpha=.8, label=label)
axes[0, column].set(title=f"{mode}: усреднённая H(f)", ylabel="|H(f)|, dB")
axes[1, column].set(xlabel="Частота baseband, кГц", ylabel="Остаточная фаза, rad")
for row in range(2): axes[row, column].grid(True, alpha=.25); axes[row, column].legend()
fig.tight_layout(); fig.savefig(OUTPUT / "channel_transfer.png", dpi=160); plt.close(fig)
fig, axes = plt.subplots(2, 2, figsize=(10, 7), sharex=True)
for axis, label in zip(axes.flat, labels):
taps = work[label]["isi_taps"]
axis.stem(ISI_OFFSETS, 20 * np.log10(np.maximum(abs(taps / taps[5]), 1e-8)), basefmt=" ")
axis.set(title=label, ylabel="Tap/main, dB"); axis.grid(True, alpha=.25)
axes[1, 0].set_xlabel("Смещение, символы"); axes[1, 1].set_xlabel("Смещение, символы")
fig.tight_layout(); fig.savefig(OUTPUT / "diagnostic_isi.png", dpi=160); plt.close(fig)
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
metadata = json.loads((RAW / "tx_metadata.json").read_text(encoding="utf-8"))
analysis = json.loads((LAB056 / "rf_analysis.json").read_text(encoding="utf-8"))
integrity = {"tx": {"path": str(TX_PATH.relative_to(PROJECT_ROOT)), "bytes": TX_PATH.stat().st_size,
"sha256": sha256(TX_PATH), "expected_sha256": TX_SHA256},
"rx": {"path": str(RX_PATH.relative_to(PROJECT_ROOT)), "bytes": RX_PATH.stat().st_size,
"sha256": sha256(RX_PATH), "expected_sha256": RX_SHA256}}
if integrity["tx"]["sha256"] != TX_SHA256 or integrity["rx"]["sha256"] != RX_SHA256:
raise RuntimeError("SHA-256 исходных данных Lab056 не совпал")
tx_file, rx_file = Cs8File(TX_PATH), Cs8File(RX_PATH)
measured = {row["label"]: row for row in analysis["segments"]}
rows, work = [], {}
for segment in metadata["segments"]:
label, rate = segment["label"], segment["symbol_rate"]
expected = expected_symbols(rate)
tx = tx_file.read(segment["start"], segment["samples"])
rx_start = measured[label]["measurement"]["start"]
rx = rx_file.read(rx_start, segment["samples"])
result, working = analyze_values(rx, expected, rate)
trim = 21 * (SAMPLE_RATE // rate)
channel, arrays = channel_metrics(tx[trim:-trim], rx[trim:-trim], rate,
result["oracle"]["cfo_hz"])
result["oracle"]["channel"] = channel
row = {"label": label, "symbol_rate": rate, "samples_per_symbol": SAMPLE_RATE // rate,
"rx_start": rx_start, **result}
rows.append(row); working["channel_arrays"] = arrays; work[label] = working
controls = {"tx": {}, "awgn": {}}
for rate, label in ((20_000, "A"), (500_000, "B")):
segment = next(item for item in metadata["segments"] if item["label"] == label + "1")
tx = tx_file.read(segment["start"], segment["samples"])
expected = expected_symbols(rate)
tx_result, _ = analyze_values(tx, expected, rate)
controls["tx"][label] = tx_result
random_generator = np.random.default_rng(AWGN_SEED)
noise = np.sqrt(AWGN_COMPLEX_VARIANCE / 2) * (
random_generator.normal(size=len(tx)) + 1j * random_generator.normal(size=len(tx)))
awgn_result, _ = analyze_values((tx + noise).astype(np.complex64), expected, rate)
controls["awgn"][label] = awgn_result
controls["awgn"].update({"seed": AWGN_SEED, "complex_noise_variance_per_sample": AWGN_COMPLEX_VARIANCE,
"invariant": "same Fs, TX RMS and sampled complex-noise variance"})
background_start = analysis["alignment"]["start"] - metadata["segments"][1]["samples"] - 100_000
background_values = rx_file.read(background_start, metadata["segments"][1]["samples"])
background_result, _ = analyze_values(background_values, expected_symbols(500_000), 500_000)
controls["background"] = {"rx_start": background_start, **background_result}
if any(controls["tx"][mode]["oracle"]["metrics"]["ber"] != 0 for mode in ("A", "B")):
raise RuntimeError("TX waveform control BER не равен нулю")
if not (controls["awgn"]["B"]["oracle"]["metrics"]["evm_rms"] >
controls["awgn"]["A"]["oracle"]["metrics"]["evm_rms"]):
raise RuntimeError("AWGN control не показал ожидаемого ухудшения EVM")
background_oracle = controls["background"]["oracle"]
if not (0.4 < background_oracle["metrics"]["ber"] < 0.6 and
background_oracle["train_coherence"] < 0.01):
raise RuntimeError("Background ошибочно выглядит как качественный BPSK")
def mean_metric(mode: str, path: tuple[str, ...]) -> float:
values = []
for row in rows:
if row["label"].startswith(mode):
value = row
for key in path: value = value[key]
values.append(value)
return float(np.mean(values))
primary_paths = {
"symbol_ber": ("oracle", "metrics", "ber"), "evm_rms": ("oracle", "metrics", "evm_rms"),
"matched_residual_db": ("oracle", "metrics", "matched_residual_db"),
"decision_margin_median": ("oracle", "metrics", "margin_median"),
"eye_opening": ("oracle", "eye", "opening"), "isi_ratio_db": ("oracle", "isi", "isi_ratio_db"),
"channel_ripple_db": ("oracle", "channel", "amplitude_ripple_p95_p05_db")}
comparison = {}
for name, path in primary_paths.items():
a, b = mean_metric("A", path), mean_metric("B", path)
comparison[name] = {"a_mean": a, "b_mean": b, "b_minus_a": b - a}
repeatability = {}
for mode in ("A", "B"):
first = next(row for row in rows if row["label"] == mode + "1")
second = next(row for row in rows if row["label"] == mode + "2")
repeatability[mode] = {}
for name, path in primary_paths.items():
one, two = first, second
for key in path: one = one[key]; two = two[key]
entry = {"absolute_second_minus_first": float(two - one),
"relative_second_minus_first": None if one == 0 else float((two - one) / abs(one))}
if name in ("evm_rms", "eye_opening") and one > 0 and two > 0:
entry["difference_db"] = geometric_db(two, one)
elif name == "symbol_ber" and one > 0 and two > 0:
entry["difference_db"] = float(10 * np.log10(two / one))
elif name.endswith("_db"):
entry["difference_db"] = float(two - one)
else: entry["difference_db"] = None
repeatability[mode][name] = entry
summary = {"lab": "Lab058", "status": "COMPLETED", "scope": "OFFLINE_SAVED_LAB056_ONLY",
"rf_tx": 0, "hardware_access": 0, "integrity": integrity,
"method": {"tiles": TILE_COUNT, "training_tiles": "even", "validation_tiles": "odd",
"guard_symbols_each_tile_edge": 8,
"oracle_dc_source": "guarded_even_training_raw_intervals_only",
"timing_grid_symbol": TIMING_GRID.tolist(), "isi_offsets": ISI_OFFSETS.tolist()},
"segments": serializable(rows), "comparison": comparison,
"repeatability": repeatability, "controls": serializable(controls),
"known_energy_per_symbol_difference_db": EXPECTED_ENERGY_DIFFERENCE_DB,
"lab054_historical_result": "NOT_A, 0/16", "lab058_commit": "NOT_PERFORMED",
"push": "NOT_PERFORMED"}
(OUTPUT / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
flat_rows = []
for row in rows:
for mode in ("receiver_like", "oracle"):
metric = row[mode]["metrics"]
flat_rows.append({"label": row["label"], "symbol_rate": row["symbol_rate"], "analysis_mode": mode,
**{key: metric[key] for key in ("errors", "symbols", "ber", "plus_mean", "plus_std",
"minus_mean", "minus_std", "cloud_separation_sigma", "evm_rms",
"matched_component_power", "residual_power", "matched_residual_db",
"margin_median", "margin_p10", "margin_p01")},
"timing_fraction": row[mode]["timing_fraction"], "cfo_hz": row[mode]["cfo_hz"],
"eye_opening": row.get("oracle", {}).get("eye", {}).get("opening") if mode == "oracle" else None,
"isi_ratio_db": row.get("oracle", {}).get("isi", {}).get("isi_ratio_db") if mode == "oracle" else None,
"channel_ripple_db": row.get("oracle", {}).get("channel", {}).get("amplitude_ripple_p95_p05_db") if mode == "oracle" else None})
with (OUTPUT / "segment_metrics.csv").open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=list(flat_rows[0])); writer.writeheader(); writer.writerows(flat_rows)
with (OUTPUT / "channel_response.csv").open("w", newline="", encoding="utf-8") as stream:
writer = csv.writer(stream); writer.writerow(["label", "frequency_hz", "gain_db", "phase_residual_rad", "coherence"])
for label, item in work.items():
arrays = item["channel_arrays"]
writer.writerows(zip([label] * len(arrays["frequency"]), arrays["frequency"], arrays["gain_db"], arrays["phase_residual"], arrays["coherence"]))
make_plots(rows, work)
print(json.dumps({"status": summary["status"], "comparison": comparison,
"receiver_ber": {r["label"]: r["receiver_like"]["metrics"]["ber"] for r in rows},
"oracle_ber": {r["label"]: r["oracle"]["metrics"]["ber"] for r in rows},
"controls": {"tx_ber": {k: v["oracle"]["metrics"]["ber"] for k, v in controls["tx"].items()},
"awgn_ber": {k: v["oracle"]["metrics"]["ber"] for k, v in controls["awgn"].items() if isinstance(v, dict)},
"background_ber": controls["background"]["oracle"]["metrics"]["ber"]}}, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,100 @@
"""Быстрые тесты диагностических примитивов Lab058."""
from __future__ import annotations
import numpy as np
import pytest
from experiments import lab058_abab_symbol_quality as lab058
def test_training_and_validation_tiles_are_disjoint_and_complete():
for count in (4096, 102400):
train = lab058.training_mask(count)
assert train.dtype == bool and train.size == count
assert np.all((lab058.tile_ids(count)[train] % 2) == 0)
assert np.all((lab058.tile_ids(count)[~train] % 2) == 1)
def test_guarded_tiles_are_disjoint_and_exclude_boundaries():
for count in (4096, 102400):
train, validation = lab058.guarded_tile_masks(count)
assert not np.any(train & validation)
assert train.sum() == validation.sum() == count // 2 - 16 * 32
@pytest.mark.parametrize("symbol_rate", [20_000, 500_000])
def test_cfo_gain_fit_recovers_known_symbol_model(symbol_rate):
expected = lab058.expected_symbols(symbol_rate)[:4096]
indexes = np.arange(len(expected))
cfo = 1.75 * symbol_rate / 20_000
h = .7 - .25j
samples = h * expected * np.exp(2j * np.pi * cfo * (indexes + .5) / symbol_rate)
found_cfo, found_h, coherence = lab058.fit_cfo_gain(samples, expected, indexes, symbol_rate)
assert found_cfo == pytest.approx(cfo, abs=1e-8)
assert found_h == pytest.approx(h, abs=1e-10)
assert coherence == pytest.approx(1.0)
def test_cfo_gain_subset_keeps_original_tile_time_axis():
symbol_rate = 500_000
full = lab058.expected_symbols(symbol_rate)
indexes = np.flatnonzero(lab058.training_mask(len(full)))[::31]
cfo, h = 3.25, .4 + .2j
samples = h * full[indexes] * np.exp(2j * np.pi * cfo * (indexes + .5) / symbol_rate)
found, gain, _ = lab058.fit_cfo_gain(samples, full[indexes], indexes, symbol_rate, len(full))
assert found == pytest.approx(cfo, abs=1e-8)
assert gain == pytest.approx(h, abs=1e-9)
def test_oracle_estimate_does_not_read_guarded_validation_symbols():
symbol_rate = 500_000
expected = lab058.expected_symbols(symbol_rate)[:4096]
samples_per_symbol = lab058.SAMPLE_RATE // symbol_rate
values = lab058.shaping.shape_frame_like_lab042(expected, samples_per_symbol)
values *= np.exp(2j * np.pi * lab058.CARRIER_HZ * np.arange(len(values)) / lab058.SAMPLE_RATE)
changed = values.copy()
validation = np.flatnonzero(lab058.guarded_tile_masks(len(expected))[1])
centers = 21 * samples_per_symbol + validation * samples_per_symbol
for center in centers:
changed[center:center + samples_per_symbol] += 3 - 2j
frontend = lab058.lab057.Frontend(symbol_rate)
first_matched = lab058.oracle_matched_filter(values, frontend.taps, symbol_rate, len(expected))
second_matched = lab058.oracle_matched_filter(changed, frontend.taps, symbol_rate, len(expected))
first = lab058.oracle_estimate(first_matched, frontend.nominal, expected, symbol_rate)
second = lab058.oracle_estimate(second_matched, frontend.nominal, expected, symbol_rate)
assert first["timing_fraction"] == second["timing_fraction"]
assert first["cfo_hz"] == pytest.approx(second["cfo_hz"], abs=1e-5)
assert first["h"] == pytest.approx(second["h"], abs=1e-7)
assert first["train_coherence"] == pytest.approx(second["train_coherence"], abs=1e-8)
def test_evm_and_margin_formula_on_exact_symbols():
expected = lab058.expected_symbols(20_000)
indexes = np.arange(len(expected))
h = .4 + .3j
estimate = {"symbols": h * expected, "h": h, "cfo_hz": 0.0}
result = lab058.evaluate(estimate, expected, 20_000, np.ones(len(expected), bool))
assert result["errors"] == 0 and result["ber"] == 0
assert result["evm_rms"] < 1e-14
assert result["margin_median"] == pytest.approx(1.0)
def test_isi_fit_localizes_single_main_tap():
expected = lab058.expected_symbols(20_000)
h = .2 - .1j
estimate = {"symbols": h * expected, "h": h, "cfo_hz": 0.0}
result, taps = lab058.isi_metrics(estimate, expected, 20_000)
assert abs(taps[5] - h) < 1e-12
assert result["adjacent_taps_power"] < result["main_tap_power"] * 1e-20
def test_averaged_transfer_estimate_is_flat_for_flat_synthetic_channel():
random_generator = np.random.default_rng(5802)
count = 131072
tx = (random_generator.normal(size=count) + 1j * random_generator.normal(size=count)).astype(np.complex64)
gain = .3 - .2j
result, _ = lab058.channel_metrics(tx, gain * tx, 500_000, 0.0)
assert result["stable"]
assert result["median_coherence"] > .999
assert result["amplitude_ripple_p95_p05_db"] < .01