diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index e53c49d..d8095ba 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -1270,3 +1270,100 @@ fetch не выполнялись. STOP. Разрешение на единственный аппаратный процесс Lab047 израсходовано. Новый RF, повтор передачи, изменение порога, PHY, FEC, CRC или Lab045 без отдельной новой постановки не выполнять. `RF TX = 0`; HackRF не запускать. + +# Запись 035 + +## Дата + +3 сентября 2026 года + +## Тема + +Lab048: профилирование и ускорение автономного приёмника Lab047 до обработки +быстрее реального времени. + +## Выполнено + +Работа выполнена только на сохранённой аппаратной RX-записи Lab047 +`C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\rx_stream.cs8`: +120000000 комплексных отсчётов, 240000000 байт, 15,0 с, SHA-256 +`8521bf1b574cd1fe4e0a4f980c00c8664f8bb51551a241e46449e1eb2c77e635`. +Новый RF не выполнялся, `RF TX = 0`, HackRF не запускался. + +Исходная контрольная точка: полное время 37,423910 с, строгая ступень +32,459969 с, в среднем 0,470434 с на один из 69 строгих кандидатов, +приблизительный пик памяти 1545755352 байта, около 1,44 ГиБ. Результат Lab047: +36/36 кадров, CRC PASS/FAIL `36/0`, полный JPEG, точный SHA-256, 0 ошибочных +байтов и битов. + +До изменения кода выполнен один полный проход под `cProfile`. Профилировщик +увеличил время до 45,947287 с, но сохранил 185828 локальных максимумов, 32414 +кандидатов первого каскада, 69 строгих проверок, 36 действительных кадров и +точный JPEG. Основные затраты: `decode_candidate_from_memmap` 40,912 с, +`_extract_soft_window` 25,697 с, `_matched_waveform` 21,199 с, +`fftconvolve` 13,660 с, формирование RRC-коэффициентов 11,885 с и временная +синхронизация 4,140 с. Опорная преамбула формировалась 70 раз, +RRC-коэффициенты — 175 раз. Витерби занял только 0,875 с на 36 кадров, +CFO/фаза 0,054 с, PHY-заголовок 0,006 с; эти части не оптимизировались. + +Реализованы ограниченные вычислительные оптимизации без изменения научных +критериев: кэшированы неизменяемые опорная PRBS7-преамбула и RRC-коэффициенты; +полная линейная RRC-свёртка переведена с `fftconvolve` на математически +эквивалентный overlap-add; кэшированы не более четырёх векторов переноса +несущей; рабочие массивы согласованного фильтра переведены с `complex128` на +`complex64`, коэффициенты — на `float32`; те же 1601 преамбульные оценки +timing считаются группами по 400 фазам без второго одинакового набора оценок. + +Прямое сравнение старого и нового согласованного фильтра ограничивает +абсолютное расхождение величиной `2e-6` и относительное к пику величиной +`5e-7`. Максимальное измеренное абсолютное расхождение равно `1,263e-6`. +Отдельные тесты подтверждают точность и неизменяемость кэшей, ограниченный +размер кэша, тот же timing offset и отсутствие TX/expected-frame параметров у +независимого API. Связанные тесты дали 169 passed. + +После программного шлюза выполнен один итоговый полный проход той же записи. +Первый вызов нового командного файла остановился до импорта проекта из-за +отсутствующего загрузочного пути; RX не открывался и файл-свидетель не был +создан. После исправления загрузочного блока выполнен ровно один фактический +полный проход без автоматического повтора. + +Итоговое время равно 13,966058 с: грубый поиск 4,776677 с, строгая ступень +9,171629 с, средняя строгая проверка 0,132922 с. Ускорение полного прохода +2,679633 раза, отношение времени обработки к 15,0 с эфира 0,931071: +`REAL-TIME CAPABLE = YES`. Сохранены все исходные счётчики: 185828 локальных +максимумов, 32414 кандидатов первого каскада, 69 строгих проверок, 32345 +пропущенных максимумов внутри принятых областей и 0 точных дубликатов. +PHY-заголовок PASS/FAIL `36/33`, FEC attempts/PASS/FAIL `36/36/0`, CRC +PASS/FAIL `36/0`, действительных кадров и фрагментов `36/36`, отсутствующих +фрагментов 0, `OBJECT COMPLETE = YES`. + +Независимый приёмник не получал ожидаемого числа кадров, TX offsets, номеров +TX-фрагментов, переданного JPEG, пакетов или кодированных битов. Только после +завершённого независимого вердикта подтверждены 36/36 переданных кадров, 19 +ошибок кодированных бит до FEC, 0 ошибок после FEC и 0 ложных CRC PASS. +Восстановленный JPEG имеет 8724 байта, RGB 320 x 240, декодируется Pillow и +имеет тот же SHA-256 +`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`; +ошибочных байтов 0, ошибочных битов 0. + +После оптимизации крупнейшее верхнеуровневое узкое место — грубый поиск, +4,776677 с. В строгой ступени согласованный фильтр занимает 3,282575 с, +уточнение начала 3,121533 с, timing 1,270783 с, Витерби 0,627109 с. Пиковая +оценка памяти осталась около 1,44 ГиБ, потому что её определяет неизменённый +16-миллионный блок грубого поиска; уменьшение блока не выполнялось, чтобы не +изменять границы и результат детектора. + +Созданы `experiments/lab048_receiver_performance.py`, +`tests/test_lab048_receiver_performance.py`, `docs/lab048_current_state.md` и +малые артефакты в `data/processed/lab048`. Изменены только вычислительные +примитивы в `experiments/lab043_hackrf_bpsk.py`, +`experiments/lab044_hackrf_bpsk_packet.py` и +`experiments/lab046b_continuous_iq.py`, а также этот журнал и указатель +лабораторных. До итогового прохода `python tools/quick_gate.py` прошёл с 547 +тестами и 11 функциональными проверками; `python -m pytest tests/ -q` дал 547 +passed и 0 failed; `git diff --check` прошёл. + +## Следующий этап + +STOP. Не переходить к Lab049, видео, QPSK, повышению символьной скорости или +новому RF-сеансу без отдельной постановки. `RF TX = 0`; HackRF не запускать. diff --git a/data/processed/lab048/final_processing_attempt.json b/data/processed/lab048/final_processing_attempt.json new file mode 100644 index 0000000..4205f6a --- /dev/null +++ b/data/processed/lab048/final_processing_attempt.json @@ -0,0 +1,15 @@ +{ + "capture_path": "C:\\Users\\user\\Documents\\SDR_Rover_Lab047_raw\\20260903_115747\\rx_stream.cs8", + "original_result_path": "C:\\Users\\user\\Documents\\SDR_Rover_Lab047_raw\\20260903_115747\\independent_result.json", + "threshold": 0.030964473262429237, + "automatic_retry": false, + "receiver_oracles": { + "expected_frame_count": false, + "tx_coded_bits": false, + "tx_fragment_indexes": false, + "tx_frame_offsets": false, + "tx_image": false, + "tx_packets": false + }, + "rf_tx": 0 +} \ No newline at end of file diff --git a/data/processed/lab048/recovered_image.jpg b/data/processed/lab048/recovered_image.jpg new file mode 100644 index 0000000..6e43702 Binary files /dev/null and b/data/processed/lab048/recovered_image.jpg differ diff --git a/data/processed/lab048/result.json b/data/processed/lab048/result.json new file mode 100644 index 0000000..1e0713d --- /dev/null +++ b/data/processed/lab048/result.json @@ -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": 13.966058199992403, + "coarse_search_seconds": 4.77667730016401, + "strict_stage_seconds": 9.171629499876872, + "average_strict_candidate_seconds": 0.13292216666488219, + "approximate_peak_bytes": 1545755352, + "stage_measurements": { + "cs8_to_complex64": { + "calls": 113, + "cumulative_seconds": 1.4877978002768941 + }, + "refine_start": { + "calls": 69, + "cumulative_seconds": 3.1215326999663375 + }, + "matched_filter": { + "calls": 105, + "cumulative_seconds": 3.2825752997305244 + }, + "timing": { + "calls": 105, + "cumulative_seconds": 1.270783199404832 + }, + "cfo_phase_soft": { + "calls": 105, + "cumulative_seconds": 0.042235699598677456 + }, + "phy_header": { + "calls": 105, + "cumulative_seconds": 0.004110799578484148 + }, + "post_preamble_fec_crc": { + "calls": 36, + "cumulative_seconds": 0.645559300086461 + }, + "viterbi": { + "calls": 36, + "cumulative_seconds": 0.6271085999323986 + } + }, + "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.6796329690243317, + "processing_to_capture_ratio": 0.9310705466661602, + "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 +} \ No newline at end of file diff --git a/docs/LABS_INDEX.md b/docs/LABS_INDEX.md index 7ed51c9..354d598 100644 --- a/docs/LABS_INDEX.md +++ b/docs/LABS_INDEX.md @@ -176,6 +176,30 @@ `0.030964473262429237` не менялся, второй RF-сеанс не выполнялся. - **Журнал:** записи 033-034 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)). +## Lab048 + +- **Цель:** профилирование и ускорение доказанного автономного BPSK-приёмника + без изменения детектора, PHY, FEC, CRC и порога. +- **Статус:** завершена успешно только на сохранённой RX-записи Lab047; + обработка ускорена с 37,423910 до 13,966058 с, в 2,679633 раза. Для 15,0 с + эфира отношение времени обработки равно 0,931071: + `REAL-TIME CAPABLE = YES`. +- **Результат:** сохранены 36/36 кадров, FEC PASS 36/36, CRC PASS/FAIL 36/0, + полный JPEG, точный SHA-256, 0 ошибочных байтов и битов. Порог + `0.030964473262429237` не изменён. +- **Ограничение:** приблизительный пик памяти остаётся около 1,44 ГиБ из-за + 16-миллионного блока грубого поиска; запас реального времени около 1,03 с. +- **Основной эксперимент:** + [`experiments/lab048_receiver_performance.py`](../experiments/lab048_receiver_performance.py). +- **Проверки:** + [`tests/test_lab048_receiver_performance.py`](../tests/test_lab048_receiver_performance.py). +- **Документ состояния:** + [`docs/lab048_current_state.md`](lab048_current_state.md). +- **Обработанные данные:** машинный результат и восстановленный JPEG находятся + в [`data/processed/lab048`](../data/processed/lab048). +- **RF:** новый радиосеанс не выполнялся, `RF TX = 0`. +- **Журнал:** запись 035 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)). + ## Правило хранения данных Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные diff --git a/docs/lab048_current_state.md b/docs/lab048_current_state.md new file mode 100644 index 0000000..fe1e439 --- /dev/null +++ b/docs/lab048_current_state.md @@ -0,0 +1,206 @@ +# Lab048: профилирование и ускорение автономного BPSK-приёмника + +## Состояние + +Lab048 завершена успешно только на сохранённой аппаратной RX-записи Lab047. +Новый радиосеанс не выполнялся: `RF TX = 0`, HackRF не запускался. Научные +критерии обнаружения и принятия кадра, BPSK, PRBS7, PHY, FEC, CRC, формат +пакета и порог `0.030964473262429237` не изменены. + +Итоговая обработка 15,0 с эфира занимает 13,966058 с. Это в 2,679633 раза +быстрее исходных 37,423910 с и впервые быстрее длительности самой записи: +отношение времени обработки к времени эфира равно 0,931071. + +## Исходная контрольная точка + +Использована та же запись: +`C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\rx_stream.cs8`. +Размер — 240000000 байт, 120000000 комплексных отсчётов, длительность 15,0 с, +SHA-256 `8521bf1b574cd1fe4e0a4f980c00c8664f8bb51551a241e46449e1eb2c77e635`. + +Принятые до оптимизации метрики Lab047: + +- полное время: 37,423910 с; +- строгая ступень: 32,459969 с; +- среднее время строгой проверки: 0,470434 с; +- строгих кандидатов: 69; +- приблизительный пик памяти: 1545755352 байта, около 1,44 ГиБ; +- действительных кадров: 36; +- CRC PASS/FAIL: `36/0`; +- полный JPEG, SHA-256 совпадает, ошибок байтов и битов нет. + +## Базовое профилирование + +До изменения кода выполнен один полный проход под `cProfile`. Он сохранил +185828 локальных максимумов, 32414 кандидатов первого каскада, 69 строгих +проверок, 36 действительных кадров, CRC PASS/FAIL `36/0` и точный JPEG. +Накладные расходы профилировщика увеличили полное время до 45,947287 с, +поэтому коэффициент ускорения считается относительно принятого измерения без +профилировщика 37,423910 с. + +Десять функций по суммарному времени: + +| Функция | Вызовы | Суммарно, с | +|---|---:|---:| +| `_run_with_fixed_candidate_rule` | 1 | 45,945 | +| `stream_process_capture` | 1 | 45,930 | +| `decode_candidate_from_memmap` | 69 | 40,912 | +| `_extract_soft_window` | 105 | 25,697 | +| `_matched_waveform` | 105 | 21,199 | +| `fftconvolve` | 252 | 13,660 | +| `_freq_domain_conv` | 252 | 12,811 | +| `__ua_function__` FFT | 756 | 11,889 | +| `root_raised_cosine_taps` | 175 | 11,885 | +| `_execute_nD` FFT | 756 | 11,881 | + +Десять функций по собственному времени: + +| Функция | Вызовы | Собственное время, с | +|---|---:|---:| +| `scipy.fft c2c` | 756 | 11,222 | +| `_matched_waveform` | 105 | 5,704 | +| `numpy.isclose` | 1400175 | 5,407 | +| `upfirdn.apply_filter` | 9 | 3,171 | +| `_timing_offsets` | 105 | 2,859 | +| `root_raised_cosine_taps` | 175 | 2,306 | +| `_scale_and_carrier` | 70 | 1,801 | +| генератор `numpy.isclose` | 7000875 | 1,160 | +| `_cs8_window` | 113 | 1,105 | +| `_freq_domain_conv` | 252 | 0,917 | + +Профиль показал повторное формирование одной опорной преамбулы 70 раз и +одинаковых RRC-коэффициентов 175 раз. Согласованный фильтр вызывался 105 раз: +69 коротких окон для PHY и 36 полных окон. Временная синхронизация выполняла +1601 Python-итерацию для каждого окна. Витерби занимал лишь 0,875 с на все 36 +кадров, CFO/фаза — 0,054 с на 105 вызовов, PHY-заголовок — 0,006 с. Поэтому +FEC, CFO, фаза, PHY и CRC не оптимизировались. + +## Выполненные оптимизации + +Изменены только вычислительные детали приёмника: + +1. Опорная PRBS7-преамбула формируется один раз, кэшируется и защищается от + записи. +2. RRC-коэффициенты формируются один раз, хранятся как неизменяемый `float32`. +3. Та же полная линейная RRC-свёртка вычисляется методом overlap-add + (`oaconvolve`) вместо единой FFT (`fftconvolve`). +4. Неизменяемые векторы переноса несущей кэшируются по началу и длине окна; + кэш ограничен четырьмя записями. +5. Рабочие массивы согласованного фильтра переведены с `complex128` на + `complex64`. Максимальное абсолютное расхождение с прежним фильтром на + проверочном шумовом массиве равно `1,263e-6`, менее `5e-7` от пика. +6. Преамбульная временная синхронизация считает те же 1601 нормированные + оценки группами по 400 фазам и не вычисляет второй идентичный набор оценок. + +Прямой тест прежнего `fftconvolve/complex128` и нового +`oaconvolve/complex64` требует абсолютное расхождение не более `2e-6` и +отношение максимального расхождения к пику менее `5e-7`. Отдельные тесты +подтверждают точность и неизменяемость кэшей, ограничение их размера, тот же +timing offset и отсутствие TX/expected-frame параметров у независимого API. +Существующие сквозные тесты защищают те же PHY, FEC, CRC и полезные данные. + +## Единственный итоговый полный проход + +После программного шлюза выполнен один итоговый полный проход той же записи. +Первый вызов нового командного файла завершился до импорта проекта из-за +отсутствующего загрузочного пути: RX не открывался и файл-свидетель не был +создан. После исправления загрузочного блока выполнен ровно один фактический +полный проход; автоматического повтора нет. + +Независимая стадия не получала ожидаемое число кадров, TX offsets, номера +TX-фрагментов, переданный JPEG, пакеты или кодированные биты. Только после +`completed = true` и `OBJECT COMPLETE = YES` выполнена диагностическая сверка +с TX. + +Результат: + +- полное время: 13,966058 с; +- грубый поиск: 4,776677 с; +- строгая ступень: 9,171629 с; +- среднее время строгой проверки: 0,132922 с; +- ускорение полного прохода: 2,679633 раза; +- отношение обработки к длительности эфира: 0,931071; +- `REAL-TIME CAPABLE = YES`; +- локальных максимумов: 185828; +- кандидатов первого каскада: 32414; +- строгих проверок: 69; +- пропущено внутри уже принятых областей: 32345; +- точных дубликатов: 0; +- PHY-заголовок PASS/FAIL: `36/33`; +- FEC attempts/PASS/FAIL: `36/36/0`; +- CRC PASS/FAIL: `36/0`; +- действительных кадров и фрагментов: `36/36`; +- отсутствующих фрагментов: 0; +- `OBJECT COMPLETE = YES`. + +Только после независимого вердикта подтверждены 36/36 переданных кадров, +19 ошибок кодированных бит до FEC, 0 ошибок протокольных бит после FEC и 0 +ложных CRC PASS. Восстановленный JPEG имеет 8724 байта, RGB 320 x 240, +декодируется Pillow и имеет SHA-256 +`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`. +SHA-256 переданного файла совпадает; ошибочных байтов 0, ошибочных битов 0. + +## Время стадий после оптимизации + +Измерения вложенных функций пересекаются по времени: + +| Стадия | Вызовы | Суммарно, с | +|---|---:|---:| +| преобразование CS8 в `complex64` | 113 | 1,487798 | +| уточнение начала | 69 | 3,121533 | +| согласованный фильтр | 105 | 3,282575 | +| временная синхронизация | 105 | 1,270783 | +| CFO, фаза и мягкие решения | 105 | 0,042236 | +| PHY-заголовок | 105 | 0,004111 | +| FEC, разбор пакета и CRC | 36 | 0,645559 | +| Витерби | 36 | 0,627109 | + +Следующее узкое место верхнего уровня — грубый поиск, 4,776677 с. Внутри +строгой ступени сопоставимы согласованный фильтр, 3,282575 с, и уточнение +начала, 3,121533 с. Запас реального времени пока мал: около 1,03 с на 15 с +эфира. + +## Память + +Консервативная пиковая оценка осталась 1545755352 байта, около 1,44 ГиБ, +поскольку максимум создаёт неизменённая обработка 16-миллионного блока +грубого поиска. Крупнейшие явно управляемые массивы: + +| Назначение | dtype | shape | MiB | +|---|---|---:|---:| +| окно грубого поиска | `complex64` | 16000000 | 122,070 | +| прореженный поток | `complex64` | 800000 | 6,104 | +| вектор переноса грубого поиска | `complex64` | 800000 | 6,104 | +| оценки корреляции | `float32` | 773561 | 2,951 | +| полное строгое окно | `complex64` | 2306400 | 17,596 | +| кэшированный вектор несущей | `complex64` | 2306400 | 17,596 | +| основная полоса строгого окна | `complex64` | 2306400 | 17,596 | +| выход согласованного фильтра | `complex64` | 2310400 | 17,627 | +| RRC-коэффициенты | `float32` | 4001 | 0,015 | + +Переход строгой ступени с `complex128` на `complex64` уменьшил её рабочие +массивы, но не общий пик всего процесса. Уменьшение блока грубого поиска в +Lab048 не выполнялось, чтобы не менять блочные границы и результаты детектора. + +## Артефакты + +- `experiments/lab048_receiver_performance.py`; +- `tests/test_lab048_receiver_performance.py`; +- `data/processed/lab048/final_processing_attempt.json`; +- `data/processed/lab048/result.json`; +- `data/processed/lab048/recovered_image.jpg`. + +Файл `cProfile` сохранён только как временный локальный профиль вне проекта: +`C:\Users\user\AppData\Local\Temp\lab048_baseline_20260903_6fb7e4e.pstats`. + +## Проверки + +- связанные тесты после поэтапных изменений: 169 passed; +- `python tools/quick_gate.py`: 547 тестов и 11 функциональных проверок, PASS; +- `python -m pytest tests/ -q`: 547 passed, 0 failed; +- `git diff --check`: PASS. + +## Следующий этап + +STOP. Не переходить к Lab049, видео, QPSK, повышению символьной скорости или +новому RF-сеансу без отдельной постановки. `RF TX = 0`; HackRF не запускать. diff --git a/experiments/lab043_hackrf_bpsk.py b/experiments/lab043_hackrf_bpsk.py index 010ec03..a8023ea 100644 --- a/experiments/lab043_hackrf_bpsk.py +++ b/experiments/lab043_hackrf_bpsk.py @@ -25,6 +25,7 @@ BPSK (Binary Phase Shift Keying) — двоичная фазовая манип from __future__ import annotations import argparse +from functools import lru_cache import hashlib import json import math @@ -41,7 +42,7 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np -from scipy.signal import fftconvolve, resample_poly +from scipy.signal import fftconvolve, oaconvolve, resample_poly from experiments.lab043_hackrf_hardware import ( signed_iq_bytes_to_complex64, @@ -362,14 +363,36 @@ def _refine_frame_start(samples: np.ndarray, tx_samples: np.ndarray, coarse_star return low + int(np.argmax(np.abs(correlation))) +@lru_cache(maxsize=1) +def _matched_filter_taps() -> np.ndarray: + """Вернуть неизменяемые RRC-коэффициенты доказанной рабочей точки.""" + + taps = radio.root_raised_cosine_taps( + 0.35, + SAMPLES_PER_SYMBOL, + 10, + ).astype(np.float32) + taps.flags.writeable = False + return taps + + +@lru_cache(maxsize=4) +def _carrier_downconversion_vector(start: int, length: int) -> np.ndarray: + """Один раз вычислить неизменяемый перенос несущей для размера окна.""" + + indexes = start + np.arange(length) + rotation = np.exp( + -2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ + ).astype(np.complex64) + rotation.flags.writeable = False + return rotation + + def _matched_waveform(samples: np.ndarray, start: int, tx_length: int) -> tuple[np.ndarray, int]: segment = samples[start : start + tx_length] - np.mean(samples) - indexes = start + np.arange(len(segment)) - baseband = segment * np.exp( - -2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ - ) - taps = radio.root_raised_cosine_taps(0.35, SAMPLES_PER_SYMBOL, 10) - matched = fftconvolve(baseband, taps, mode="full") + baseband = segment * _carrier_downconversion_vector(start, len(segment)) + taps = _matched_filter_taps() + matched = oaconvolve(baseband, taps, mode="full") nominal = 16 * SAMPLES_PER_SYMBOL + 2 * ((len(taps) - 1) // 2) return matched, nominal @@ -400,6 +423,34 @@ def _timing_offsets(matched: np.ndarray, nominal: int, expected_symbols: np.ndar return preamble_offset, oracle_offset +def _preamble_timing_offset( + matched: np.ndarray, + nominal: int, + expected_symbols: np.ndarray, +) -> int: + """Найти то же преамбульное смещение без второй одинаковой оценки.""" + + expected = np.asarray(expected_symbols) + offsets = np.arange(-800, 801) + scores = np.zeros(offsets.size, dtype=np.float64) + first_sample = nominal + int(offsets[0]) + for phase in range(SAMPLES_PER_SYMBOL): + score_indexes = np.arange(phase, offsets.size, SAMPLES_PER_SYMBOL) + sequence_length = expected.size + score_indexes.size - 1 + start = first_sample + phase + stop = start + sequence_length * SAMPLES_PER_SYMBOL + sequence = matched[start:stop:SAMPLES_PER_SYMBOL] + if start < 0 or sequence.size != sequence_length: + return _timing_offsets(matched, nominal, expected)[0] + windows = np.lib.stride_tricks.sliding_window_view(sequence, expected.size) + correlation = windows @ np.conj(expected) + energy = np.sum(np.abs(windows) ** 2, axis=1) + scores[score_indexes] = np.abs(correlation) / np.sqrt( + expected.size * energy + 1e-30 + ) + return int(offsets[int(np.argmax(scores))]) + + def _estimate_cfo_gain( symbols: np.ndarray, expected_symbols: np.ndarray, diff --git a/experiments/lab044_hackrf_bpsk_packet.py b/experiments/lab044_hackrf_bpsk_packet.py index 346130a..06deed0 100644 --- a/experiments/lab044_hackrf_bpsk_packet.py +++ b/experiments/lab044_hackrf_bpsk_packet.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +from functools import lru_cache import hashlib import json import math @@ -253,9 +254,14 @@ def validate_transmission_plan(plan: TransmissionPlan) -> None: # ============================================================ Независимый RX +@lru_cache(maxsize=1) def _preamble_reference_samples() -> np.ndarray: + """Вернуть неизменяемую опорную преамбулу без повторного формирования.""" + waveform = _scale_and_carrier(radio.bpsk_modulate(preamble_bits())) - return signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform)) + reference = signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform)) + reference.flags.writeable = False + return reference def _detect_preamble(samples: np.ndarray) -> dict: diff --git a/experiments/lab046b_continuous_iq.py b/experiments/lab046b_continuous_iq.py index 94e8834..fb091d0 100644 --- a/experiments/lab046b_continuous_iq.py +++ b/experiments/lab046b_continuous_iq.py @@ -357,7 +357,7 @@ def _extract_soft_window( ) preamble_bits = lab045.lab044.preamble_bits() preamble_symbols = radio.bpsk_modulate(preamble_bits) - timing, _ = lab045.lab044.lab043._timing_offsets( + timing = lab045.lab044.lab043._preamble_timing_offset( matched, nominal, preamble_symbols, diff --git a/experiments/lab048_receiver_performance.py b/experiments/lab048_receiver_performance.py new file mode 100644 index 0000000..2ff92b6 --- /dev/null +++ b/experiments/lab048_receiver_performance.py @@ -0,0 +1,319 @@ +"""Lab048: измерение ускоренного автономного BPSK-приёмника без RF. + +Единственный основной режим обрабатывает сохранённую RX-запись Lab047 с уже +зафиксированным порогом. Независимый приёмник не получает данные передатчика. +JPEG сравнивается с TX только после завершённого независимого вердикта. +""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from dataclasses import asdict, dataclass +from functools import wraps +import json +import math +from pathlib import Path +import sys +import time +from typing import Callable, Iterator + +import numpy as np + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from experiments import lab043_hackrf_bpsk as lab043 +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab046c_offline_recovery as offline +from experiments import lab047_jpeg_object_transport as lab047 + + +OUTPUT_DIRECTORY = Path("data/processed/lab048") +ATTEMPT_NAME = "final_processing_attempt.json" +RESULT_NAME = "result.json" +RECOVERED_IMAGE_NAME = "recovered_image.jpg" + +BASELINE_CAPTURE_SECONDS = 15.0 +BASELINE_FULL_SECONDS = 37.423910000012256 +BASELINE_STRICT_SECONDS = 32.45996900013415 +BASELINE_AVERAGE_STRICT_SECONDS = 0.47043433333527757 +BASELINE_APPROXIMATE_PEAK_BYTES = 1_545_755_352 +FIXED_THRESHOLD = lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD + + +@dataclass +class StageMeasurement: + calls: int = 0 + cumulative_seconds: float = 0.0 + + +def _timed_wrapper( + function: Callable, + measurement: StageMeasurement, +) -> Callable: + @wraps(function) + def wrapped(*args: object, **kwargs: object) -> object: + started = time.perf_counter() + try: + return function(*args, **kwargs) + finally: + measurement.calls += 1 + measurement.cumulative_seconds += time.perf_counter() - started + + return wrapped + + +@contextmanager +def measure_receiver_stages() -> Iterator[dict[str, StageMeasurement]]: + """Временно измерить крупные стадии, не изменяя их входы и результаты.""" + + measurements = { + "cs8_to_complex64": StageMeasurement(), + "refine_start": StageMeasurement(), + "matched_filter": StageMeasurement(), + "timing": StageMeasurement(), + "cfo_phase_soft": StageMeasurement(), + "phy_header": StageMeasurement(), + "post_preamble_fec_crc": StageMeasurement(), + "viterbi": StageMeasurement(), + } + patches = ( + (offline.lab046c, "_cs8_window", "cs8_to_complex64"), + (lab043, "_refine_frame_start", "refine_start"), + (lab043, "_matched_waveform", "matched_filter"), + (lab043, "_preamble_timing_offset", "timing"), + (lab043, "_evaluate_symbols", "cfo_phase_soft"), + (lab045, "decode_phy_header_soft", "phy_header"), + (lab045, "analyze_post_preamble_soft", "post_preamble_fec_crc"), + (lab045.conv, "viterbi_decode_soft", "viterbi"), + ) + originals: list[tuple[object, str, Callable]] = [] + for owner, name, measurement_name in patches: + original = getattr(owner, name) + originals.append((owner, name, original)) + setattr(owner, name, _timed_wrapper(original, measurements[measurement_name])) + try: + yield measurements + finally: + for owner, name, original in reversed(originals): + setattr(owner, name, original) + + +def run_profiled_receiver( + capture_path: Path, + original_result_path: Path, +) -> tuple[offline.IndependentRecoveryResult, dict[str, StageMeasurement]]: + """Обработать RX без TX-данных и измерить крупные стадии.""" + + rule, calibration, control = lab047.load_fixed_hardware_candidate_rule( + capture_path, + original_result_path, + ) + if rule.threshold != FIXED_THRESHOLD: + raise RuntimeError("Порог Lab047 был изменён") + with measure_receiver_stages() as measurements: + started = time.perf_counter() + independent = offline._run_with_fixed_candidate_rule( + capture_path, + rule, + calibration, + control, + full_started=started, + ) + return independent, measurements + + +def memory_array_inventory( + result: offline.IndependentRecoveryResult, +) -> list[dict]: + """Описать крупнейшие явные массивы и консервативную оценку пика.""" + + coarse_samples = result.coarse.maximum_window_samples + decimation = offline.lab046c.lab044.lab043.DETECTION_DECIMATION + decimated_samples = math.ceil(coarse_samples / decimation) + reference_samples = offline.lab046c.lab044._preamble_reference_samples().size + reference_decimated = math.ceil(reference_samples / decimation) + score_samples = max(0, decimated_samples - reference_decimated + 1) + strict_samples = result.strict.maximum_local_window_samples + matched_samples = strict_samples + lab043._matched_filter_taps().size - 1 + + definitions = ( + ("coarse_iq_window", "complex64", coarse_samples), + ("coarse_decimated", "complex64", decimated_samples), + ("coarse_downconversion_vector", "complex64", decimated_samples), + ("coarse_correlation_scores", "float32", score_samples), + ("strict_full_iq_window", "complex64", strict_samples), + ("strict_cached_carrier_vector", "complex64", strict_samples), + ("strict_baseband", "complex64", strict_samples), + ("strict_matched_output", "complex64", matched_samples), + ("matched_filter_taps", "float32", lab043._matched_filter_taps().size), + ) + inventory = [] + for name, dtype, count in definitions: + size_bytes = int(count * np.dtype(dtype).itemsize) + inventory.append( + { + "name": name, + "dtype": dtype, + "shape": [int(count)], + "bytes": size_bytes, + "mib": size_bytes / (1024**2), + } + ) + return inventory + + +def result_summary( + independent: offline.IndependentRecoveryResult, + measurements: dict[str, StageMeasurement], + diagnostics: dict, +) -> dict: + """Собрать машинно-читаемый итог после разрешённой TX-диагностики.""" + + return { + "stage": "LAB048_OFFLINE_RECEIVER_PERFORMANCE", + "baseline": { + "capture_seconds": BASELINE_CAPTURE_SECONDS, + "full_processing_seconds": BASELINE_FULL_SECONDS, + "strict_stage_seconds": BASELINE_STRICT_SECONDS, + "average_strict_candidate_seconds": BASELINE_AVERAGE_STRICT_SECONDS, + "approximate_peak_bytes": BASELINE_APPROXIMATE_PEAK_BYTES, + }, + "optimized": { + "capture_seconds": independent.capture_duration_seconds, + "full_processing_seconds": independent.full_elapsed_seconds, + "coarse_search_seconds": independent.coarse.elapsed_seconds, + "strict_stage_seconds": independent.strict.elapsed_seconds, + "average_strict_candidate_seconds": independent.strict.average_candidate_seconds, + "approximate_peak_bytes": independent.coarse.estimated_maximum_temporary_bytes, + "stage_measurements": { + name: asdict(value) for name, value in measurements.items() + }, + "memory_arrays": memory_array_inventory(independent), + }, + "speedup": BASELINE_FULL_SECONDS / independent.full_elapsed_seconds, + "processing_to_capture_ratio": ( + independent.full_elapsed_seconds / independent.capture_duration_seconds + ), + "real_time_capable": ( + independent.full_elapsed_seconds < independent.capture_duration_seconds + ), + "threshold": independent.rule.threshold, + "threshold_changed": independent.rule.threshold != FIXED_THRESHOLD, + "coarse": { + "total_local_peaks": independent.coarse.total_local_peaks, + "first_stage_candidates": independent.coarse.candidate_count, + "skipped_as_owned_event": ( + independent.coarse.candidates_skipped_as_same_local_event + ), + "exact_duplicates": independent.coarse.exact_duplicate_candidates_skipped, + }, + "strict": asdict(independent.strict.counters), + "object": { + "valid_fragments": independent.object_verdict.valid_fragments, + "missing_fragments": independent.object_verdict.missing_fragments, + "object_complete": independent.object_verdict.object_complete, + }, + "after_independent_verdict": diagnostics, + "receiver_oracles": lab047.receiver_oracle_declaration(), + "phy_changed": False, + "fec_changed": False, + "bpsk_changed": False, + "rf_tx": 0, + } + + +def run_and_write( + capture_path: Path, + original_result_path: Path, + tx_image_path: Path, + output_directory: Path, +) -> dict: + """Однократно выполнить итоговый проход и после вердикта сравнить JPEG.""" + + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=True) + attempt_path = output_directory / ATTEMPT_NAME + result_path = output_directory / RESULT_NAME + recovered_path = output_directory / RECOVERED_IMAGE_NAME + if attempt_path.exists() or result_path.exists() or recovered_path.exists(): + raise FileExistsError("Итоговый проход Lab048 уже запускался") + rule, _, _ = lab047.load_fixed_hardware_candidate_rule( + capture_path, + original_result_path, + ) + attempt_path.write_text( + json.dumps( + { + "capture_path": str(Path(capture_path)), + "original_result_path": str(Path(original_result_path)), + "threshold": rule.threshold, + "automatic_retry": False, + "receiver_oracles": lab047.receiver_oracle_declaration(), + "rf_tx": 0, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + independent, measurements = run_profiled_receiver( + capture_path, + original_result_path, + ) + if not independent.completed or not independent.object_verdict.object_complete: + raise RuntimeError("Итоговый независимый проход Lab048 не завершён") + recovered = independent.object_verdict.recovered_object + if recovered is None: + raise RuntimeError("OBJECT COMPLETE без восстановленного JPEG") + recovered_path.write_bytes(recovered) + diagnostics = lab047.diagnose_hardware_jpeg_after_independent_verdict( + independent, + tx_image_path, + recovered_path, + ) + summary = result_summary(independent, measurements, diagnostics) + result_path.write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return summary + + +def run_functional_tests() -> None: + assert BASELINE_FULL_SECONDS > BASELINE_CAPTURE_SECONDS + assert BASELINE_STRICT_SECONDS < BASELINE_FULL_SECONDS + assert BASELINE_APPROXIMATE_PEAK_BYTES > 1024**3 + assert FIXED_THRESHOLD == 0.030964473262429237 + assert not any(lab047.receiver_oracle_declaration().values()) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Lab048 offline receiver performance") + parser.add_argument("--run-saved-capture", action="store_true") + parser.add_argument("--capture", type=Path) + parser.add_argument("--original-result", type=Path) + parser.add_argument("--tx-image", type=Path) + parser.add_argument("--output-directory", type=Path, default=OUTPUT_DIRECTORY) + arguments = parser.parse_args() + if arguments.run_saved_capture: + if arguments.capture is None or arguments.original_result is None: + parser.error("Требуются --capture и --original-result") + if arguments.tx_image is None: + parser.error("TX reference требуется только для послерезультатной диагностики") + summary = run_and_write( + arguments.capture, + arguments.original_result, + arguments.tx_image, + arguments.output_directory, + ) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + run_functional_tests() + print("Lab048 functional tests: PASS; RF TX = 0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lab048_receiver_performance.py b/tests/test_lab048_receiver_performance.py new file mode 100644 index 0000000..2842b9d --- /dev/null +++ b/tests/test_lab048_receiver_performance.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import inspect + +import numpy as np +from scipy.signal import fftconvolve + +from experiments import lab043_hackrf_bpsk as lab043 +from experiments import lab044_hackrf_bpsk_packet as lab044 +from experiments import lab048_receiver_performance as lab048 +from protocol import bpsk_radio as radio + + +def test_preamble_reference_cache_preserves_exact_samples() -> None: + lab044._preamble_reference_samples.cache_clear() + cached = lab044._preamble_reference_samples() + repeated = lab044._preamble_reference_samples() + expected_waveform = lab044._scale_and_carrier( + radio.bpsk_modulate(lab044.preamble_bits()) + ) + expected = lab044.signed_iq_bytes_to_complex64( + lab044.lab043.to_cs8_bytes(expected_waveform) + ) + + assert repeated is cached + assert cached.flags.writeable is False + assert np.array_equal(cached, expected) + + +def test_matched_filter_taps_cache_preserves_exact_coefficients() -> None: + lab043._matched_filter_taps.cache_clear() + cached = lab043._matched_filter_taps() + repeated = lab043._matched_filter_taps() + expected = radio.root_raised_cosine_taps( + 0.35, + lab043.SAMPLES_PER_SYMBOL, + 10, + ).astype(np.float32) + + assert repeated is cached + assert cached.flags.writeable is False + assert np.array_equal(cached, expected) + + +def test_overlap_add_matched_filter_matches_previous_fft_result() -> None: + random_generator = np.random.default_rng(48) + samples = ( + random_generator.normal(size=50_000) + + 1j * random_generator.normal(size=50_000) + ).astype(np.complex64) + start = 1_337 + length = 40_000 + segment = samples[start : start + length] - np.mean(samples) + indexes = start + np.arange(len(segment)) + baseband = segment * np.exp( + -2j + * np.pi + * lab043.CARRIER_OFFSET_HZ + * indexes + / lab043.SAMPLE_RATE_HZ + ) + legacy = fftconvolve( + baseband, + radio.root_raised_cosine_taps(0.35, lab043.SAMPLES_PER_SYMBOL, 10), + mode="full", + ) + + optimized, nominal = lab043._matched_waveform(samples, start, length) + + assert nominal == 16 * lab043.SAMPLES_PER_SYMBOL + 4_000 + assert optimized.dtype == np.complex64 + assert legacy.dtype == np.complex128 + maximum_difference = float(np.max(np.abs(optimized - legacy))) + reference_peak = float(np.max(np.abs(legacy))) + assert np.allclose(optimized, legacy, rtol=2e-6, atol=2e-6) + assert maximum_difference / reference_peak < 5e-7 + + +def test_carrier_downconversion_vector_cache_is_exact_and_bounded() -> None: + lab043._carrier_downconversion_vector.cache_clear() + start = 1_337 + length = 40_000 + cached = lab043._carrier_downconversion_vector(start, length) + repeated = lab043._carrier_downconversion_vector(start, length) + indexes = start + np.arange(length) + expected = np.exp( + -2j + * np.pi + * lab043.CARRIER_OFFSET_HZ + * indexes + / lab043.SAMPLE_RATE_HZ + ).astype(np.complex64) + + assert repeated is cached + assert cached.flags.writeable is False + assert np.array_equal(cached, expected) + for extra_length in range(length + 1, length + 5): + lab043._carrier_downconversion_vector(start, extra_length) + assert lab043._carrier_downconversion_vector.cache_info().currsize == 4 + + +def test_specialized_preamble_timing_matches_previous_search() -> None: + samples = lab044._preamble_reference_samples() + matched, nominal = lab043._matched_waveform(samples, 0, samples.size) + expected = radio.bpsk_modulate(lab044.preamble_bits()) + + legacy, _ = lab043._timing_offsets(matched, nominal, expected) + optimized = lab043._preamble_timing_offset(matched, nominal, expected) + + assert optimized == legacy + + +def test_profiled_receiver_api_has_no_tx_or_expected_frame_oracle() -> None: + parameters = inspect.signature(lab048.run_profiled_receiver).parameters + + assert tuple(parameters) == ("capture_path", "original_result_path") + assert "expected_frame_count" not in parameters + assert "tx_image_path" not in parameters + + +def test_lab048_functional_checks() -> None: + lab048.run_functional_tests()