Lab051: accelerate 500 ksymbol/s streaming receiver

This commit is contained in:
LittleSam129
2026-09-08 16:04:09 +03:00
parent 60691a09f1
commit 9f33607d3a
13 changed files with 9695 additions and 0 deletions

View File

@@ -1754,3 +1754,108 @@ Lab050 git add/commit и push/pull/fetch не выполнялись.
и ускорение строгой проверки ложных кандидатов на C/500k с сохранением
независимости RX и FEC/CRC. До доказанной скорости и свежести не выполнять
RF, QPSK, новый видеокодек или перенос SDR на 180 МГц.
# Запись 040
## Дата
8 сентября 2026 года
## Тема
Lab051 — алгоритмическое ускорение потокового BPSK-приёмника на 500 ксимв/с
и проверка свежести JPEG при измеренной стоимости обработки
## Выполнено
Перед новой разработкой проверены HEAD, status, diff stat и diff check.
По прямому разрешению текущего задания Lab050 закреплена коммитом
60691a09f193f36983c7174c5252ed0897c559e9:
Lab050: model fresh-frame SDR video stream.
В показанный staged scope вошли девять файлов: код и тесты Lab050, необходимые
изменения прежних модулей, отчёт, журнал, индекс и небольшой receiver_benchmarks.json.
Большой result.json, JPEG-профили и посторонние файлы не добавлялись. Push не выполнялся.
Lab051 начата от нового HEAD. Аудит прочитал Lab046B/C, Lab047/048/050,
связанные функции Lab043/044/045 и convolutional_fec. Рабочая точка строго
500000 символов/с, Fs=8000000, 16 отсчётов/символ; профиль C 128×96 RGB q25,
1740–1893 байта. Управление ELRS/CRSF и локальная защита остаются отдельно.
Ни один файл protocol в Lab051 не менялся.
Воспроизведён прежний короткий шумный опыт Lab050: три JPEG, 24 пакета,
seed50050, coded-bit Eb/N0=7 дБ, CFO+10 Гц, фаза0,73 и чередование полярности.
Историческая стоимость 55,157553 с / 0,323745 с IQ =170,373647.
Свежий инструментированный baseline занял49,614327 с, отношение153,251430;
это отдельный замер, с влиянием настольной нагрузки/инструментации.
Обнаружены2374 кандидата:24 valid и2350 false; средние72,967/20,099 мс.
Главная стоимость ложных кандидатов — timing:41,833 с из47,232 с.
При nominal416 и диапазоне±800 прежняя функция возвращалась к Python-перебору
1601 смещения. Matched filter на ложных кандидатах занимал0,934 с, уточнение2,797 с.
Ранний отказ PHY header уже существовал и не запускал тело/Viterbi/CRC.
Добавлен отдельный вычислительный адаптер Lab051. Повторно используется внутренняя
часть децимации с точным совпадением отсчётов; пересчитываются новые края.
Общая корреляция уточнения и общий перенос/RRC обслуживают срезы кандидатов.
Сохранены локальный DC и нулевые края. Результат извлечения кэшируется по
уточнённому положению/длине в пределах блока. Timing вычисляет все прежние1601
смещения через корреляцию и накопленные энергии, без сужения диапазона.
Для неразделимых FFT-пиков и постоянного IQ сохранён исходный выбор первого максимума.
Viterbi использует предвычисленные четыре пары евклидовых метрик и сравнение
двух ветвей с прежним tie→branch0. K7,R1/2,171/133,tail6,интерливер,CRC и
результаты декодирования сохранены. CFO-оценка прежняя; кэшируются векторы переноса.
Новый порог отсева, GPU, multiprocessing и зависимости не добавлены.
Фиксированный порог кандидатов0.030964473262429237 не изменялся.
Финальный короткий контроль:24/24 пакета,3/3 JPEG, SHA MATCH; совпали число
кандидатов и все причины отказов. Медиана трёх проходов1,144176 с,
processing/RF3,534193 — ускорение48,21 раза относительно исторической базы.
Средний valid candidate17,7675 мс, false0,187317 мс; false-медиана0,0605 мс.
Полная стоимость с ложными кандидатами/фоном47,674 мс на физический пакет.
Основной опыт:30 изображений, вход5/с, окно6 с, LATEST_ONLY, MAX_VIDEO_AGE200 мс.
Реально поданы6,044416 с синтетического IQ блоками; receiver не получал TX-границы,
payload или ожидаемый счётчик. Все240/240 пакетов и30/30 JPEG восстановлены точно.
Фактическая стоимость каждого push, сборки и JPEG decode использована в событийной
шкале CPU. Суммарно38,630132 с, processing/RF6,391045,160,959 мс/пакет.
Получены147082 кандидата,12239 новых окон синхронизации и135057 cache hits.
Ни один JPEG не уложился в200 мс: DISPLAYED=FRESH DISPLAYED=0,FRESH DISPLAY FPS=0.
Цель5 свежих/с и минимальная цель>0 НЕ достигнуты.
Переданы30 изображений, до TX удалено0, после decode по возрасту удалено30.
Возраст декодированных/отброшенных JPEG min/median/max:
276,515/15691,203/31150,009 мс. Это не измерение камеры/экрана: их задержки UNKNOWN.
Capture lookup используется только после независимого JPEG для отчётной метрики;
в прежнем fragment format нет capture timestamp. Live-контроль возраста не доказан.
Видеочередь≤1, однако непрерывный IQ-источник потребовал бы внешнего backlog
2088473848 байт при отставании32,632404 с. Такой буфер не реализован и не скрыт.
REAL-TIME CAPABLE=NO. Поток воспроизводится офлайн с обратным давлением источнику.
Пик удерживаемого IQ3177728 байт(3,031 МиБ), проверяемый предел3398912 байт.
Инженерная оценка рабочих массивов136,161 МиБ; это не универсальный предел и не RSS.
RSS процесса после потока154,105 МиБ, peak178,004 МиБ, включая Python/генератор/кэши.
Длинный тест4096 блоков(33,554432 с,нулевой фон) не показал роста IQ и децимации:
2653440 и397216 байт после прогрева. RSS156,199–156,250 МиБ. Finish освобождает буферы.
В отдельном replay посчитана нижняя граница наблюдаемых ndarray/владельцев и
tracemalloc peak. Внутренние C-аллокации не объявляются полностью измеренными;
холодные общие массивы могут занимать больше памяти, чем отдельное старое окно.
Сохранённая Lab047-запись20 ксимв/с повторно разобрана штатным Lab048:
36/36,FEC/CRC PASS,JPEG SHA MATCH,19 ошибок до FEC и0 после. Исходные IQ/JPEG сохранены.
Новый адаптер рассчитан только на500 ксимв/с; аппаратных запусков не было.
38 целевых тестов PASS. Verifier независимо нашёл два FFT tie-расхождения;
оба исправлены и повторно воспроизведены с совпадением старого результата.
Adversary проверил malformed PHY без body/Viterbi, дубликаты/старые ID,
лимиты объекта, усечение, восстановление подмен и арифметику возраста/backlog.
Таблицы отчёта сверены с JSON. Документ проверен в UTF-8, ссылки существуют.
quick_gate PASS:688 тестов,17 функциональных проверок,13 штатных пропусков.
Полный pytest:688 passed за72,92 с,0 failed; одно предупреждение доступа к .pytest_cache.
git diff --check PASS. Lab051 git add/commit и push не выполнялись; индекс пуст.
RF TX=0; HackRF,180 МГц,QPSK,новый FEC/кодек и аппаратные500 ксимв/с не запускались.
Следующий этап: STOP. Рекомендуемый Lab052 — отдельное программное исследование
стоимости уникальных фоновых кандидатов в полном режиме5 изображений/с,
с неизменными PHY/FEC/CRC и независимыми положительными/отрицательными регрессиями.
До отдельного разрешения не выполнять RF или аппаратные500 ксимв/с.

View File

@@ -0,0 +1,209 @@
{
"benchmark": {
"symbol_rate": 500000,
"samples_per_symbol": 16,
"detection_decimation": 8,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.323744625,
"payload_airtime_seconds": 0.276768,
"processing_seconds": 49.61432680033613,
"processing_per_rf_second": 153.25143019852803,
"processing_per_payload_airtime_second": 179.26323419013804,
"coarse_seconds": 0.4982285003643483,
"strict_seconds": 49.04027030314319,
"assembly_jpeg_decode_seconds": 0.0013099998468533158,
"processing_seconds_per_rf_packet": 2.067263616680672,
"peak_iq_buffer_samples": 424309,
"iq_buffer_limit_samples": 424864,
"working_arrays_estimate_bytes": 74928288,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2374,
"peak_candidates_in_block": 2114,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2298,
"TRUNCATED_SYMBOLS": 38,
"TRUNCATED_IQ_WINDOW": 14
},
"buffer_release_delay_upper_seconds": 0.052608,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 747682,
"emitted_after_sample": 1025782
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 747989,
"last_sample": 1491022,
"emitted_after_sample": 1742690
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1491425,
"last_sample": 2234130,
"emitted_after_sample": 2496773
}
],
"rf_tx": 0
},
"profile": {
"shared_stages": {
"phy_header": {
"calls": 24,
"seconds": 0.0005645002238452435,
"exclusive_seconds": 0.0005645002238452435
},
"coarse_localization": {
"calls": 36,
"seconds": 0.4980395002057776,
"exclusive_seconds": 0.4980395002057776
}
},
"valid": {
"count": 24,
"mean_seconds": 0.07296675831700365,
"median_seconds": 0.07252859993604943,
"total_seconds": 1.7512021996080875,
"stages": {
"refinement": {
"calls": 24,
"seconds": 0.028973900014534593,
"exclusive_seconds": 0.028973900014534593
},
"matched_filter": {
"calls": 48,
"seconds": 0.1906564999371767,
"exclusive_seconds": 0.1906564999371767
},
"timing": {
"calls": 48,
"seconds": 0.9146315004909411,
"exclusive_seconds": 0.0007350004743784666
},
"timing_fallback": {
"calls": 48,
"seconds": 0.9138965000165626,
"exclusive_seconds": 0.9138965000165626
},
"phase_diagnostics": {
"calls": 48,
"seconds": 0.019832400372251868,
"exclusive_seconds": 0.008994300849735737
},
"cfo_gain": {
"calls": 48,
"seconds": 0.010838099522516131,
"exclusive_seconds": 0.010838099522516131
},
"phy_header": {
"calls": 48,
"seconds": 0.0017112999921664596,
"exclusive_seconds": 0.0017112999921664596
},
"body_pipeline": {
"calls": 24,
"seconds": 0.49711450026370585,
"exclusive_seconds": 0.011535500641912222
},
"viterbi": {
"calls": 24,
"seconds": 0.4843809995800257,
"exclusive_seconds": 0.4843809995800257
},
"crc_parser": {
"calls": 24,
"seconds": 0.0002698999596759677,
"exclusive_seconds": 0.0002698999596759677
}
}
},
"false": {
"count": 2350,
"mean_seconds": 0.02009875251049612,
"median_seconds": 0.019996499991975725,
"total_seconds": 47.23206839966588,
"stages": {
"refinement": {
"calls": 2350,
"seconds": 2.7974041998386383,
"exclusive_seconds": 2.7974041998386383
},
"matched_filter": {
"calls": 2336,
"seconds": 0.9339654004434124,
"exclusive_seconds": 0.9339654004434124
},
"timing": {
"calls": 2336,
"seconds": 41.83316580078099,
"exclusive_seconds": 0.028656100272201
},
"timing_fallback": {
"calls": 2336,
"seconds": 41.80450970050879,
"exclusive_seconds": 41.80450970050879
},
"phase_diagnostics": {
"calls": 2336,
"seconds": 0.910528302541934,
"exclusive_seconds": 0.4247559007490054
},
"cfo_gain": {
"calls": 2336,
"seconds": 0.4857724017929286,
"exclusive_seconds": 0.4857724017929286
},
"phy_header": {
"calls": 2298,
"seconds": 0.06713570095598698,
"exclusive_seconds": 0.06713570095598698
}
}
}
},
"rf_tx": 0,
"array_observation": {
"valid": {
"observed_array_objects": 14361,
"observed_owned_arrays": 8704,
"observed_owned_bytes_total": 16681890,
"tracemalloc_retained_bytes": 1842,
"tracemalloc_peak_bytes": 4787123,
"array_count_is_lower_bound": true
}
},
"process_memory_after_profiling": {
"rss_bytes": 156041216,
"peak_rss_bytes": 169754624
}
}

View File

@@ -0,0 +1,419 @@
{
"repeats": [
{
"symbol_rate": 500000,
"samples_per_symbol": 16,
"detection_decimation": 8,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.323744625,
"payload_airtime_seconds": 0.276768,
"processing_seconds": 1.2077819011174142,
"processing_per_rf_second": 3.7306624044103103,
"processing_per_payload_airtime_second": 4.363878414836304,
"coarse_seconds": 0.0927895000204444,
"strict_seconds": 0.9242713990388438,
"assembly_jpeg_decode_seconds": 0.0011941003613173962,
"processing_seconds_per_rf_packet": 0.05032424587989226,
"peak_iq_buffer_samples": 424309,
"iq_buffer_limit_samples": 424864,
"working_arrays_estimate_bytes": 74928288,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2374,
"peak_candidates_in_block": 2114,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2298,
"TRUNCATED_SYMBOLS": 38,
"TRUNCATED_IQ_WINDOW": 14
},
"buffer_release_delay_upper_seconds": 0.052608,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 747682,
"emitted_after_sample": 1025782
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 747989,
"last_sample": 1491022,
"emitted_after_sample": 1742690
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1491425,
"last_sample": 2234130,
"emitted_after_sample": 2496773
}
],
"rf_tx": 0
},
{
"symbol_rate": 500000,
"samples_per_symbol": 16,
"detection_decimation": 8,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.323744625,
"payload_airtime_seconds": 0.276768,
"processing_seconds": 1.144175999914296,
"processing_per_rf_second": 3.534193038461398,
"processing_per_payload_airtime_second": 4.134061740932101,
"coarse_seconds": 0.08686010027304292,
"strict_seconds": 0.8724833994638175,
"assembly_jpeg_decode_seconds": 0.0011348994448781013,
"processing_seconds_per_rf_packet": 0.047673999996428996,
"peak_iq_buffer_samples": 424309,
"iq_buffer_limit_samples": 424864,
"working_arrays_estimate_bytes": 74928288,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2374,
"peak_candidates_in_block": 2114,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2298,
"TRUNCATED_SYMBOLS": 38,
"TRUNCATED_IQ_WINDOW": 14
},
"buffer_release_delay_upper_seconds": 0.052608,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 747682,
"emitted_after_sample": 1025782
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 747989,
"last_sample": 1491022,
"emitted_after_sample": 1742690
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1491425,
"last_sample": 2234130,
"emitted_after_sample": 2496773
}
],
"rf_tx": 0
},
{
"symbol_rate": 500000,
"samples_per_symbol": 16,
"detection_decimation": 8,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.323744625,
"payload_airtime_seconds": 0.276768,
"processing_seconds": 1.1161368001485243,
"processing_per_rf_second": 3.4475840337071983,
"processing_per_payload_airtime_second": 4.032752341847773,
"coarse_seconds": 0.08814430015627295,
"strict_seconds": 0.841466698795557,
"assembly_jpeg_decode_seconds": 0.0011885995045304298,
"processing_seconds_per_rf_packet": 0.04650570000618851,
"peak_iq_buffer_samples": 424309,
"iq_buffer_limit_samples": 424864,
"working_arrays_estimate_bytes": 74928288,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2374,
"peak_candidates_in_block": 2114,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2298,
"TRUNCATED_SYMBOLS": 38,
"TRUNCATED_IQ_WINDOW": 14
},
"buffer_release_delay_upper_seconds": 0.052608,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 747682,
"emitted_after_sample": 1025782
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 747989,
"last_sample": 1491022,
"emitted_after_sample": 1742690
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1491425,
"last_sample": 2234130,
"emitted_after_sample": 2496773
}
],
"rf_tx": 0
}
],
"median_processing_seconds": 1.144175999914296,
"profile": {
"shared_stages": {
"phy_header": {
"calls": 24,
"seconds": 0.00042649987153708935,
"exclusive_seconds": 0.00042649987153708935
},
"coarse_localization": {
"calls": 36,
"seconds": 0.08918269968125969,
"exclusive_seconds": 0.08918269968125969
}
},
"valid": {
"count": 24,
"mean_seconds": 0.01776749997710188,
"median_seconds": 0.017930250032804906,
"total_seconds": 0.4264199994504452,
"stages": {
"refinement": {
"calls": 24,
"seconds": 0.09387810016050935,
"exclusive_seconds": 0.09387810016050935
},
"matched_filter": {
"calls": 48,
"seconds": 0.1028753004502505,
"exclusive_seconds": 0.1028753004502505
},
"timing": {
"calls": 48,
"seconds": 0.04353450005874038,
"exclusive_seconds": 0.04353450005874038
},
"phase_diagnostics": {
"calls": 48,
"seconds": 0.016765999956987798,
"exclusive_seconds": 0.008323899819515646
},
"cfo_gain": {
"calls": 48,
"seconds": 0.008442100137472153,
"exclusive_seconds": 0.008442100137472153
},
"phy_header": {
"calls": 48,
"seconds": 0.001451600226573646,
"exclusive_seconds": 0.001451600226573646
},
"body_pipeline": {
"calls": 24,
"seconds": 0.14421880000736564,
"exclusive_seconds": 0.011199799599125981
},
"viterbi": {
"calls": 24,
"seconds": 0.1321190003072843,
"exclusive_seconds": 0.1321190003072843
},
"crc_parser": {
"calls": 24,
"seconds": 0.00021099986042827368,
"exclusive_seconds": 0.00021099986042827368
}
}
},
"false": {
"count": 2350,
"mean_seconds": 0.00018731676512695057,
"median_seconds": 6.049999501556158e-05,
"total_seconds": 0.4401943980483338,
"stages": {
"refinement": {
"calls": 2350,
"seconds": 0.11974260024726391,
"exclusive_seconds": 0.11974260024726391
},
"matched_filter": {
"calls": 174,
"seconds": 0.026502199470996857,
"exclusive_seconds": 0.026502199470996857
},
"timing": {
"calls": 174,
"seconds": 0.13858849939424545,
"exclusive_seconds": 0.13858849939424545
},
"phase_diagnostics": {
"calls": 174,
"seconds": 0.05482260056305677,
"exclusive_seconds": 0.028438000474125147
},
"cfo_gain": {
"calls": 174,
"seconds": 0.02638460008893162,
"exclusive_seconds": 0.02638460008893162
},
"phy_header": {
"calls": 2298,
"seconds": 0.03204370115417987,
"exclusive_seconds": 0.03204370115417987
}
}
}
},
"profiled_benchmark": {
"symbol_rate": 500000,
"samples_per_symbol": 16,
"detection_decimation": 8,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.323744625,
"payload_airtime_seconds": 0.276768,
"processing_seconds": 1.16440529958345,
"processing_per_rf_second": 3.5966783991655458,
"processing_per_payload_airtime_second": 4.207152920798105,
"coarse_seconds": 0.08932610007468611,
"strict_seconds": 0.8848743984708562,
"assembly_jpeg_decode_seconds": 0.0011538001708686352,
"processing_seconds_per_rf_packet": 0.048516887482643746,
"peak_iq_buffer_samples": 424309,
"iq_buffer_limit_samples": 424864,
"working_arrays_estimate_bytes": 74928288,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2374,
"peak_candidates_in_block": 2114,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2298,
"TRUNCATED_SYMBOLS": 38,
"TRUNCATED_IQ_WINDOW": 14
},
"buffer_release_delay_upper_seconds": 0.052608,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 747682,
"emitted_after_sample": 1025782
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 747989,
"last_sample": 1491022,
"emitted_after_sample": 1742690
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1491425,
"last_sample": 2234130,
"emitted_after_sample": 2496773
}
],
"rf_tx": 0
},
"rf_tx": 0
}

View File

@@ -0,0 +1,67 @@
{
"blocks": 4096,
"model_seconds": 33.554432,
"background": "zeros; noisy background covered by six-second integration",
"peak_iq_bytes": 3177728,
"iq_limit_bytes": 3398912,
"retained_after_finish": 0,
"checkpoints": [
{
"block": 512,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163840000,
"peak_rss_bytes": 186650624
},
{
"block": 1024,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163840000,
"peak_rss_bytes": 186650624
},
{
"block": 1536,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 2048,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 2560,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 3072,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 3584,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 4096,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163790848,
"peak_rss_bytes": 186650624
}
],
"rf_tx": 0
}

View File

@@ -0,0 +1,198 @@
{
"stage": "LAB048_OFFLINE_RECEIVER_PERFORMANCE",
"baseline": {
"capture_seconds": 15.0,
"full_processing_seconds": 37.423910000012256,
"strict_stage_seconds": 32.45996900013415,
"average_strict_candidate_seconds": 0.47043433333527757,
"approximate_peak_bytes": 1545755352
},
"optimized": {
"capture_seconds": 15.0,
"full_processing_seconds": 12.978691199910827,
"coarse_search_seconds": 4.4797875001095235,
"strict_stage_seconds": 8.487128499895334,
"average_strict_candidate_seconds": 0.12300186231732368,
"approximate_peak_bytes": 1545755352,
"stage_measurements": {
"cs8_to_complex64": {
"calls": 113,
"cumulative_seconds": 1.306003100471571
},
"refine_start": {
"calls": 69,
"cumulative_seconds": 2.930933100869879
},
"matched_filter": {
"calls": 105,
"cumulative_seconds": 2.9355400999775156
},
"timing": {
"calls": 105,
"cumulative_seconds": 1.2902987005654722
},
"cfo_phase_soft": {
"calls": 105,
"cumulative_seconds": 0.039461099775508046
},
"phy_header": {
"calls": 105,
"cumulative_seconds": 0.00410620030015707
},
"post_preamble_fec_crc": {
"calls": 36,
"cumulative_seconds": 0.6347944003064185
},
"viterbi": {
"calls": 36,
"cumulative_seconds": 0.6168836002470925
}
},
"memory_arrays": [
{
"name": "coarse_iq_window",
"dtype": "complex64",
"shape": [
16000000
],
"bytes": 128000000,
"mib": 122.0703125
},
{
"name": "coarse_decimated",
"dtype": "complex64",
"shape": [
800000
],
"bytes": 6400000,
"mib": 6.103515625
},
{
"name": "coarse_downconversion_vector",
"dtype": "complex64",
"shape": [
800000
],
"bytes": 6400000,
"mib": 6.103515625
},
{
"name": "coarse_correlation_scores",
"dtype": "float32",
"shape": [
773561
],
"bytes": 3094244,
"mib": 2.9509010314941406
},
{
"name": "strict_full_iq_window",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_cached_carrier_vector",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_baseband",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_matched_output",
"dtype": "complex64",
"shape": [
2310400
],
"bytes": 18483200,
"mib": 17.626953125
},
{
"name": "matched_filter_taps",
"dtype": "float32",
"shape": [
4001
],
"bytes": 16004,
"mib": 0.015262603759765625
}
]
},
"speedup": 2.883488745018403,
"processing_to_capture_ratio": 0.8652460799940551,
"real_time_capable": true,
"threshold": 0.030964473262429237,
"threshold_changed": false,
"coarse": {
"total_local_peaks": 185828,
"first_stage_candidates": 32414,
"skipped_as_owned_event": 32345,
"exact_duplicates": 0
},
"strict": {
"candidates_processed": 69,
"rejected_during_refinement": 0,
"refinement_pass": 69,
"timing_pass": 69,
"carrier_pass": 69,
"phy_header_pass": 36,
"phy_header_fail": 33,
"fec_attempts": 36,
"fec_pass": 36,
"fec_fail": 0,
"protocol_parse_pass": 36,
"crc_pass": 36,
"crc_fail": 0,
"valid_frames": 36
},
"object": {
"valid_fragments": 36,
"missing_fragments": [],
"object_complete": true
},
"after_independent_verdict": {
"stage": "AFTER_COMPLETED_INDEPENDENT_VERDICT_ONLY",
"expected_tx_frames": 36,
"matched_valid_frames": 36,
"missed_tx_frames": [],
"false_crc_pass": 0,
"duplicate_valid_detections": 0,
"total_pre_fec_coded_bit_errors": 19,
"total_post_fec_protocol_bit_errors": 0,
"jpeg_size": 8724,
"recovered_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"tx_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"sha_match": true,
"byte_errors": 0,
"bit_errors": 0,
"jpeg_decode": "PASS",
"width": 320,
"height": 240
},
"receiver_oracles": {
"expected_frame_count": false,
"tx_coded_bits": false,
"tx_fragment_indexes": false,
"tx_frame_offsets": false,
"tx_image": false,
"tx_packets": false
},
"phy_changed": false,
"fec_changed": false,
"bpsk_changed": false,
"rf_tx": 0
}

View File

@@ -0,0 +1,40 @@
{
"false": {
"reason": "PHY_HEADER_MAGIC_MISMATCH",
"baseline_arrays": {
"observed_array_objects": 2888,
"observed_owned_arrays": 61,
"observed_owned_bytes_total": 6612268,
"tracemalloc_retained_bytes": 1481,
"tracemalloc_peak_bytes": 2104129,
"array_count_is_lower_bound": true
},
"optimized_arrays": {
"observed_array_objects": 76,
"observed_owned_arrays": 61,
"observed_owned_bytes_total": 5906101,
"tracemalloc_retained_bytes": 1936,
"tracemalloc_peak_bytes": 5524392,
"array_count_is_lower_bound": true
}
},
"valid": {
"reason": "CRC_PASS",
"baseline_arrays": {
"observed_array_objects": 14361,
"observed_owned_arrays": 8704,
"observed_owned_bytes_total": 16681890,
"tracemalloc_retained_bytes": 2130,
"tracemalloc_peak_bytes": 5885299,
"array_count_is_lower_bound": true
},
"optimized_arrays": {
"observed_array_objects": 6594,
"observed_owned_arrays": 6560,
"observed_owned_bytes_total": 12345244,
"tracemalloc_retained_bytes": 2645,
"tracemalloc_peak_bytes": 7771675,
"array_count_is_lower_bound": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -275,6 +275,39 @@
- **Далее:** Lab051 — профиль и ускорение строгой обработки ложных кандидатов.
- **RF:** RF TX = 0. Lab050 commit/push не выполнялись. STOP.
## Lab051
- **Цель:** алгоритмически ускорить независимый блочный RX на 500 ксимв/с,
16 отсчётов/символ, Fs=8 Мвыб/с; проверить 5 свежих JPEG C в секунду.
- **База:** Lab050 закреплена разрешённым коммитом
`60691a09f193f36983c7174c5252ed0897c559e9`.
- **Профиль:** главная стоимость Lab050 — fallback timing, 41,833 с на ложных
кандидатах. Полный диапазон 1601 смещения сохранён и вычисляется через FFT
и накопленные энергии, с исходным выбором на неразделимых пиках.
- **Изменения:** общий front-end, точное повторное использование децимации,
кэш результатов уточнённого окна, короткий probe до PHY PASS, предвычисленные
метрики прежнего Viterbi. Порог, PHY/FEC/CRC и protocol не менялись.
- **Короткий контроль:** 24/24 пакета, 3/3 точных JPEG; медиана трёх повторов
1,144176 с вместо исторических 55,157553 с. Processing/RF=3,534193.
- **Основной поток:** 30 JPEG за 6 с, 240/240 пакетов, SHA MATCH 30/30.
Обработка 6,044416 с IQ заняла 38,630132 с, processing/RF=6,391045.
При MAX_VIDEO_AGE=200 мс все 30 JPEG просрочены; FRESH DISPLAY FPS=0.
Цель реального времени не достигнута, включая минимальную свежесть >0.
- **Память:** пик удерживаемого IQ 3,031 МиБ, RSS процесса в опыте измерен.
Тест 33,55 с не показывает роста IQ/кэша с длительностью. Это не готовая live-
очередь: непрерывный источник потребовал бы около 2,09 ГБ внешнего backlog.
- **Регрессия:** сохранённая Lab047-запись 20 ксимв/с штатным Lab048:
36/36, CRC PASS, JPEG SHA MATCH; 19 ошибок до FEC и 0 после.
- **Код:** [приёмник](../experiments/lab051_streaming_receiver.py),
[профилировщик](../experiments/lab051_receiver_profile.py),
[полный поток](../experiments/lab051_receiver_performance.py),
[38 тестов](../tests/test_lab051_streaming_receiver.py).
- **Отчёт:** [состояние](lab051_current_state.md),
[измеренный поток](../data/processed/lab051/stream.json). Журнал: запись 040.
- **Далее:** отдельное программное исследование стоимости уникальных фоновых
кандидатов в полном потоке 5 кадров/с с тем же PHY/FEC/CRC.
- **RF / Git:** RF TX=0; Lab051 git add/commit и push не выполнялись. STOP.
## Правило хранения данных
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные

View File

@@ -0,0 +1,281 @@
# Lab051 — вычислительная архитектура потокового BPSK-приёмника
Дата: 8 сентября 2026 года. SOFTWARE ONLY. RF TX = 0.
## Результат и граница вывода
**Программный эксперимент завершён; цели реального времени и свежести не достигнуты.**
В полном шестисекундном потоке получены 240/240 физических пакетов и 30/30 JPEG
с точным содержимым. Обработка 6,044416 с синтетического IQ заняла **38,630132 с**,
отношение **6,391045**. При MAX_VIDEO_AGE=200 мс **FRESH DISPLAY FPS=0**;
не выполнен даже минимальный критерий >0.
Короткий шумный сценарий Lab050 ускорен примерно в **48,21 раза** относительно
исторического замера: 55,157553 с → медиана **1,144176 с** трёх проходов.
Его processing/RF = **3,534193**, поэтому и этот вариант не работает в реальном времени.
Среднее успешного кандидата — **17,7675 мс**; полная стоимость короткого опыта —
**47,674 мс/пакет**, а длинного потока — **160,959 мс/пакет**.
Последние величины включают обработку ложных кандидатов и фона.
Модуляция BPSK, PRBS7, PHY-заголовок, фрагментация, интерливер, FEC R=1/2 K=7,
171/133 octal, soft-метрика и CRC не менялись. GPU и multiprocessing не использовались.
Новых зависимостей нет. Аппаратура не перечислялась и не запускалась.
## Git и аудит до реализации
Исходный main: `cdf5396e807b83f82a264632dc46f71428fcd616`.
Проверены HEAD/status/diff stat/diff check, затем показан точный staged scope.
По прямому разрешению пункта 0 задания создан коммит:
`60691a09f193f36983c7174c5252ed0897c559e9`
`Lab050: model fresh-frame SDR video stream`
В него вошли ровно девять файлов: PROJECT_LOG.md, docs/LABS_INDEX.md,
experiments/lab046c_offline_recovery.py, protocol/video_frame_scheduler.py,
docs/lab050_current_state.md, experiments/lab050_streaming_receiver.py,
experiments/lab050_video_stream.py, tests/test_lab050_video_stream.py,
data/processed/lab050/receiver_benchmarks.json. 1769 добавлений, 12 удалений.
Большой result.json, профили JPEG и посторонние локальные файлы не добавлялись.
Lab051 начата от этого HEAD. Изучены Lab046B/C, Lab047, Lab048, Lab050,
Lab043/044/045 и свёрточный декодер. Ядро protocol в Lab051 не менялось.
Планировщик LATEST_ONLY и ObjectAssembler переиспользованы. Новая видеочередь не создана.
## Профиль до оптимизации
Рабочая точка: 500000 символов/с, Fs=8000000 отсчётов/с, 16 отсчётов/символ.
Профиль C: 128×96 RGB, JPEG quality 25. Короткий контроль: три изображения,
24 пакета, seed=50050, coded-bit Eb/N0=7 дБ, CFO=+10 Гц, фаза 0,73 рад,
чередование полярности, исходное CS8-квантование. Генератор точно прежний, из Lab050.
Путь кандидата: IQ → грубая нормированная корреляция → уточнение старта →
согласованный RRC-фильтр → timing → оценка частотного рассогласования CFO →
компенсация CFO и фазы → мягкие решения → PHY header → deinterleave →
soft Viterbi → protocol parser → CRC. Только полный PHY/FEC/CRC PASS выдаёт пакет.
TX-границы, ожидаемое число кадров и содержимое не входят в RX API.
Историческая база Lab050: 55,157553 / 0,323745 = 170,373647.
Свежий инструментированный baseline Lab051: **49,614327 с**, processing/RF=153,251430.
Настольная нагрузка и инструментация влияют на время; эти замеры не подменяют друг друга.
Все 2374 кандидата: 24 valid, 2350 false. Средние времена — **72,967 / 20,099 мс**.
**Главная причина замедления — fallback timing.** Номинальное положение равно 416,
а диапазон остался −800…+800 отсчётов. Начало выходит за ноль, и прежний быстрый
путь возвращается к Python-перебору 1601 смещения. У ложных кандидатов timing
занял 41,833 с из 47,232 с строгой обработки. Их matched filter — 0,934 с,
уточнение — 2,797 с. Прежняя реализация уже останавливала кандидата при PHY FAIL:
тело, Viterbi и CRC на таких входах не запускались.
Для преамбулы и заголовка достаточно 23456 входных отсчётов и 23616 отсчётов
после RRC. Полный физический пакет с payload 256 байт требует 92256 входных
отсчётов. Длина тела определяется только проверенным PHY-заголовком.
## Изменения вычислений
1. Децимация сохраняет внутреннюю часть конечного импульсного отклика FIR.
При сдвиге буфера заново считаются левый нулевой край и новые правые отсчёты.
Коэффициенты resample_poly/kaiser(8) прежние; сравнение с исходной функцией
побитно точное. Среднее по всему прежнему окну сохраняется. Корреляция считается
только для принадлежащих блоку стартов плюс правый сосед максимума.
2. Корреляция известного prefix для уточнения считается один раз для общего участка.
Кандидаты используют срезы, с прежней поправкой локального DC.
Постоянный probe и неразделимые FFT-пики сохраняют исходный выбор первого
максимума; при необходимости вызывается старое уточнение.
3. Компенсация известного +50 кГц и RRC выполняются для общего участка.
Локальное вычитание среднего и оба нулевых края изолированного окна
восстановлены отдельно. Без этих поправок общая фильтрация меняла бы алгоритм.
Тело за пределами общего участка фильтруется локально только после PHY PASS.
4. Извлечение символов кэшируется по уточнённому положению и длине внутри одного блока.
Усечённое окно не подменяется ранее сохранённым полным окном.
Кэш уничтожается вместе с блоком; история сессии не сохраняется.
5. Timing сохраняет весь прежний диапазон 1601 смещения. Корреляция с разреженной
известной преамбулой и разности накопленных энергий заменяют Python-перебор.
Тело за последним нужным отсчётом не участвует в этом расчёте. При пиках,
неразделимых с точностью float32, возвращается исходный расчёт и его tie-правило.
6. CFO по прежним преамбульным символам не изменён. Векторы переноса кэшируются;
вектор остаточной частоты строится на символах локального окна. Gain/фаза для
probe и полного кадра не объединены: у них различается локальное среднее IQ.
7. Viterbi после устранения главного повтора остался существенной долей принятого
пакета. Предвычислены четыре различные пары евклидовых branch metrics;
две ветви сравниваются непосредственно, с прежним выбором branch 0 при равенстве.
Решётка, старт/финиш 0, шесть хвостовых битов и декодированные биты сохранены.
Дополнительный порог преамбулы/новый критерий отсева не введён. Фиксированный
порог первого каскада **0.030964473262429237** сохранён. В коротком опыте совпали
число кандидатов и все причины отказа с исходным RX. Параллелизм и CUDA не добавлялись.
## Профиль стадий
Ячейка: **число вызовов / суммарные миллисекунды** по соответствующей группе.
Это не длительность одного вызова. Общая работа front-end учитывается в том
кандидате, который впервые запросил её. Фаза показана без вложенного CFO;
время body pipeline не складывается повторно со временем Viterbi/CRC.
| Стадия | Baseline valid (24) | Optimized valid (24) | Baseline false (2350) | Optimized false (2350) |
|---|---:|---:|---:|---:|
| Точное уточнение | 24 / 28.974 | 24 / 93.878 | 2350 / 2797.404 | 2350 / 119.743 |
| Согласованная RRC-фильтрация | 48 / 190.656 | 48 / 102.875 | 2336 / 933.965 | 174 / 26.502 |
| Timing: весь диапазон | 48 / 914.632 | 48 / 43.535 | 2336 / 41833.166 | 174 / 138.588 |
| CFO и комплексное усиление | 48 / 10.838 | 48 / 8.442 | 2336 / 485.772 | 174 / 26.385 |
| Фаза/полярность и диагностика | 48 / 8.994 | 48 / 8.324 | 2336 / 424.756 | 174 / 28.438 |
| PHY header | 48 / 1.711 | 48 / 1.452 | 2298 / 67.136 | 2298 / 32.044 |
| Viterbi | 24 / 484.381 | 24 / 132.119 | 0 / 0 | 0 / 0 |
| Protocol parser / CRC | 24 / 0.270 | 24 / 0.211 | 0 / 0 | 0 / 0 |
Грубая локализация — общая стадия: 36 вызовов, baseline 498,040 мс,
optimized **89,183 мс**. На один false candidate среднее **0,187317 мс**,
медиана **0,060500 мс**; это включает cache hits. Холодное новое окно дороже.
## Массивы и память
Число всех аллокаций внутри NumPy/SciPy C не измерено. Line tracer наблюдает
массивы в локальных переменных проектного Python: число ниже — **нижняя граница**,
включающая views. Владельцы данных выделены отдельно. Временные выражения и
внутренние C-массивы не перечислены. Отдельный tracemalloc-проход измеряет пик
отслеживаемой памяти. Эти медленные проходы не включены в CPU benchmark.
| Кандидат / алгоритм | Наблюдаемые ndarray | Из них владельцы | Суммарные байты наблюдаемых владельцев | Пик tracemalloc, байт |
|---|---:|---:|---:|---:|
| false / baseline | 2888 | 61 | 6612268 | 2104129 |
| false / optimized | 76 | 61 | 5906101 | 5524392 |
| valid / baseline | 14361 | 8704 | 16681890 | 5885299 |
| valid / optimized | 6594 | 6560 | 12345244 | 7771675 |
Пик холодного кандидата вырос из-за общих промежуточных данных: ускорение не
объявляется уменьшением каждого временного массива. В рабочем потоке:
- Пик удерживаемых IQ: **3177728 байт = 3,031 МиБ**.
- Проверяемый предел IQ: 3398912 байт; кэш децимации хранится отдельно.
- Инженерная оценка рабочих массивов: **136,161 МиБ**. Использован запас прежних
22 размеров IQ-окна плюс 20 размеров на новые массивы и кэши. Это оценка для
данного опыта, не доказанный универсальный предел всех аллокаций и не RSS.
- Windows process RSS после потока: **154,105 МиБ**; peak RSS процесса —
**178,004 МиБ**. В него входят Python, библиотеки, генератор, предыдущие замеры и аллокатор.
- Текущий объект ограничен 65536 байтами / 269 фрагментами; C фактически ≤1893 байт.
Длинный тест: **4096 блоков, 33,554432 с модельного времени**. Фон нулевой;
шум отдельно проверен в основном опыте. После прогрева удерживаются 2653440 байт
IQ и 397216 байт децимированных данных; значения не растут.
RSS контрольных точек: **156,199–156,250 МиБ**, без роста вместе с длительностью.
После finish эти буферы освобождены. История JSON относится к отчёту, не к RX.
## Непрерывный поток 5 изображений/с
30 воспроизводимых сдвигов исходного data/raw/lab011_scene.jpg, профиль C,
1740–1893 байта. Это искусственная последовательность, не видео камеры.
Видеопланировщик LATEST_ONLY использует время capture и завершает активное JPEG.
Передатчик знает нижнюю задержку радио/буфера; он не получает будущую стоимость
CPU или результаты приёма. Приёмник получает лишь IQ-блоки по 65536 отсчётов.
Шум, фаза, CFO и полярность заданы, но готовность JPEG берётся из измеренного encode.
Поэтому точное размещение пакетов и число фоновых кандидатов длинного опыта могут
немного меняться между запусками. Короткий baseline использует точно прежний
детерминированный сценарий Lab050.
Время CPU-работника для каждого блока:
`start=max(previous_end, block_arrival); end=start+measured_push_time`.
Сборка/JPEG decode добавляют своё измеренное время. Генерация TX/IQ, SHA-сравнение
с передатчиком и запись JSON исключены. Длительность RX не заменена нулём.
Все данные реально прошли независимый PHY/FEC/CRC и JPEG decode.
| Метрика | Итог |
|---|---:|
| INPUT FPS / FRAMES | 5 / 30 |
| TX IMAGES / PHYSICAL PACKETS | 30 / 240 |
| DROPPED BEFORE TX | 0 |
| MAX WAITING VIDEO FRAMES | 1 |
| RX PHYSICAL PACKETS / IMAGES | 240 / 30 |
| JPEG SHA MATCH | 30 / 30 |
| DROPPED STALE AT DISPLAY | 30 |
| DISPLAYED / FRESH DISPLAYED | 0 / 0 |
| RAW RECEIVED FPS в первых 6 с CPU-шкалы | 0.833333 |
| RAW DISPLAY FPS / FRESH DISPLAY FPS | 0 / 0 |
| Возраст декодированных JPEG: min / median / max | 276.515 / 15691.203 / 31150.009 мс |
| MAX_VIDEO_AGE | 200 мс |
| Строгих кандидатов | 147082 |
| Полное время обработки / IQ | 38.630132 / 6.044416 с |
| PROCESSING / RF TIME | 6.391045 |
Возраст относится к **декодированным, затем отброшенным JPEG**. Показанных изображений нет.
RAW DISPLAY и FRESH DISPLAY совпадают, потому что политика не выводит просроченные
изображения; RAW RECEIVED приведён отдельно.
Физическое время одного JPEG C: 8×5766/500000 = **92,256 мс**. Очередь ожидающих
JPEG в этом режиме не задерживает готовые изображения. Время RX за опыт:
38,619085 с; сборка 5,451 мс; JPEG decode 5,596 мс суммарно на 30 JPEG.
RF и CPU перекрываются по событийной шкале: эти суммы нельзя просто сложить
и назвать измеренной задержкой камеры→экрана. Camera capture latency и display
latency **UNKNOWN**; экран в отчётной модели мгновенный.
Capture lookup используется только **после** независимой сборки JPEG для метрики
возраста/решения модели вывода. В существующем fragment format нет времени съёмки.
Готовый независимый аппаратный контроль возраста этим не доказан; формат в Lab051
не расширялся. ID монотонны в одной конечной сессии; wrap/restart не реализованы.
Внешняя IQ-очередь также не реализована. Офлайн-источник подаёт следующий блок
по запросу и физически не хранит многосекундный backlog. Если бы вход шёл непрерывно,
максимальное отставание **32,632404 с** потребовало бы около **2088473848 байт**
дополнительного complex64 IQ. Поэтому bounded RX state нельзя выдавать за готовую
ограниченную live-систему: при таких затратах потребуются потери/переполнение.
REAL-TIME CAPABLE = NO.
В длительном опыте встречено значительно больше фоновых кандидатов, чем в коротком:
147082 против 2374. Кэш дал 135057 повторных использований, но осталось 12239 новых
окон синхронизации. Следующее ограничение — стоимость этих фоновых событий.
Одного ускорения Viterbi недостаточно; подгонять отсев по TX запрещено.
## Регрессии и независимая проверка
38 целевых тестов: весь диапазон timing, ненулевые и нулевые ties, локальные
DC/края общего RRC, точная децимация при движении окна, Viterbi noisy/tie/invalid,
совпадение старого/нового блочного приёма, усечение, буферы и finally.
Verifier нашёл два случая округления FFT (timing и refine); оба исправлены,
воспроизведены повторно и закреплены тестами. Adversary отдельно проверил
malformed PHY (0 body/Viterbi), дубликаты и старые ID, лимиты объекта,
усечение и восстановление глобальных подмен.
Сохранённая Lab047-запись 20 ксимв/с повторно обработана штатным Lab048:
**36/36, FEC/CRC PASS, JPEG SHA MATCH**, 19 ошибок до FEC, 0 после.
Исходный путь и SHA JPEG сохранены в regression20k/result.json.
Старый режим работает прежним декодером; адаптер Lab051 принимает только 500 ксимв/с
и не подменяет аппаратную проверку этого режима.
Итог: quick_gate PASS — 688 тестов, 17 функциональных проверок, 13 штатных пропусков.
Полный pytest — 688 passed за 72,92 с, 0 failed; одно предупреждение доступа к
`.pytest_cache`. git diff --check PASS. Подробности — в записи 040 журнала.
Управление ELRS/CRSF, локальная защита и ядро protocol не менялись.
## Файлы и воспроизведение
- [Потоковый RX](../experiments/lab051_streaming_receiver.py)
- [Профилировщик](../experiments/lab051_receiver_profile.py)
- [Сценарии и событийная шкала](../experiments/lab051_receiver_performance.py)
- [Тесты](../tests/test_lab051_streaming_receiver.py)
- [Baseline](../data/processed/lab051/baseline_profile.json)
- [Три коротких замера и профиль](../data/processed/lab051/benchmark.json)
- [Основной поток и все 739 блоков](../data/processed/lab051/stream.json)
- [Длинный тест памяти](../data/processed/lab051/memory.json)
- [Наблюдение массивов](../data/processed/lab051/replay.json)
- [20 ксимв/с](../data/processed/lab051/regression20k/result.json)
```powershell
python experiments/lab051_receiver_profile.py
python experiments/lab051_receiver_performance.py --stage all
python -m pytest tests/test_lab051_streaming_receiver.py -q -p no:cacheprovider
python tools/quick_gate.py
python -m pytest tests/ -q
git diff --check
```
Старая запись:
```powershell
python experiments/lab048_receiver_performance.py --run-saved-capture --capture 'C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\rx_stream.cs8' --original-result 'C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\independent_result.json' --tx-image data/processed/lab047/tx_image.jpg --output-directory data/processed/lab051/regression20k
```
Локальные replay .npy — ограниченные окна, не полная IQ-запись; в Git не добавлены.
Новый полный CS8/IQ файл не создавался. Lab051 git add/commit и push не выполнялись.
Следующий рекомендуемый этап — отдельное программное исследование стоимости
уникальных фоновых кандидатов в полном режиме 5 кадров/с с тем же PHY/FEC/CRC.
RF, QPSK, новый FEC/кодек, 180 МГц и аппаратные 500 ксимв/с не выполнялись. STOP.

View File

@@ -0,0 +1,320 @@
"""Lab051: измеренный блочный RX и возраст JPEG в шестисекундном потоке.
Источник синтетический, из реального фото. RF не выполняется. Виртуальное
время работника использует фактическое время каждого push/сборки/JPEG decode.
Это офлайн-воспроизведение; отставание явно означает требуемый внешний IQ backlog.
"""
from __future__ import annotations
import argparse
from collections import Counter
from dataclasses import asdict
import hashlib
from io import BytesIO
import json
import math
from pathlib import Path
import statistics
import sys
import time
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from PIL import Image
from experiments import lab050_video_stream as video
from experiments import lab051_streaming_receiver as fast
from experiments.lab051_receiver_profile import OUTPUT, ROOT, StageProfiler, array_observation, process_memory
from protocol.video_frame_scheduler import FramePolicy, schedule_video_frames
def write_result(name: str, result: dict) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / name).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
class ImageSink:
"""Только текущий объект; никаких ожидаемых ID, размеров JPEG или TX bytes."""
def __init__(self):
self.object_id = -1
self.last_completed_id = -1
self.assembler = None
self.first_rf_sample = None
self.first_rx_time = None
self.retained_payload_peak = 0
self.assembly_seconds = self.jpeg_decode_seconds = 0.0
self.rejected = Counter()
def offer(self, packet, received_time: float) -> dict | None:
started = time.perf_counter()
fragment = video.transport.recover_fragment_from_payload(packet.payload)
if fragment is None or fragment.original_size > 65536 or fragment.fragment_count > 269:
self.rejected["invalid_fragment"] += 1
self.assembly_seconds += time.perf_counter() - started
return None
if fragment.image_id < self.object_id or fragment.image_id <= self.last_completed_id:
self.rejected["older_object"] += 1
self.assembly_seconds += time.perf_counter() - started
return None
if fragment.image_id > self.object_id:
self.object_id = fragment.image_id
self.assembler = video.transport.ObjectAssembler()
self.first_rf_sample = packet.start_sample
self.first_rx_time = received_time
self.assembler.offer(fragment)
self.retained_payload_peak = max(self.retained_payload_peak, fragment.original_size)
jpeg = self.assembler.recovered_object()
self.assembly_seconds += time.perf_counter() - started
if jpeg is None:
return None
decode_started = time.perf_counter()
try:
with Image.open(BytesIO(jpeg)) as image:
image.load()
dimensions = image.size
except (OSError, ValueError):
self.rejected["jpeg_decode"] += 1
return None
finally:
self.jpeg_decode_seconds += time.perf_counter() - decode_started
self.last_completed_id = self.object_id
self.assembler = None
return {"image_id": self.object_id, "jpeg": jpeg, "dimensions": dimensions,
"first_rf_sample": self.first_rf_sample, "last_rf_sample": packet.end_sample,
"first_rx_time": self.first_rx_time}
def run_stream(sequence: list[video.EncodedImage]) -> dict:
"""Вход RX — только последовательные IQ-блоки; TX-диагностика после JPEG."""
mode = video.phy.PhyMode(500000)
receiver = fast.StreamingReceiver()
sink = ImageSink()
random_generator = np.random.default_rng(50050)
frames = video.make_frame_groups(sequence, 5)
packet_time = video.FRAME_INTERVALS / mode.symbol_rate
bitrate = (256 + video.HEADER_SIZE) * 8 / packet_time
# TX использует известную нижнюю задержку радио/буфера, без предсказания
# будущего CPU и без знания RX verdict. Перегрузка RX измеряется ниже.
buffer_delay = (receiver.lookahead + 2*receiver.core_samples) / mode.sample_rate_hz
schedule = schedule_video_frames(frames, (), FramePolicy.LATEST_ONLY, bitrate,
max_video_age_seconds=.2, completion_latency_seconds=buffer_delay)
starts = {}
for sent in schedule.transmitted:
starts.setdefault(sent.item.composite_frame_id, sent.start_seconds)
horizon = len(sequence) / 5
encoded_lookup = {image.image_id: image for image in sequence}
capture_lookup = {frame.composite_frame_id: frame.generation_time_seconds for frame in frames}
pending = np.empty(0, np.complex64)
total_samples = tx_packets = 0
worker_time = rx_processing = max_lag = 0.0
completed = []
block_metrics = []
rss_samples = []
def consume_block(block, *, final=False):
nonlocal total_samples, worker_time, rx_processing, max_lag
total_samples += block.size
arrival = total_samples / mode.sample_rate_hz
worker_start = max(worker_time, arrival)
started = time.perf_counter()
packets = receiver.finish() if final else receiver.push_iq_block(block)
cpu = time.perf_counter() - started
rx_processing += cpu
worker_time = worker_start + cpu
for packet in packets:
started = time.perf_counter()
item = sink.offer(packet, worker_time)
cost = time.perf_counter() - started
worker_time += cost
if item is not None:
# Приём/сборка/JPEG уже завершены. TX используется только теперь.
jpeg = item.pop("jpeg")
image_id = item["image_id"]
capture = capture_lookup.get(image_id)
age = None if capture is None else worker_time - capture
completed.append({**item, "decode_complete_timestamp": worker_time,
"capture_timestamp": capture, "image_age_seconds": age,
"fresh": age is not None and 0 <= age <= .2,
"sha256": hashlib.sha256(jpeg).hexdigest(),
"sha_match": image_id in encoded_lookup and jpeg == encoded_lookup[image_id].jpeg})
lag = max(0., worker_time - arrival)
max_lag = max(max_lag, lag)
block_metrics.append({"samples_received": total_samples, "arrival_time": arrival,
"worker_start": worker_start, "worker_end": worker_time,
"rx_cpu_seconds": cpu, "packets": len(packets), "lag_seconds": lag})
if len(block_metrics) % 64 == 0:
rss_samples.append({"samples": total_samples, **process_memory()})
def push(values):
nonlocal pending
offset = 0
if pending.size:
needed = receiver.block_samples - pending.size
joined = np.concatenate((pending, values[:needed]))
offset = min(needed, values.size)
if joined.size < receiver.block_samples:
pending = joined
return
consume_block(joined)
pending = np.empty(0, np.complex64)
while offset + receiver.block_samples <= values.size:
consume_block(values[offset:offset + receiver.block_samples])
offset += receiver.block_samples
pending = values[offset:].copy()
def background(count):
while count:
amount = min(count, receiver.block_samples)
values = .2 * (random_generator.normal(size=amount) + 1j*random_generator.normal(size=amount))
push(values.astype(np.complex64))
count -= amount
for image in sequence:
if image.image_id not in starts:
continue
target = math.ceil(starts[image.image_id] * mode.sample_rate_hz)
background(max(0, target - total_samples - pending.size))
for frame in video.transport.build_object_frames(image.jpeg, image.image_id):
raw = video.phy.build_parameterized_tx_cs8(frame.plan, mode)
components = np.frombuffer(raw, np.int8).astype(np.float32)
values = components[0::2] + 1j*components[1::2]
indexes = np.arange(values.size)
values = values * np.exp(1j*(.73 + 2*np.pi*10*indexes/mode.sample_rate_hz)) * (-1 if tx_packets % 2 else 1)
energy = float(np.sum(abs(values)**2)) / frame.plan.frame_symbol_count
sigma = math.sqrt(energy / (2*10**(.7)))
values += sigma*(random_generator.normal(size=values.size) + 1j*random_generator.normal(size=values.size))
push(values.astype(np.complex64))
tx_packets += 1
background(max(0, math.ceil(horizon * mode.sample_rate_hz) + receiver.lookahead + receiver.core_samples
- total_samples - pending.size))
if pending.size:
consume_block(pending)
consume_block(np.empty(0,np.complex64), final=True)
fresh = [row for row in completed if row["fresh"]]
displayed_in_window = [row for row in fresh if row["decode_complete_timestamp"] <= horizon]
decoded_in_window = [row for row in completed if row["decode_complete_timestamp"] <= horizon]
ages = [row["image_age_seconds"] for row in completed if row["image_age_seconds"] is not None]
fresh_ages = [row["image_age_seconds"] for row in fresh]
processing = rx_processing + sink.assembly_seconds + sink.jpeg_decode_seconds
return {
"scope": "continuous synthetic IQ; measured per-block CPU event replay; camera/display latency UNKNOWN",
"symbol_rate":500000,"samples_per_symbol":16,"input_fps":5,"input_frames":len(sequence),
"observation_seconds":horizon,"iq_seconds":total_samples/mode.sample_rate_hz,
"tx_images":len(starts),"tx_physical_packets":tx_packets,
"dropped_before_tx":len(schedule.dropped_frames),"maximum_waiting_frames":schedule.maximum_waiting_frames,
"rx_physical_packets":receiver.packet_count,"received_images":len(completed),
"object_jpeg_integrity":len(completed)==len(starts) and all(row["sha_match"] for row in completed),
"dropped_stale_at_display":len(completed)-len(fresh),"displayed":len(fresh),"fresh_displayed":len(fresh),
"raw_received_fps":len(decoded_in_window)/horizon,
"raw_display_fps":len(displayed_in_window)/horizon,"fresh_display_fps":len(displayed_in_window)/horizon,
"drain_fresh_displayed":len(fresh)-len(displayed_in_window),
"image_age_min_seconds":min(ages) if ages else None,
"image_age_median_seconds":statistics.median(ages) if ages else None,
"image_age_max_seconds":max(ages) if ages else None,
"fresh_image_age_seconds":fresh_ages,"max_video_age_seconds":.2,
"processing_seconds":processing,"rx_processing_seconds":rx_processing,
"assembly_seconds":sink.assembly_seconds,"jpeg_decode_seconds":sink.jpeg_decode_seconds,
"processing_per_rf_second":processing/(total_samples/mode.sample_rate_hz),
"physical_packet_processing_seconds":processing/tx_packets if tx_packets else None,
"maximum_cpu_lag_seconds":max_lag,
"implied_external_iq_backlog_bytes":math.ceil(max_lag*mode.sample_rate_hz)*8,
"live_external_queue_implemented":False,
"retained_iq_peak_bytes":receiver.peak_buffer_samples*8,
"working_arrays_upper_estimate_bytes":receiver.memory_estimate_bytes + 20*receiver.buffer_limit_samples*8,
"object_payload_peak_bytes":sink.retained_payload_peak,"process_memory":process_memory(),
"rss_samples":rss_samples,"candidate_count":receiver.candidate_count,
"rejected_reasons":dict(receiver.rejected_reasons),"front_statistics":dict(receiver.front_statistics),
"decimated_samples_reused":receiver.decimator.reused_samples,
"actual_decimator_input_samples":receiver.decimator.filtered_input_samples,
"images":completed,"blocks":block_metrics,"rf_tx":0,
}
def run_memory_test(block_count: int = 4096) -> dict:
"""33,55 с без истории IQ/пакетов; нулевой фон отделён от noisy IQ опыта."""
receiver = fast.StreamingReceiver()
values = np.zeros(receiver.block_samples,np.complex64)
checkpoints=[]
for index in range(block_count):
assert receiver.push_iq_block(values) == []
assert receiver.buffer.size <= receiver.buffer_limit_samples
if (index+1) % 512 == 0:
checkpoints.append({"block":index+1,"iq_bytes":receiver.buffer.nbytes,
"decimated_bytes":receiver.decimator.values.nbytes,**process_memory()})
assert receiver.finish()==[]
return {"blocks":block_count,"model_seconds":block_count*values.size/8000000,
"background":"zeros; noisy background covered by six-second integration",
"peak_iq_bytes":receiver.peak_buffer_samples*8,"iq_limit_bytes":receiver.buffer_limit_samples*8,
"retained_after_finish":receiver.buffer.nbytes+receiver.decimator.values.nbytes,
"checkpoints":checkpoints,"rf_tx":0}
def run_candidate_replay() -> dict:
metadata=json.loads((OUTPUT/"optimized_examples.json").read_text(encoding="utf-8"))
result={}
for kind, entry in metadata.items():
raw=np.load(OUTPUT/f"optimized_{kind}_window.npy")
candidate=fast.offline.CoarseCandidate(entry["offset"],entry["score"])
def call(optimized=False):
with fast.previous.streaming_mode(video.phy.PhyMode(500000)):
def decode():
return fast.offline.decode_candidate_from_memmap(raw,entry["sample_count"],candidate,1,
read_iq_window=lambda data,start,end:data[2*start:2*end].view(np.complex64))
if not optimized:
return decode()
front=fast.BlockFrontEnd(raw.view(np.complex64),65536,Counter())
with fast.optimized_context(front): return decode()
old=call(False)
new=call(True)
assert (old.accepted,old.reason,old.refined_start_sample)==(new.accepted,new.reason,new.refined_start_sample)
result[kind]={"reason":new.reason,"baseline_arrays":array_observation(lambda:call(False)),
"optimized_arrays":array_observation(lambda:call(True))}
return result
def run_benchmark(sequence) -> dict:
original=video.StreamingReceiver
try:
video.StreamingReceiver=fast.StreamingReceiver
repeats=[video.benchmark_receiver(sequence[:3],500000) for unused in range(3)]
profiler=StageProfiler()
with profiler.measure(optimized=True): profiled=video.benchmark_receiver(sequence[:3],500000)
metadata={}
for kind,(raw,count,candidate) in profiler.examples.items():
np.save(OUTPUT/f"optimized_{kind}_window.npy",raw)
metadata[kind]={"sample_count":count,"offset":candidate.sample_offset,"score":candidate.score}
write_result("optimized_examples.json",metadata)
return {"repeats":repeats,"median_processing_seconds":statistics.median(row["processing_seconds"] for row in repeats),
"profile":profiler.summary(),"profiled_benchmark":profiled,"rf_tx":0}
finally:
video.StreamingReceiver=original
def run_functional_tests() -> None:
assert video.maximum_jpeg_bytes(500000,5)==4148
assert fast.lab045.conv.CONSTRAINT_LENGTH==7
def main() -> None:
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage",choices=("benchmark","stream","memory","replay","all"),default="all")
args=parser.parse_args()
OUTPUT.mkdir(parents=True,exist_ok=True)
with Image.open(ROOT/"data/raw/lab011_scene.jpg") as source:
sequence=video.encode_sequence(source,video.PROFILES[2],30)
for name,function in (("benchmark",lambda:run_benchmark(sequence)),("stream",lambda:run_stream(sequence)),
("memory",run_memory_test),("replay",run_candidate_replay)):
if args.stage not in ("all",name): continue
print(f"Lab051 {name}: START; RF TX=0",flush=True)
result=function()
write_result(f"{name}.json",result)
print(json.dumps({key:value for key,value in result.items() if key not in (
"blocks","images","repeats","profiled_benchmark","rss_samples")},ensure_ascii=False),flush=True)
if __name__=="__main__":
main()

View File

@@ -0,0 +1,246 @@
"""Lab051: воспроизводимое профилирование приёмника, без оборудования.
Измерение времени отделено от медленного наблюдения массивов/tracemalloc.
Классификация valid/false происходит только после штатного PHY/FEC/CRC.
"""
from __future__ import annotations
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import asdict, dataclass
import ctypes
from ctypes import wintypes
import json
from pathlib import Path
import statistics
import sys
import time
import tracemalloc
import weakref
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from PIL import Image
from experiments import lab046c_offline_recovery as offline
from experiments import lab050_video_stream as video
from experiments.lab050_streaming_receiver import streaming_mode
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab051"
lab045 = video.phy.lab045
lab043 = lab045.lab044.lab043
def process_memory() -> dict:
"""Working set Windows без psutil; чужие процессы не перечисляются."""
if sys.platform != "win32":
return {"rss_bytes": None, "peak_rss_bytes": None}
class Counters(ctypes.Structure):
_fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD)] + [
(name, ctypes.c_size_t) for name in (
"PeakWorkingSetSize", "WorkingSetSize", "QuotaPeakPagedPoolUsage",
"QuotaPagedPoolUsage", "QuotaPeakNonPagedPoolUsage", "QuotaNonPagedPoolUsage",
"PagefileUsage", "PeakPagefileUsage")]
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)
kernel.GetCurrentProcess.restype = wintypes.HANDLE
psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(Counters), wintypes.DWORD]
counters = Counters()
counters.cb = ctypes.sizeof(counters)
if not psapi.GetProcessMemoryInfo(kernel.GetCurrentProcess(), ctypes.byref(counters), counters.cb):
return {"rss_bytes": None, "peak_rss_bytes": None, "error": ctypes.get_last_error()}
return {"rss_bytes": counters.WorkingSetSize, "peak_rss_bytes": counters.PeakWorkingSetSize}
@dataclass
class Stage:
calls: int = 0
seconds: float = 0.0
exclusive_seconds: float = 0.0
class StageProfiler:
"""Только последовательное профилирование; подмены возвращаются в finally."""
def __init__(self):
self.rows = []
self.current = None
self.shared = defaultdict(Stage)
self.stack = []
self.examples = {}
@contextmanager
def measure(self, *, optimized: bool = False):
patches = []
targets = (
(offline, "_correlation_scores", "coarse_localization"),
(lab043, "_refine_frame_start", "refinement"),
(lab043, "_matched_waveform", "matched_filter"),
(lab043, "_preamble_timing_offset", "timing"),
(lab043, "_timing_offsets", "timing_fallback"),
(lab043, "_estimate_cfo_gain", "cfo_gain"),
(lab043, "_evaluate_symbols", "phase_diagnostics"),
(lab045, "decode_phy_header_soft", "phy_header"),
(lab045, "analyze_post_preamble_soft", "body_pipeline"),
(lab045.conv, "viterbi_decode_soft", "viterbi"),
(lab045, "parse_packet", "crc_parser"),
)
if optimized:
from experiments import lab051_streaming_receiver as fast
targets += ((fast.BlockFrontEnd, "refine", "refinement"),
(fast.BlockFrontEnd, "coarse_scores", "coarse_localization"),
(fast.BlockFrontEnd, "matched_window", "matched_filter"),
(fast.BlockFrontEnd, "timing", "timing"),
(fast, "fast_viterbi", "viterbi"))
def wrap(function, name):
def measured(*args, **kwargs):
scope = self.shared if self.current is None else self.current
stage = scope[name]
started = time.perf_counter()
entry = [0.0]
self.stack.append(entry)
try:
return function(*args, **kwargs)
finally:
elapsed = time.perf_counter() - started
self.stack.pop()
if self.stack:
self.stack[-1][0] += elapsed
stage.calls += 1
stage.seconds += elapsed
stage.exclusive_seconds += elapsed - entry[0]
return measured
for owner, name, stage in targets:
original = getattr(owner, name)
patches.append((owner, name, original))
setattr(owner, name, wrap(original, stage))
original_decode = offline.decode_candidate_from_memmap
def candidate(raw, sample_count, coarse, number, **kwargs):
self.current = defaultdict(Stage)
started = time.perf_counter()
try:
outcome = original_decode(raw, sample_count, coarse, number, **kwargs)
elapsed = time.perf_counter() - started
kind = "valid" if outcome.accepted else "false"
self.rows.append({"kind": kind, "seconds": elapsed, "reason": outcome.reason,
"offset": coarse.sample_offset, "refined": outcome.refined_start_sample,
"stages": {name: asdict(row) for name, row in self.current.items()}})
# По одному примеру после решения, только для отдельного replay.
if kind not in self.examples and (kind == "valid" or outcome.reason.startswith("PHY_HEADER_MAGIC")):
self.examples[kind] = (raw.copy(), sample_count, coarse)
return outcome
finally:
self.current = None
offline.decode_candidate_from_memmap = candidate
try:
yield self
finally:
offline.decode_candidate_from_memmap = original_decode
for owner, name, original in reversed(patches):
setattr(owner, name, original)
def summary(self) -> dict:
result = {"shared_stages": {name: asdict(row) for name, row in self.shared.items()}}
for kind in ("valid", "false"):
selected = [row for row in self.rows if row["kind"] == kind]
stages = defaultdict(Stage)
for row in selected:
for name, stage in row["stages"].items():
stages[name].calls += stage["calls"]
stages[name].seconds += stage["seconds"]
stages[name].exclusive_seconds += stage["exclusive_seconds"]
times = [row["seconds"] for row in selected]
result[kind] = {"count": len(times), "mean_seconds": statistics.mean(times) if times else None,
"median_seconds": statistics.median(times) if times else None,
"total_seconds": sum(times),
"stages": {name: asdict(row) for name, row in stages.items()}}
return result
def array_observation(function) -> dict:
"""Нижняя граница числа массивов в локальных переменных проектного Python.
Временные выражения и внутренности C/SciPy не видны line tracer-у.
tracemalloc отдельно считает пик отслеживаемой памяти, включая NumPy.
Эти проходы НИКОГДА не используются как измерение производительности.
"""
seen = {}
counters = {"observed_array_objects": 0, "observed_owned_arrays": 0, "observed_owned_bytes_total": 0}
def trace(frame, event, argument):
filename = frame.f_code.co_filename.replace("\\", "/")
if not ("/experiments/" in filename or "/protocol/" in filename):
return None
if event in ("line", "return"):
for value in frame.f_locals.values():
if not isinstance(value, np.ndarray):
continue
identity = id(value)
if identity in seen and seen[identity]() is value:
continue
seen[identity] = weakref.ref(value)
counters["observed_array_objects"] += 1
if value.flags.owndata:
counters["observed_owned_arrays"] += 1
counters["observed_owned_bytes_total"] += value.nbytes
return trace
previous = sys.gettrace()
try:
sys.settrace(trace)
function()
finally:
sys.settrace(previous)
tracemalloc.start()
try:
function()
current, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
return {**counters, "tracemalloc_retained_bytes": current, "tracemalloc_peak_bytes": peak,
"array_count_is_lower_bound": True}
def run_functional_tests() -> None:
assert video.phy.PhyMode(500_000).samples_per_symbol == 16
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
with Image.open(ROOT / "data/raw/lab011_scene.jpg") as source:
sequence = video.encode_sequence(source, video.PROFILES[2], 3)
profiler = StageProfiler()
with profiler.measure():
benchmark = video.benchmark_receiver(sequence, 500_000)
result = {"benchmark": benchmark, "profile": profiler.summary(), "rf_tx": 0}
(OUTPUT / "baseline_profile.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result["profile"], indent=2), flush=True)
# Небольшие ограниченные окна, не полная запись. В Git не включать.
metadata = {}
for kind, (raw, count, coarse) in profiler.examples.items():
np.save(OUTPUT / f"baseline_{kind}_window.npy", raw)
metadata[kind] = {"sample_count": count, "offset": coarse.sample_offset, "score": coarse.score}
with streaming_mode(video.phy.PhyMode(500_000)):
call = lambda: offline.decode_candidate_from_memmap(
raw, count, coarse, 1,
read_iq_window=lambda data, start, end: data[2*start:2*end].view(np.complex64))
result.setdefault("array_observation", {})[kind] = array_observation(call)
(OUTPUT / "baseline_examples.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
result["process_memory_after_profiling"] = process_memory()
(OUTPUT / "baseline_profile.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result["array_observation"]), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,364 @@
"""Lab051: общий front-end и эквивалентный timing на 500 ксимв/с.
PHY, FEC, CRC и порог первого каскада заимствованы без изменения правил.
Подмены старых функций действуют только внутри последовательного push.
"""
from __future__ import annotations
from collections import Counter
from contextlib import contextmanager
from functools import lru_cache
import math
import time
import numpy as np
from scipy.signal import convolve, fftconvolve, oaconvolve, firwin, resample_poly
from experiments import lab046b_continuous_iq as iq
from experiments import lab046c_offline_recovery as offline
from experiments import lab050_streaming_receiver as previous
phy = previous.phy
lab045 = phy.lab045
lab043 = lab045.lab044.lab043
ORIGINAL_REFINE = lab043._refine_frame_start
ORIGINAL_TIMING = lab043._preamble_timing_offset
ORIGINAL_MATCHED = lab043._matched_waveform
ORIGINAL_VITERBI = lab045.conv.viterbi_decode_soft
@lru_cache(maxsize=4)
def timing_kernel(samples_per_symbol: int) -> np.ndarray:
"""Те же символы преамбулы на исходной сетке отсчётов."""
expected = iq.radio.bpsk_modulate(lab045.lab044.preamble_bits())
kernel = np.zeros((expected.size - 1) * samples_per_symbol + 1, np.complex64)
kernel[::samples_per_symbol] = np.conj(expected)
return kernel[::-1].copy()
def fast_preamble_timing(matched: np.ndarray, nominal: int, expected_symbols: np.ndarray) -> int:
"""Все прежние 1601 смещения; отрицательные/усечённые окна имеют score=0.
Корреляция с разреженной преамбулой эквивалентна vdot каждой выборки.
Энергии получаются разностями сумм на каждой из 16 фаз. Диапазон не сужен.
"""
samples_per_symbol = lab045.SAMPLES_PER_SYMBOL
offsets = np.arange(-800, 801)
starts = nominal + offsets
count = len(expected_symbols)
# Тело не участвует ни в одном из прежних 1601 timing-окон.
matched = matched[:nominal + 800 + (count - 1) * samples_per_symbol + 1]
valid = (starts >= 0) & (starts + (count - 1) * samples_per_symbol < matched.size)
scores = np.zeros(offsets.size, np.float64)
if np.any(valid):
kernel = timing_kernel(samples_per_symbol)
correlations = fftconvolve(matched, kernel, mode="valid")
padding = (-matched.size) % samples_per_symbol
power = np.pad(np.abs(matched) ** 2, (0, padding)).reshape(-1, samples_per_symbol)
cumulative = np.concatenate((np.zeros((1, samples_per_symbol)),
np.cumsum(power, axis=0, dtype=np.float64))).ravel()
selected = starts[valid]
energy = cumulative[selected + count * samples_per_symbol] - cumulative[selected]
scores[valid] = np.abs(correlations[selected]) / np.sqrt(count * np.maximum(energy, 0) + 1e-30)
largest = np.partition(scores, -2)[-2:]
if largest[-1] > 0 and largest[-1] - largest[-2] <= 32 * np.finfo(np.float32).eps * largest[-1]:
# FFT меняет последние разряды. На неразделимых пиках сохраняем
# исходное суммирование и его tie→первый offset, включая чистый тон.
return ORIGINAL_TIMING(matched, nominal, expected_symbols)
return int(offsets[int(np.argmax(scores))])
@lru_cache(maxsize=8)
def carrier_rotation(length: int) -> np.ndarray:
"""Кэш переносов не очищается при каждом входном блоке старым адаптером."""
indexes = np.arange(length)
return np.exp(-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / lab045.SAMPLE_RATE_HZ).astype(np.complex64)
@lru_cache(maxsize=8)
def dc_response(length: int, samples_per_symbol: int) -> np.ndarray:
"""Отклик локального окна постоянной составляющей после переноса/RRC."""
rotation = carrier_rotation(length)
return oaconvolve(rotation, lab043._matched_filter_taps(), mode="full")
@lru_cache(maxsize=4)
def decimation_taps(decimation: int) -> np.ndarray:
"""Штатные коэффициенты resample_poly(up=1, window=('kaiser', 8))."""
return firwin(20 * decimation + 1, 1 / decimation, window=("kaiser", 8)).astype(np.float32)
@lru_cache(maxsize=8)
def detection_rotation(length: int, decimation: int) -> np.ndarray:
return np.exp(-2j * np.pi * lab045.CARRIER_OFFSET_HZ * np.arange(length)
/ (lab045.SAMPLE_RATE_HZ / decimation)).astype(np.complex64)
class DecimationCache:
"""Повторно использовать внутреннюю часть FIR, пересчитать только края.
resample_poly имеет поддержку ±10 выходных отсчётов. После сдвига окна
неизменная внутренняя область даёт ровно те же значения; левый нулевой
край и новое продолжение считаются с прежними коэффициентами заново.
"""
def __init__(self, decimation: int):
self.decimation = decimation
self.base = self.input_count = 0
self.values = np.empty(0, np.complex64)
self.reused_samples = self.filtered_input_samples = 0
def get(self, samples: np.ndarray, base: int) -> np.ndarray:
stride = self.decimation
count = math.ceil(samples.size / stride)
shift = (base - self.base) // stride
low = max(10, -shift + 10)
high = min(count - 10, self.values.size - 11 - shift)
def filtered(array):
self.filtered_input_samples += array.size
return resample_poly(array, 1, stride, window=decimation_taps(stride))
if not self.values.size or (base - self.base) % stride or high <= low:
output = filtered(samples)
else:
output = np.empty(count, np.complex64)
output[low:high] = self.values[low + shift:high + shift]
self.reused_samples += high - low
prefix_end = min(samples.size, (low + 11) * stride)
output[:low] = filtered(samples[:prefix_end])[:low]
suffix_start = (high - 11) * stride
output[high:] = filtered(samples[suffix_start:])[11:11 + count - high]
self.values = output
self.base, self.input_count = base, samples.size
return output
def fast_viterbi(soft_values: np.ndarray, information_bit_count: int) -> np.ndarray:
"""Та же евклидова метрика и tie→branch 0, с готовыми метриками ветвей."""
conv = lab045.conv
if not isinstance(information_bit_count, (int, np.integer)) or information_bit_count <= 0:
return ORIGINAL_VITERBI(soft_values, information_bit_count)
values = np.asarray(soft_values, dtype=np.float64)
if (values.ndim != 1 or values.size != conv.encoded_bit_count(information_bit_count)
or not np.all(np.isfinite(values))):
return ORIGINAL_VITERBI(soft_values, information_bit_count)
step_count = int(information_bit_count) + conv.TAIL_BIT_COUNT
received = values.reshape(step_count, 2)
# Всего четыре различных пары BPSK-символов. Порядок арифметики исходный.
negative = (received + 1.0) ** 2
positive = (received - 1.0) ** 2
metrics = np.column_stack((negative[:, 0] + negative[:, 1], negative[:, 0] + positive[:, 1],
positive[:, 0] + negative[:, 1], positive[:, 0] + positive[:, 1]))
symbols = conv._BRANCH_SYMBOLS
codes = (2 * (symbols[:, :, 0] > 0) + (symbols[:, :, 1] > 0)).astype(np.intp)
metric_zero = metrics[:, codes[:, 0]]
metric_one = metrics[:, codes[:, 1]]
predecessor_zero = conv._PREDECESSORS[:, 0]
predecessor_one = conv._PREDECESSORS[:, 1]
path = np.full(conv.STATE_COUNT, 1e18)
path[0] = 0.0
choices = np.empty((step_count, conv.STATE_COUNT), np.uint8)
for step in range(step_count):
zero = path[predecessor_zero] + metric_zero[step]
one = path[predecessor_one] + metric_one[step]
choices[step] = one < zero
path = np.minimum(zero, one)
decoded = np.empty(step_count, np.uint8)
state = 0
for step in range(step_count - 1, -1, -1):
decoded[step] = state >> (conv.MEMORY - 1)
state = int(conv._PREDECESSORS[state, choices[step, state]])
assert state == 0
return decoded[:information_bit_count]
class BlockFrontEnd:
"""Один ограниченный общий участок; кандидаты читают его представления.
Конечные края и локальное вычитание среднего восстановлены отдельно:
нельзя без этого подменять фильтрацию изолированного окна непрерывной.
"""
def __init__(self, samples: np.ndarray, owned_end: int, statistics: Counter,
*, decimator: DecimationCache | None = None, base: int = 0):
self.samples = samples
self.statistics = statistics
self.decimator, self.base = decimator, base
self.reference = lab045.lab044._preamble_reference_samples()
self.reference_length = (16 + lab043.PILOT_SYMBOL_COUNT + 10) * lab045.SAMPLES_PER_SYMBOL
self.probe_symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT
self.probe_waveform = (self.probe_symbols + 42) * lab045.SAMPLES_PER_SYMBOL
self.front_length = min(samples.size, owned_end + self.probe_waveform + offline.REFINEMENT_MARGIN_SAMPLES)
self.owned_end = owned_end
self.prefix_correlation = None
self.changes = None
self.matched = None
self.baseband = None
self.soft_cache = {}
def coarse_scores(self, samples: np.ndarray, reference: np.ndarray) -> np.ndarray:
"""Сохранить DC всего старого окна, считать корреляцию только owned+1."""
decimation = lab043.DETECTION_DECIMATION
needed = math.ceil(self.owned_end / decimation) + reference.size + 1
raw = (resample_poly(samples, 1, decimation, window=decimation_taps(decimation)) if self.decimator is None
else self.decimator.get(samples, self.base))
mean = np.mean(raw, dtype=np.complex128).astype(np.complex64)
values = raw[:needed].copy()
values -= mean
values *= detection_rotation(values.size, decimation)
if values.size < reference.size:
return np.empty(0, np.float32)
scores = lab043._normalized_sliding_correlation(values, reference).astype(np.float32)
scores[~np.isfinite(scores)] = 0
np.clip(scores, 0, 1, out=scores)
return scores
def local_start(self, array: np.ndarray) -> int:
return (array.ctypes.data - self.samples.ctypes.data) // self.samples.itemsize
def refine(self, samples: np.ndarray, reference: np.ndarray, coarse_start: int) -> int:
start = self.local_start(samples)
low = max(0, coarse_start - 2000)
high = min(samples.size, coarse_start + self.reference_length + 2000)
if high - low < self.reference_length or start < 0:
return ORIGINAL_REFINE(samples, reference, coarse_start)
if self.changes is None:
self.changes = np.empty(self.front_length, np.int64)
self.changes[0] = 0
np.cumsum(self.samples[1:self.front_length] != self.samples[:self.front_length-1],
dtype=np.int64, out=self.changes[1:])
if self.changes[start + samples.size - 1] == self.changes[start]:
# После вычитания локального среднего точно нулевой сигнал.
# Общая FFT может оставить шум округления вместо нулевого tie.
return low
if self.prefix_correlation is None:
centered = self.reference[:self.reference_length] - np.mean(self.reference[:self.reference_length])
self.centered_reference_sum = np.sum(np.conj(centered), dtype=np.complex128)
self.prefix_correlation = fftconvolve(
self.samples[:self.front_length], np.conj(centered[::-1]), mode="valid")
self.statistics["shared_refinement_correlations"] += 1
first = start + low
last = start + high - self.reference_length + 1
correlation = self.prefix_correlation[first:last]
if not correlation.size:
return ORIGINAL_REFINE(samples, reference, coarse_start)
# Та же DC-поправка, что у корреляции (samples - mean(probe)).
correlation = correlation - np.mean(samples) * self.centered_reference_sum
magnitudes = np.abs(correlation)
if magnitudes.size > 1:
largest = np.partition(magnitudes, -2)[-2:]
if largest[-1] - largest[-2] <= 32*np.finfo(np.float32).eps*largest[-1]:
self.statistics["refinement_tie_fallbacks"] += 1
return ORIGINAL_REFINE(samples, reference, coarse_start)
return low + int(np.argmax(magnitudes))
def matched_window(self, samples: np.ndarray, start: int, length: int):
absolute = self.local_start(samples) + start
taps = lab043._matched_filter_taps()
edge = taps.size - 1
if absolute < 0 or absolute + length + edge > self.front_length:
self.statistics["local_body_matched_calls"] += 1
segment = samples[start:start + length] - np.mean(samples)
baseband = segment * carrier_rotation(length)
return oaconvolve(baseband, taps, mode="full"), 16 * lab045.SAMPLES_PER_SYMBOL + edge
if self.matched is None:
self.baseband = self.samples[:self.front_length] * carrier_rotation(self.front_length)
self.matched = oaconvolve(self.baseband, taps, mode="full")
self.statistics["shared_matched_calls"] += 1
matched = self.matched[absolute:absolute + length + edge].copy()
# Восстановить нулевые края исходного локального окна, без соседнего IQ.
matched[:edge] = convolve(self.baseband[absolute:absolute + edge], taps, method="direct")[:edge]
matched[-edge:] = convolve(self.baseband[absolute + length - edge:absolute + length], taps,
method="direct")[edge:]
phase = np.complex64(np.exp(2j * np.pi * lab043.CARRIER_OFFSET_HZ * absolute / lab045.SAMPLE_RATE_HZ))
matched *= phase
mean = np.mean(samples)
matched -= mean * dc_response(length, lab045.SAMPLES_PER_SYMBOL)
nominal = 16 * lab045.SAMPLES_PER_SYMBOL + edge
return matched, nominal
def timing(self, matched, nominal, expected):
"""Локальный FFT оказался дешевле дополнительных общих timing-массивов."""
return fast_preamble_timing(matched, nominal, expected)
def extract(self, samples: np.ndarray, frame_start: int, symbol_count: int) -> iq.SoftWindow:
key = (self.local_start(samples) + frame_start, symbol_count)
# Усечение зависит и от доступности текущего probe, а не только от start.
length = (symbol_count + 42) * lab045.SAMPLES_PER_SYMBOL
if frame_start < 0 or frame_start + length > samples.size:
return iq.SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW")
if key in self.soft_cache:
self.statistics["soft_window_cache_hits"] += 1
return self.soft_cache[key]
segment = samples[frame_start:frame_start + length]
matched, nominal = lab043._matched_waveform(segment, 0, length)
bits = lab045.lab044.preamble_bits()
expected = iq.radio.bpsk_modulate(bits)
timing = lab043._preamble_timing_offset(matched, nominal, expected)
carrier = lab043._evaluate_symbols(matched, nominal, timing, bits, lab045.PREAMBLE_BIT_COUNT)
if carrier["abs_h"] == 0:
result = iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "ZERO_GAIN")
else:
symbols = matched[nominal + timing:nominal + timing + symbol_count * lab045.SAMPLES_PER_SYMBOL:
lab045.SAMPLES_PER_SYMBOL].astype(np.complex128)
if symbols.size != symbol_count:
result = iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS")
else:
symbol_time = (np.arange(symbol_count, dtype=np.float64) + 0.5) / lab045.SYMBOL_RATE
normalized = symbols * np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time)
normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
result = iq.SoftWindow(True, normalized.real, int(timing),
int((frame_start + nominal + timing) % lab045.SAMPLES_PER_SYMBOL),
nominal, carrier, None)
self.soft_cache[key] = result
self.statistics["unique_soft_windows"] += 1
return result
@contextmanager
def optimized_context(front: BlockFrontEnd):
targets = ((lab043, "_refine_frame_start", front.refine),
(lab043, "_matched_waveform", front.matched_window),
(lab043, "_preamble_timing_offset", front.timing),
(iq, "_extract_soft_window", front.extract),
(lab045.conv, "viterbi_decode_soft", fast_viterbi),
(offline, "_correlation_scores", front.coarse_scores))
originals = [(owner, name, getattr(owner, name)) for owner, name, unused in targets]
try:
for owner, name, function in targets:
setattr(owner, name, function)
yield
finally:
for owner, name, function in reversed(originals):
setattr(owner, name, function)
class StreamingReceiver(previous.StreamingReceiver):
"""Совместимый API Lab050, ускорение только для рабочей точки 500 ксимв/с."""
def __init__(self, symbol_rate: int = 500_000, *, block_samples: int = 65_536):
if symbol_rate != 500_000:
raise ValueError("Lab051 исследует только 500 ксимв/с")
super().__init__(symbol_rate, block_samples=block_samples)
self.front_statistics = Counter()
self.decimator = DecimationCache(self.decimation)
def _scan(self, owned_end: int) -> list[previous.ReceivedPacket]:
front = BlockFrontEnd(self.buffer, owned_end - self.base_sample, self.front_statistics,
decimator=self.decimator, base=self.base_sample)
with optimized_context(front):
return super()._scan(owned_end)
def finish(self):
result = super().finish()
self.decimator.values = np.empty(0, np.complex64)
return result
def run_functional_tests() -> None:
receiver = StreamingReceiver()
assert receiver.mode.samples_per_symbol == 16
assert receiver.finish() == []

View File

@@ -0,0 +1,160 @@
"""Регрессии вычислительной архитектуры Lab051, без файлов/оборудования."""
from __future__ import annotations
from collections import Counter
from dataclasses import replace
import numpy as np
import pytest
from scipy.signal import resample_poly
from experiments import lab051_streaming_receiver as lab
from experiments.lab050_streaming_receiver import StreamingReceiver as PreviousReceiver
from experiments.lab050_streaming_receiver import streaming_mode
@pytest.mark.parametrize("seed", range(8))
def test_all_timing_offsets_preserved_including_negative_start(seed):
random_generator = np.random.default_rng(seed)
samples = (random_generator.normal(size=23616) + 1j * random_generator.normal(size=23616)).astype(np.complex64)
with streaming_mode(lab.phy.PhyMode(500000)):
expected = lab.iq.radio.bpsk_modulate(lab.lab045.lab044.preamble_bits())
if seed % 2:
samples[416 + 47:416 + 47 + len(expected)*16:16] += expected * 3
old = lab.ORIGINAL_TIMING(samples, 416, expected)
assert lab.fast_preamble_timing(samples, 416, expected) == old
def test_zero_timing_tie_is_same_first_offset():
with streaming_mode(lab.phy.PhyMode(500000)):
expected = lab.iq.radio.bpsk_modulate(lab.lab045.lab044.preamble_bits())
assert lab.fast_preamble_timing(np.zeros(23616, np.complex64), 416, expected) == -800
@pytest.mark.parametrize("value", [1, 1j, 1+1j, 44])
def test_equal_nonzero_timing_scores_preserve_original_tie(value):
with streaming_mode(lab.phy.PhyMode(500000)):
expected = lab.iq.radio.bpsk_modulate(lab.lab045.lab044.preamble_bits())
samples = np.full(23616, value, np.complex64)
assert lab.fast_preamble_timing(samples, 416, expected) == lab.ORIGINAL_TIMING(samples,416,expected)
@pytest.mark.parametrize("value", [1, 1j, 44])
def test_refinement_constant_iq_preserves_zero_after_local_dc(value):
with streaming_mode(lab.phy.PhyMode(500000)):
samples=np.full(100000,value,np.complex64)
front=lab.BlockFrontEnd(samples,65536,Counter())
window=samples[1000:28456]
assert front.refine(window,front.reference,2000)==lab.ORIGINAL_REFINE(window,front.reference,2000)==0
def test_refinement_equal_nonzero_peaks_preserve_first_maximum():
with streaming_mode(lab.phy.PhyMode(500000)):
samples=np.zeros(100000,np.complex64)
samples[1200]=samples[2200]=1
front=lab.BlockFrontEnd(samples,65536,Counter())
reference=np.zeros(front.reference_length,np.complex64)
reference[0],reference[1]=1,-1
front.reference=reference
window=samples[1000:28456]
assert front.refine(window,reference,2000)==lab.ORIGINAL_REFINE(window,reference,2000)==199
@pytest.mark.parametrize("seed", range(5))
def test_shared_matched_preserves_local_dc_and_both_zero_padded_edges(seed):
random_generator = np.random.default_rng(seed)
values = (random_generator.normal(size=110000) + 1j * random_generator.normal(size=110000) + 4 + 3j).astype(np.complex64)
with streaming_mode(lab.phy.PhyMode(500000)):
front = lab.BlockFrontEnd(values, 70000, Counter())
segment = values[5000 + seed:5000 + seed + 23456]
expected, nominal = lab.ORIGINAL_MATCHED(segment, 0, len(segment))
actual, actual_nominal = front.matched_window(segment, 0, len(segment))
assert actual_nominal == nominal
np.testing.assert_allclose(actual, expected, atol=2e-5, rtol=2e-5)
def test_reused_decimation_is_bit_exact_for_moving_and_truncated_windows():
random_generator = np.random.default_rng(51001)
values = (random_generator.normal(size=1800000) + 1j * random_generator.normal(size=1800000)).astype(np.complex64)
cache = lab.DecimationCache(8)
for base in range(0, len(values) - 10000, 65536):
window = values[base:base + 400123]
expected = resample_poly(window, 1, 8, window=("kaiser", 8))
np.testing.assert_array_equal(cache.get(window, base), expected)
assert cache.values.size <= 50016
assert cache.reused_samples > 1000000
@pytest.mark.parametrize("seed,count", [(0, 1), (1, 32), (2, 244*8), (3, 268*8), (4, 1024*8)])
def test_viterbi_exact_bits_for_noisy_metrics_and_ties(seed, count):
random_generator = np.random.default_rng(seed)
for values in (np.zeros(lab.lab045.conv.encoded_bit_count(count)),
random_generator.normal(size=lab.lab045.conv.encoded_bit_count(count)),
random_generator.choice([-1., 1.], size=lab.lab045.conv.encoded_bit_count(count))):
np.testing.assert_array_equal(lab.fast_viterbi(values, count), lab.ORIGINAL_VITERBI(values, count))
@pytest.mark.parametrize("values,count", [(np.array([np.nan, 0.]), 1), (np.zeros((2,2)), 1),
(np.zeros(12), 1), (np.zeros(12), 0), (np.zeros(12), 1.5)])
def test_viterbi_rejects_same_invalid_input(values, count):
with pytest.raises((ValueError, TypeError)):
lab.fast_viterbi(values, count)
def signal_with_unknown_offsets(seed=51):
random_generator = np.random.default_rng(seed)
plan = lab.lab045.build_transmission_plan(bytes(range(256)))
values = lab.phy.build_parameterized_tx_complex(plan, lab.phy.PhyMode(500000)).astype(np.complex64)
values *= np.exp(1j * (0.73 + 2*np.pi*10*np.arange(len(values))/8000000)).astype(np.complex64)
values += (random_generator.normal(size=len(values)) + 1j*random_generator.normal(size=len(values))).astype(np.complex64)
return np.concatenate((np.zeros(63217,np.complex64), values, np.zeros(450000,np.complex64)))
@pytest.mark.parametrize("block", [32749, 65536])
def test_independent_stream_packets_equal_old_decoder_across_block_boundaries(block):
samples = signal_with_unknown_offsets()
results = []
for receiver in (PreviousReceiver(500000), lab.StreamingReceiver()):
output = []
for start in range(0,len(samples),block):
output.extend(receiver.push_iq_block(samples[start:start + block]))
assert [item.payload for item in output] == [bytes(range(256))]
output.extend(receiver.finish())
results.append([(item.start_sample,item.end_sample,item.payload) for item in output])
assert results[0] == results[1]
def test_invalid_push_and_finish_preserve_bounded_state():
receiver = lab.StreamingReceiver()
for invalid in (np.array([complex(float("nan"),0)]), np.zeros((1,2)), np.zeros(65537)):
with pytest.raises(ValueError): receiver.push_iq_block(invalid)
assert receiver.total_samples == 0
for unused in range(100):
assert receiver.push_iq_block(np.zeros(65536, np.complex64)) == []
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert receiver.decimator.values.size <= (receiver.buffer_limit_samples + 7)//8
assert receiver.finish() == []
assert receiver.buffer.size == receiver.decimator.values.size == 0
with pytest.raises(RuntimeError): receiver.finish()
with pytest.raises(RuntimeError): receiver.push_iq_block(np.zeros(1,np.complex64))
def test_truncated_frame_cannot_reach_crc_pass():
samples = signal_with_unknown_offsets()[:100000]
receiver = lab.StreamingReceiver()
output = []
for start in range(0,len(samples),65536):
output.extend(receiver.push_iq_block(samples[start:start+65536]))
assert output + receiver.finish() == []
def test_context_restores_functions_even_on_exception():
originals = (lab.lab043._matched_waveform, lab.iq._extract_soft_window,
lab.lab045.conv.viterbi_decode_soft, lab.offline._correlation_scores)
with streaming_mode(lab.phy.PhyMode(500000)):
front = lab.BlockFrontEnd(np.zeros(100000,np.complex64),65536,Counter())
with pytest.raises(RuntimeError):
with lab.optimized_context(front): raise RuntimeError("test")
assert originals == (lab.lab043._matched_waveform, lab.iq._extract_soft_window,
lab.lab045.conv.viterbi_decode_soft, lab.offline._correlation_scores)