Lab058: characterize symbol quality in controlled ABAB recording
This commit is contained in:
@@ -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 автоматически.
|
||||
|
||||
1087
data/processed/lab058/channel_response.csv
Normal file
1087
data/processed/lab058/channel_response.csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
data/processed/lab058/channel_transfer.png
Normal file
BIN
data/processed/lab058/channel_transfer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
data/processed/lab058/constellation_a_vs_b.png
Normal file
BIN
data/processed/lab058/constellation_a_vs_b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
BIN
data/processed/lab058/diagnostic_isi.png
Normal file
BIN
data/processed/lab058/diagnostic_isi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
data/processed/lab058/eye_a.png
Normal file
BIN
data/processed/lab058/eye_a.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 500 KiB |
BIN
data/processed/lab058/eye_b.png
Normal file
BIN
data/processed/lab058/eye_b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
9
data/processed/lab058/segment_metrics.csv
Normal file
9
data/processed/lab058/segment_metrics.csv
Normal 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
|
||||
|
1420
data/processed/lab058/summary.json
Normal file
1420
data/processed/lab058/summary.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
data/processed/lab058/symbol_quality_comparison.png
Normal file
BIN
data/processed/lab058/symbol_quality_comparison.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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 не выполнялся.
|
||||
|
||||
82
docs/lab058_current_state.md
Normal file
82
docs/lab058_current_state.md
Normal 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 не выполняется.
|
||||
105
docs/lab058_offline_result.md
Normal file
105
docs/lab058_offline_result.md
Normal 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Eye и чувствительность timing
|
||||
|
||||
Время нормировано в долях символа, поэтому 400 и 16 отсчётов/символ сравниваются в общей шкале. Среднее падение opening при −0,1/+0,1 символа составляет около 1,98%/1,87% у A и 1,72%/1,78% у B. Сильного дополнительного закрытия eye именно от малого timing offset не видно. У B основная проблема проявляется в разбросе точек и фазе, а не в среднем расстоянии между центрами.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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 не применялся.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Контроли и энергетическое сравнение
|
||||
|
||||
- Сохранённый 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 не выполнялся.
|
||||
549
experiments/lab058_abab_symbol_quality.py
Normal file
549
experiments/lab058_abab_symbol_quality.py
Normal 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()
|
||||
100
tests/test_lab058_symbol_quality.py
Normal file
100
tests/test_lab058_symbol_quality.py
Normal 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
|
||||
Reference in New Issue
Block a user