Lab048: accelerate BPSK receiver to real-time

This commit is contained in:
LittleSam129
2026-09-03 14:42:02 +03:00
parent 6fb7e4ec54
commit ac6cc792b8
11 changed files with 1047 additions and 9 deletions

View File

@@ -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 не запускать.

View File

@@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

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": 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
}

View File

@@ -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`, бинарные объекты и другие аппаратные исходные

View File

@@ -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 не запускать.

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,

View File

@@ -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())

View File

@@ -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()