Lab048: accelerate BPSK receiver to real-time
This commit is contained in:
@@ -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 не запускать.
|
||||
|
||||
15
data/processed/lab048/final_processing_attempt.json
Normal file
15
data/processed/lab048/final_processing_attempt.json
Normal 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
|
||||
}
|
||||
BIN
data/processed/lab048/recovered_image.jpg
Normal file
BIN
data/processed/lab048/recovered_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
198
data/processed/lab048/result.json
Normal file
198
data/processed/lab048/result.json
Normal 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
|
||||
}
|
||||
@@ -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`, бинарные объекты и другие аппаратные исходные
|
||||
|
||||
206
docs/lab048_current_state.md
Normal file
206
docs/lab048_current_state.md
Normal 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 не запускать.
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
319
experiments/lab048_receiver_performance.py
Normal file
319
experiments/lab048_receiver_performance.py
Normal 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())
|
||||
122
tests/test_lab048_receiver_performance.py
Normal file
122
tests/test_lab048_receiver_performance.py
Normal 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()
|
||||
Reference in New Issue
Block a user