Lab043: validate pilot-aided short BPSK link

This commit is contained in:
LittleSam129
2026-08-19 18:14:13 +03:00
parent fa2e473c6d
commit f0fa7e8a46
6 changed files with 4416 additions and 0 deletions

View File

@@ -0,0 +1,521 @@
# Lab043. Передача JPEG с Pluto+ на независимый RTL-SDR
## 1. Цель лабораторной
Передать тот же подготовленный JPEG, который использован в Lab042, с
Pluto+ на отдельный RTL-SDR и восстановить его побайтово.
В Lab042 передатчик и приёмник находились в одном Pluto+ и использовали
общий опорный генератор. Lab043 должна убрать это упрощение и раздельно
измерить три эффекта:
1. рассогласование несущих частот передатчика и приёмника;
2. относительное рассогласование частот дискретизации двух устройств;
3. начальную фазу выбора отсчёта символа внутри символьного интервала.
Лабораторная считается успешной только при выполнении радиокритериев,
прикладных критериев и программных проверок, определённых в разделе 11.
Запуск программы сам по себе успехом не считается.
## 2. Границы работы
Lab043 остаётся кабельной. Эфир добавил бы многолучёвость, внешние сигналы
и неконтролируемые потери, хотя предмет этой работы состоит в
синхронизации независимых устройств.
В Lab043 не входят:
- передача через антенны;
- выбор рабочего диапазона 200-250 МГц;
- ARQ и повторная передача;
- исправление стираний и новая FEC;
- потоковое видео;
- скачки по частоте;
- изменение логики управления и безопасности ровера.
Эфирный тракт остаётся предметом следующей лабораторной.
## 3. Подтверждённое оборудование и окружение
Состояние оборудования проверено непосредственным опросом. Перед
реализацией и перед каждым аппаратным прогоном его необходимо проверить
повторно.
### 3.1. Передатчик
- Pluto+;
- устройство должно открываться успешным `iio.Context`, а не проверяться
только командой `ping`;
- чип AD9361;
- используется первый передающий канал.
Конкретный рабочий URI IIO определяется повторным опросом перед опытом и
сохраняется в метаданных запуска. Исторически работали сетевой URI
`ip:192.168.2.1` и прямой USB IIO, но ни один из них нельзя считать
доступным без текущей проверки.
### 3.2. Приёмник
- USB ID `VID_0BDA&PID_2838`;
- имя библиотеки: `Generic RTL2832U OEM`;
- тюнер, сообщённый библиотекой: Fitipower FC0013;
- основной интерфейс `MI_00` использует WinUSB от libwdi;
- устройство открыто из Python через `pyrtlsdr` и `librtlsdr`;
- установка 435 МГц подтверждена чтением значения обратно;
- частота дискретизации 2 400 000 отсчётов/с работает;
- получены комплексные отсчёты;
- доступные аппаратные усиления находятся в диапазоне от -9,9 до
19,7 дБ и задаются дискретными ступенями устройства.
Ошибка второго USB-интерфейса не считается отказом SDR. Основной
интерфейс `MI_00`, через который работает `librtlsdr`, исправен.
### 3.3. Зафиксированное программное окружение
- Python 3.13.1 x64;
- `pyrtlsdr` 0.5.0;
- `pyrtlsdrlib` 0.0.5;
- поставляемая пакетом нативная `librtlsdr` v0.9.0;
- `pyadi-iio` и `pylibiio` для Pluto+.
Принятый способ доступа к RTL-SDR: API `pyrtlsdr` с поставляемой
`pyrtlsdrlib` нативной библиотекой. Малый собственный DLL-адаптер не
нужен. Реализация должна проверять версии и выдавать понятную ошибку, если
библиотека или устройство недоступны. Пользовательский путь к DLL
зашивать в код запрещено.
## 4. Физическая схема и безопасность
Схема:
`Pluto+ TX -> SMA-кабель -> AT30S 30 дБ -> переходник/кабель -> RTL-SDR RX`
Обязательные условия перед каждым включением TX:
1. Антенны Pluto+ и RTL-SDR сняты.
2. AT30S включён последовательно в тракт, а не подключён к свободному
порту.
3. Все разъёмы затянуты, кабель не отсоединяется при включённом TX.
4. Пользователь отдельно подтверждает эту физическую схему.
5. Первый заранее определённый рабочий режим: TX -30 дБ, RTL-SDR
19,7 дБ, несущая 435 МГц, номинально 2,4 Мвыб/с на обоих устройствах.
6. Автоматический перебор усилений, частот, порогов и способов
синхронизации запрещён.
7. Если первый режим не проходит, программа сохраняет исходные данные и
диагностику и останавливается. Следующий прогон разрешён только после
разбора причины и с изменением ровно одного заранее названного
параметра.
При TX -30 дБ расчётный уровень после AT30S составляет около -53 дБм.
Это значение следует из бюджета Lab042: около +7 дБм при TX 0 дБ,
минус 30 дБ настройки TX и минус 30 дБ аттенюатора. Расчёт не заменяет
проверку наличия аттенюатора и правильности схемы.
## 5. Номинальные параметры сигнала и обязательное чтение обратно
| Параметр | Запрошенное значение | Происхождение |
|---|---:|---|
| Несущая TX и RX | 435 МГц | Кабельная рабочая точка Lab042 |
| Символьная скорость | 20 000 симв/с | Lab018-Lab023 и Lab042 |
| Частота дискретизации TX | 2 400 000 отсчётов/с | Поддерживается Pluto+ и проверена на RTL-SDR |
| Частота дискретизации RX | 2 400 000 отсчётов/с | Проверена непосредственным опросом RTL-SDR |
| Номинальное число отсчётов на символ | 120 | 2 400 000 / 20 000 |
| Полоса TX | 200 кГц | Минимальная полоса AD9361, использованная в Lab042 |
| Размер данных фрагмента | 512 байт | Lab042 |
| Модуляция | BPSK | Lab018-Lab023 и Lab042 |
Одинаковое номинальное значение 2,4 Мвыб/с выбрано специально. Lab043
измеряет физическую ошибку генераторов, а не искусственно созданную
разницу номинальных частот дискретизации.
Перед аппаратным опытом программа обязана установить и прочитать обратно:
- частоту дискретизации TX Pluto+;
- частоту дискретизации RX RTL-SDR;
- центральную частоту TX Pluto+;
- центральную частоту RX RTL-SDR;
- усиление TX Pluto+;
- фактически выбранную ступень усиления RTL-SDR.
Для каждого параметра в отчёте и метаданных хранятся отдельные поля
`requested` и `actual`. Если API не предоставляет независимого чтения
обратно, поле `actual` имеет значение `N/A`, а не копию запрошенного
значения. Передача не начинается, если частота дискретизации или несущая
не прочитаны обратно либо отличаются от запрошенных больше, чем допускает
API устройства и заранее установленная проверка.
## 6. Двухтоновая калибровка
Перед BPSK Pluto+ передаёт два комплексных тона, симметричных относительно
несущей:
`-Fcal` и `+Fcal`, где `Fcal = 50 кГц`.
50 кГц выбраны до измерения: оба тона находятся внутри полосы 200 кГц,
не сливаются около нуля и дают разнос 100 кГц для оценки масштаба частот.
RTL-SDR измеряет положения двух пиков относительно своей настроенной
несущей: `f_low` и `f_high`.
### 6.1. Рассогласование несущей
`carrier_offset_hz = (f_low + f_high) / 2`
`carrier_offset_ppm = carrier_offset_hz / 435000000 * 1e6`
Положительный результат означает, что принятый спектр сдвинут вверх по
частоте. Для компенсации сырые комплексные отсчёты умножаются на:
`exp(-j * 2*pi*carrier_offset_hz*n/Fs_rx_actual)`
Знак поправки проверяется независимо: после компенсации среднее положение
двух тонов должно стать ближе к нулю. Рассогласование несущей не должно
подменяться оценкой ошибки частоты дискретизации.
### 6.2. Относительная ошибка частот дискретизации
`clock_scale = (f_high - f_low) / (2*Fcal)`
`sample_clock_error_ppm = (clock_scale - 1) * 1e6`
Здесь положительная ошибка означает, что масштаб передающего такта больше
масштаба приёмного. Для перехода принятых отсчётов на временную сетку
передатчика ожидаемая длина после передискретизации равна:
`len_corrected = round(len_received * clock_scale)`
Направление этой операции не принимается на веру. Оно подтверждается
машинными тестами из раздела 8. После передискретизации накопленная ошибка
символьного времени должна уменьшаться.
### 6.3. Начальная фаза выбора отсчёта символа
После грубой CFO-коррекции и передискретизации приёмник отдельно ищет
начальную фазу выбора отсчёта в диапазоне от 0 до 119 номинальных
отсчётов на символ. Это не CFO и не ошибка такта. В результатах отдельно
хранятся:
- `carrier_offset_hz` и `carrier_offset_ppm`;
- `clock_scale` и `sample_clock_error_ppm`;
- `symbol_sample_phase` и остаточная ошибка символьного времени.
### 6.4. Формальное определение шумового фона
Шумовой фон и превышение пиков вычисляются одинаковым детерминированным
алгоритмом:
1. Из комплексных отсчётов вычитается среднее.
2. Оценивается спектральная плотность мощности методом Welch с окном Hann,
`nfft = nperseg = 65536` и перекрытием 50 процентов.
3. Разрешение одного БПФ вычисляется по прочитанной обратно частоте как
`delta_f = Fs_rx_actual / 65536`. При 2,4 Мвыб/с оно равно
`36,62109375 Гц`.
4. Анализируется полоса от -100 до +100 кГц относительно настроенной
несущей.
5. Нижний пик ищется в диапазоне от -80 до -20 кГц, верхний от +20 до
+80 кГц. Эти непересекающиеся окна заданы до измерения.
6. После нахождения кандидатов ожидаемых тонов из оценки шума исключаются
защитные зоны шириной +/-2 кГц вокруг измеренного положения каждого
кандидата. Каждая полузона занимает около 55 разрешающих элементов БПФ
при 2,4 Мвыб/с, поэтому утечка основного лепестка и ближайших боковых
лепестков тона не попадает в медиану шума.
7. Шумовая мощность `P_noise` равна медиане линейных значений мощности
всех оставшихся спектральных элементов анализируемой полосы.
8. Для каждого тона вычисляется
`peak_excess_db = 10*log10(P_peak/P_noise)`.
Оба пика должны превышать шумовой фон не менее чем на 10 дБ. Порог 10 дБ,
полоса анализа, окна поиска и защитные зоны зафиксированы до аппаратного
измерения. Если нет двух конечных пиков, недостаточно элементов шума или
хотя бы один пик не проходит порог, оценка этого захвата недействительна.
### 6.5. Повторы и представление недействительных оценок
Без изменения схемы выполняются три отдельных калибровочных захвата. Для
каждого захвата отдельно сохраняются:
- `f_low_hz` и `f_high_hz`;
- `low_peak_excess_db` и `high_peak_excess_db`;
- `carrier_offset_hz` и `carrier_offset_ppm`;
- `clock_scale` и `sample_clock_error_ppm`;
- остаточная CFO после коррекции;
- остаточная ошибка разноса тонов в герцах и ppm;
- признак достоверности и причина отказа.
По трём захватам вычисляются среднее, минимум, максимум и стандартное
отклонение каждой конечной оценки, а также число недействительных
захватов. Стандартное отклонение считается по совокупности всех конечных
оценок с `ddof=0`. Недействительное значение записывается как `NaN` в CSV
и как `N/A` в текстовом отчёте. Оно никогда не заменяется нулём. Если
конечных значений нет, все агрегаты этой величины равны `N/A`.
## 7. Диагностическая последовательность передачи
Аппаратная часть выполняется по ступеням. Переход к следующей ступени
разрешён только после сохранения результатов предыдущей:
1. двухтоновые калибровочные захваты;
2. известная короткая BPSK-последовательность;
3. символьная синхронизация и измерение BER известной последовательности;
4. один пакет с CRC;
5. десять пакетированных фрагментов JPEG;
6. сборка JPEG;
7. проверка размера и SHA-256.
Известная последовательность задаётся до измерения как PRBS11 длиной
2047 бит с полиномом `x^11 + x^9 + 1` и фиксированным ненулевым начальным
состоянием, записанным в метаданные. BER вычисляется прямым сравнением
каждого принятого бита с ожидаемым до упаковки в пакет и до проверки CRC.
CRC не может использоваться вместо измерения BER.
### 7.1. Контрольный опыт PRBS11 после проверки непрерывного async-приёма
Короткая запись S и длинная запись L являются двумя отдельными аппаратными
захватами. Каждый захват выполняется одним непрерывным
`rtlsdr_read_async()` и содержит в этой последовательности: входной запас,
двухтоновую калибровку, фиксированный защитный участок 0,10 с, один
начальный маркер, непрерывную PRBS11 и выходной запас. Полезная PRBS11
длится 0,25 с в S и не менее 2,0 с в L. Внутри полезной PRBS11 нет
периодической повторной синхронизации.
Оценки CFO и масштаба частоты дискретизации для обработки PRBS11 берутся
только из двух тонов в той же самой IQ-записи. Оценку из предыдущего
двухтонового опыта или из другой записи применять запрещено. Если тоны в
текущем захвате недостоверны, PRBS11 этой записи не интерпретируется.
Для обоих захватов заранее фиксируются 435 МГц, номинальные 2 400 000
отсчётов/с TX и RX, 20 000 символов/с, TX -30 дБ, запрос усиления RTL-SDR
19,7 дБ и `Fcal = 50 кГц`. Асинхронный приём использует callback 262144
байта, 15 буферов и отбрасывает первый callback как разогревочный без
перезапуска сеанса. Автоматический подбор параметров запрещён.
Одна и та же IQ-запись обрабатывается режимами A, B и C. В B и C грубая
CFO берётся из тонов этой записи; в C дополнительно применяется
`clock_scale` этих же тонов. Разрешённая защищённая тонкая CFO-коррекция не
заменяет двухтоновое измерение. Для длинной записи SRO дополнительно
оценивается независимо по накопленному дрейфу символьной фазы.
## 8. Обязательные программные проверки до аппаратного опыта
На синтетических комплексных данных проверяются четыре ошибки частоты
дискретизации: +20, -20, +100 и -100 ppm. Для каждого случая тест обязан
проверить:
- знак оценки `sample_clock_error_ppm`;
- величину оценки в заранее заданном допуске;
- направление изменения длины при передискретизации;
- уменьшение остаточной накопленной ошибки символьного времени после
поправки.
Для положительной ошибки длина должна изменяться в направлении,
предписанном формулой `round(N * clock_scale)`, для отрицательной в
противоположном. Тест должен упасть, если вместо `clock_scale` применена
обратная величина. Допуск оценки и критерий уменьшения остаточной ошибки
задаются в тесте до аппаратного измерения и печатаются в его результате.
Дополнительно синтетические тесты проверяют:
- знак грубой CFO-коррекции для положительной и отрицательной CFO;
- уменьшение остаточной CFO после поправки;
- отдельное восстановление начальной фазы выбора отсчёта символа;
- явный `N/A`, если два тона не найдены достоверно;
- формулу шумового фона и порог 10 дБ;
- BER известной последовательности до CRC;
- отсутствие ложного успеха при неполном пакете или изображении;
- формирование кода возврата только по полному набору критериев.
До прохождения этих проверок передатчик включать нельзя.
## 9. Порядок аппаратного эксперимента
1. Открыть Pluto+ успешным `iio.Context` и записать URI и идентификаторы.
2. Открыть RTL-SDR через `pyrtlsdr` и записать имя устройства и тюнера.
3. Установить и прочитать обратно параметры из раздела 5.
4. Получить отдельное подтверждение пользователя, что кабельная схема из
раздела 4 собрана и AT30S находится в разрыве.
5. Запустить один непрерывный приём RTL-SDR за 0,5 с до передачи Pluto+.
6. Выполнить три отдельных двухтоновых калибровочных захвата.
7. Сохранить оценки каждого захвата, агрегаты и не менее одного
эталонного сырого IQ-захвата.
8. Передать известную BPSK-последовательность и измерить BER.
9. Принять один пакет и проверить CRC.
10. Передать десять фрагментов JPEG и проверить критерии раздела 11.
Если любая ступень не проходит, переход к следующей запрещён. Исходные
данные и отрицательный результат сохраняются без подгонки. Изменение
одного параметра для следующего запуска оформляется как отдельное заранее
объяснённое действие.
Весь опыт принимается RTL-SDR как одна непрерывная временная
последовательность. Продолжительность приёма вычисляется из фактически
сформированной передаваемой последовательности:
`T_rx = 0,5 с + N_tx / Fs_tx_actual + 0,5 с`.
Здесь `N_tx` является фактическим числом комплексных отсчётов всей
сформированной TX-последовательности. Общая длительность опыта не задаётся
произвольной константой. Поток разрешено читать блоками, например около
262144 отсчётов, но блоки объединяются без пропусков и перестановок в одну
последовательность. Размер блока чтения является параметром реализации, а
не физическим защитным интервалом. Десять отдельных запусков приёмника для
десяти кадров запрещены, потому что они уничтожили бы наблюдаемое
накопление ошибки тактов.
Между BPSK-кадрами не добавляются новые интервалы. Используется структура
Lab042: `GUARD_SYMBOL_COUNT` импортируется из её текущей конфигурации без
копии числовой константы в Lab043, а формирование кадра повторяет ту же
последовательность переднего интервала, символов, заднего интервала и RRC.
Готовая функция Lab042 жёстко связана с 128 отсчётами на символ, поэтому
при 120 отсчётах Lab043 переиспользуются её параметры, а не скрытая копия
её глобальной частоты дискретизации. Если в дальнейшем отдельный интервал
исчезнет из Lab042, Lab043 не создаёт новый без отдельного диагностического
решения.
## 10. Три режима обработки одной записи
Одна и та же сохранённая BPSK/IQ-запись обрабатывается тремя режимами:
- режим A: без грубой CFO-коррекции и без коррекции частоты
дискретизации;
- режим B: только грубая CFO-коррекция из двухтоновой калибровки;
- режим C: грубая CFO-коррекция, коррекция частоты дискретизации и
защищённая тонкая CFO-коррекция.
Во всех режимах начальная фаза выбора отсчёта символа оценивается и
записывается отдельно. Для каждого режима сохраняются BER известной
последовательности, остаточная CFO, остаточная временная ошибка,
корреляция маркера, число найденных кадров, число разобранных заголовков и
CRC пакетов.
В режиме C защищённая функция `should_apply_cfo_correction` из
`protocol/bpsk_radio.py` остаётся тонкой коррекцией после грубого
измерения. Она не используется как единственный способ поиска сдвига в
десятки килогерц, поскольку символьная оценка неоднозначна за пределами
половины символьной скорости.
Провал режимов A или B не считается провалом лабораторной. Они являются
контрольными измерениями. Критерии передачи применяются к режиму C.
## 11. Критерии приёмки
### 11.1. Радиокритерии режима C
- найдены все 10 кадров;
- разобраны все 10 пакетов;
- CRC32 сошлась у всех 10 пакетов.
### 11.2. Прикладные критерии режима C
- восстановлены все 10 уникальных фрагментов;
- JPEG собран без заполнения отсутствующих данных;
- размер принятого JPEG совпадает с размером переданного;
- SHA-256 принятого JPEG совпадает с SHA-256 переданного.
### 11.3. Общий результат
Код возврата 0 разрешён только при одновременном выполнении:
- всех радиокритериев;
- всех прикладных критериев;
- всех обязательных программных проверок раздела 8.
Для подтверждения воспроизводимости заранее выбранная рабочая точка
запускается три раза подряд без изменения схемы и параметров. Каждый
повтор сохраняется отдельно. Три повтора являются проверкой
воспроизводимости, а не статистической оценкой вероятности отказа.
Отрицательный результат допустим и сохраняется полностью. Запрещено
автоматически поднимать усиление, менять частоту, пороги или алгоритм
синхронизации после неудачного прогона.
## 12. Измерения и артефакты
### 12.1. Измерения передачи
Для каждого запуска и каждого режима обработки сохраняются:
- запрошенные и фактические усиления, несущие и частоты дискретизации;
- число принятых отсчётов;
- отдельные оценки CFO, ошибки частоты дискретизации и начальной
символьной фазы;
- BER известной последовательности до CRC;
- число найденных кадров и разобранных заголовков;
- CRC каждого пакета;
- решение защищённой тонкой CFO-коррекции и его диагностические величины;
- корреляция маркера и корректно определённая EVM;
- число и номера восстановленных фрагментов;
- размер и SHA-256 переданного и принятого JPEG;
- длительность сигнала и полезная скорость.
Каждое число вычисляется из текущего запуска. Перенесённые константы
снабжаются ссылкой на лабораторную-источник. Невычислимая величина
записывается как `NaN` или `N/A`, но не как правдоподобный ноль.
### 12.2. План программных артефактов
Предполагаемые файлы после отдельного согласования реализации:
- `experiments/lab043_pluto_to_rtlsdr.py`;
- `tests/test_lab043_calibration.py`;
- `data/processed/lab043/lab043_calibration.csv`;
- `data/processed/lab043/lab043_frames.csv`;
- `data/processed/lab043/lab043_summary.csv`;
- `data/processed/lab043/lab043_report.txt`;
- графики калибровочного спектра, остаточной ошибки и доставки
фрагментов.
### 12.3. Эталонный сырой IQ-захват
Не менее одного полного эталонного захвата до CFO- и SRO-коррекций
сохраняется в `data/raw/lab043/` и не добавляется в Git.
Формат отсчётов: NumPy `.npy`, одномерный массив `complex64` в порядке
приёма. Нормирование исходных 8-битных I/Q отсчётов и порядок I/Q должны
быть однозначно описаны в метаданных. Рядом сохраняется UTF-8 JSON с тем
же базовым именем и полями:
- локальная метка времени и UTC;
- идентификаторы устройств и модель тюнера;
- версии Python, `pyrtlsdr`, `pyrtlsdrlib`, `librtlsdr`, `pyadi-iio` и
`pylibiio`;
- запрошенные и фактические частоты дискретизации, несущие и усиления;
- число отсчётов, длительность, dtype и порядок байтов;
- идентификатор и SHA-256 переданной формы сигнала;
- результаты калибровки, применённые к этой записи;
- SHA-256 файла `.npy`.
IQ-файл содержит всю единую последовательность приёма. Ожидаемое число
отсчётов вычисляется как `ceil(T_rx * Fs_rx_actual)`. Чтение выполняется
последовательными блоками до достижения этого числа, последний блок
обрезается только после получения требуемого количества отсчётов.
### 12.4. Разделение общего и лабораторного кода
Общие математические примитивы обнаружения двух известных тонов, оценки
грубой CFO и относительной ошибки частот дискретизации, грубой частотной
коррекции и передискретизации размещаются в `protocol/bpsk_radio.py` либо
в другом подходящем существующем модуле `protocol/`. Каждый такой перенос
сопровождается быстрым синтетическим тестом.
В `experiments/lab043_pluto_to_rtlsdr.py` остаются сценарий лабораторной,
конкретное `Fcal`, три калибровочных захвата, методика оценки шума Lab043,
режимы A/B/C, PRBS11, контрольный пакет, JPEG, отчётные артефакты,
критерии приёмки и аппаратная последовательность. Общий код не зависит от
конкретного JPEG и номера лабораторной.
## 13. Точка остановки перед аппаратным опытом
Программная реализация и синтетические проверки разрешены. Перед
аппаратным опытом необходимо отдельно:
1. пройти все обязательные программные проверки раздела 8;
2. повторно проверить доступность устройств и чтение параметров обратно;
3. получить подтверждение пользователя о схеме и отдельное разрешение на
включение TX.
Способ Python-доступа к RTL-SDR уже выбран и не является вопросом точки
остановки.
До отдельного разрешения передатчик не включается, усиление Pluto+ не
изменяется, аппаратная калибровка и передача тонов, BPSK или JPEG не
выполняются. Операции `git add`, `commit`, `push`, `pull` и `fetch` также
не выполняются.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
"""Один разрешённый аппаратный захват PRBS11 для Lab043.
Скрипт выполняет ровно один из заранее определённых опытов S или L. Он не
подбирает параметры, не повторяет передачу при ошибке и не переходит к
пакетам, CRC либо JPEG.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import threading
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from experiments import lab043_pluto_to_rtlsdr as lab043
PLUTO_URI = "ip:192.168.2.1"
TX_GAIN_DB = -30.0
RTL_GAIN_DB = 19.7
RF_BANDWIDTH_HZ = 200_000
CONTINUITY_MAX_PHASE_JUMP_RAD = 0.25
@dataclass(frozen=True)
class CapturedPrbsAnalysis:
"""Полный результат обработки одного уже принятого S/L."""
processing_success: bool
invalid_reason: str
sections: dict
calibration: lab043.CalibrationResult
continuity: dict
modes: dict[str, lab043.PrbsModeMetrics]
def preflight_pluto() -> dict:
"""Подтвердить IIO-контекст, PHY и первый TX-канал без передачи."""
import iio
context = iio.Context(PLUTO_URI)
phy = context.find_device("ad9361-phy")
tx_device = context.find_device("cf-ad9361-dds-core-lpc")
tx_channel = tx_device.find_channel("voltage0", True) if tx_device is not None else None
if phy is None:
raise RuntimeError("IIO-контекст открыт, но ad9361-phy не найден")
if tx_device is None or tx_channel is None:
raise RuntimeError("IIO-контекст открыт, но первый TX-канал недоступен")
return {
"uri": PLUTO_URI,
"context_opened": True,
"phy_name": str(phy.name),
"tx_device_name": str(tx_device.name),
"first_tx_channel": str(tx_channel.id),
}
def _configure_rtlsdr(sdr) -> dict:
sdr.sample_rate = float(lab043.SAMPLE_RATE_HZ)
sdr.center_freq = float(lab043.CARRIER_HZ)
sdr.gain = float(RTL_GAIN_DB)
actual_sample_rate = float(sdr.sample_rate)
actual_center = float(sdr.center_freq)
raw_gain = float(sdr.gain)
actual_gain = raw_gain if math.isclose(raw_gain, RTL_GAIN_DB, abs_tol=0.2) else None
return {
"requested_center_frequency_hz": float(lab043.CARRIER_HZ),
"actual_center_frequency_hz": actual_center,
"requested_sample_rate_hz": float(lab043.SAMPLE_RATE_HZ),
"actual_sample_rate_hz": actual_sample_rate,
"requested_gain_db": RTL_GAIN_DB,
"raw_gain_readback_db": raw_gain,
"actual_gain_db": actual_gain,
}
def preflight_rtlsdr() -> dict:
"""Открыть RTL-SDR, прочитать короткий блок и закрыть до TX."""
from rtlsdr import RtlSdr
sdr = RtlSdr(device_index=0)
try:
parameters = _configure_rtlsdr(sdr)
probe = np.asarray(sdr.read_samples(16_384), dtype=np.complex64)
if len(probe) != 16_384 or not np.all(np.isfinite(probe)):
raise RuntimeError("RTL-SDR не вернул полный конечный пробный блок")
parameters["probe_sample_count"] = int(len(probe))
parameters["probe_rms"] = float(np.sqrt(np.mean(np.abs(probe) ** 2)))
return parameters
finally:
sdr.close()
def configure_pluto() -> tuple[object, dict]:
import adi
device = adi.Pluto(uri=PLUTO_URI)
device.sample_rate = int(lab043.SAMPLE_RATE_HZ)
device.tx_lo = int(lab043.CARRIER_HZ)
device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ)
device.tx_hardwaregain_chan0 = float(TX_GAIN_DB)
device.tx_cyclic_buffer = False
return device, {
"requested_center_frequency_hz": int(lab043.CARRIER_HZ),
"actual_center_frequency_hz": int(device.tx_lo),
"requested_sample_rate_hz": int(lab043.SAMPLE_RATE_HZ),
"actual_sample_rate_hz": int(device.sample_rate),
"requested_gain_db": TX_GAIN_DB,
"actual_gain_db": float(device.tx_hardwaregain_chan0),
"requested_rf_bandwidth_hz": RF_BANDWIDTH_HZ,
"actual_rf_bandwidth_hz": int(device.tx_rf_bandwidth),
}
def transmit_noncyclic_buffer_once(
device,
device_samples: np.ndarray,
actual_sample_rate_hz: float,
sleep_function=time.sleep,
) -> float:
"""Передать один нециклический буфер и не уничтожать его раньше времени.
В libiio v1 постановка блока в поток может завершиться до того, как DMA
физически выведет все отсчёты. Поэтому буфер остаётся жив не меньше его
расчётной длительности. Возвращаемое значение сохраняется в диагностике.
"""
samples = np.asarray(device_samples, dtype=np.complex64)
if samples.ndim != 1 or samples.size == 0:
raise ValueError("TX-буфер должен быть непустым и одномерным")
if not np.isfinite(actual_sample_rate_hz) or actual_sample_rate_hz <= 0.0:
raise ValueError("Фактическая частота TX должна быть положительной")
hold_seconds = len(samples) / actual_sample_rate_hz
device.tx_destroy_buffer()
device.tx(samples)
sleep_function(hold_seconds)
device.tx_destroy_buffer()
return float(hold_seconds)
def calibration_boundary_continuity(
samples: np.ndarray,
callback_boundaries,
sections: dict,
calibration: lab043.CalibrationResult,
sample_rate_hz: float,
) -> dict:
"""Проверить фазу обоих тонов на callback-границах внутри калибровки."""
relevant = [
int(row.accepted_end_sample)
for row in callback_boundaries
if sections["calibration_start_sample"] + 4_096
<= row.accepted_end_sample
<= sections["calibration_end_sample"] - 4_096
]
jumps: list[dict] = []
for boundary in relevant:
row = {"boundary_sample": boundary}
for name, frequency_hz in (
("low", calibration.f_low_hz),
("high", calibration.f_high_hz),
):
before_indexes = np.arange(boundary - 4_096, boundary, dtype=np.float64)
after_indexes = np.arange(boundary, boundary + 4_096, dtype=np.float64)
before = np.mean(
samples[boundary - 4_096 : boundary]
* np.exp(-1j * 2.0 * np.pi * frequency_hz * before_indexes / sample_rate_hz)
)
after = np.mean(
samples[boundary : boundary + 4_096]
* np.exp(-1j * 2.0 * np.pi * frequency_hz * after_indexes / sample_rate_hz)
)
row[f"{name}_phase_jump_rad"] = float(np.angle(after * np.conj(before)))
jumps.append(row)
maximum = max(
(
abs(value)
for row in jumps
for key, value in row.items()
if key.endswith("phase_jump_rad")
),
default=0.0,
)
return {
"checked_boundary_count": len(relevant),
"maximum_absolute_phase_jump_rad": float(maximum),
"fixed_limit_rad": CONTINUITY_MAX_PHASE_JUMP_RAD,
"confirmed_discontinuity_count": int(
sum(
max(abs(row["low_phase_jump_rad"]), abs(row["high_phase_jump_rad"]))
> CONTINUITY_MAX_PHASE_JUMP_RAD
for row in jumps
)
),
"boundaries": jumps,
}
def analyze_captured_prbs(
samples: np.ndarray,
plan: lab043.PrbsTransmissionPlan,
actual_rx_rate: float,
actual_tx_rate: float,
callback_boundaries=(),
) -> CapturedPrbsAnalysis:
"""Пройти тем же полным путём, что аппаратный S, но без обращения к SDR."""
calibration_samples, bpsk_samples, sections = lab043.split_calibration_and_bpsk(
samples,
plan,
actual_rx_rate,
actual_tx_rate,
)
calibration = lab043.estimate_refined_calibration(calibration_samples, actual_rx_rate)
if not calibration.valid:
reason = f"калибровка недостоверна: {calibration.invalid_reason}"
modes = {
mode: lab043._failed_prbs_metrics(mode, len(plan.payload_bits), reason)
for mode in ("A", "B", "C", "D")
}
return CapturedPrbsAnalysis(
processing_success=False,
invalid_reason=reason,
sections=sections,
calibration=calibration,
continuity={
"checked_boundary_count": 0,
"confirmed_discontinuity_count": 0,
"reason": "тоны недостоверны",
},
modes=modes,
)
continuity = calibration_boundary_continuity(
np.asarray(samples, dtype=np.complex64),
callback_boundaries,
sections,
calibration,
actual_rx_rate,
)
if continuity["confirmed_discontinuity_count"]:
reason = "в той же записи подтверждён фазовый разрыв"
modes = {
mode: lab043._failed_prbs_metrics(mode, len(plan.payload_bits), reason)
for mode in ("A", "B", "C", "D")
}
return CapturedPrbsAnalysis(
processing_success=False,
invalid_reason=reason,
sections=sections,
calibration=calibration,
continuity=continuity,
modes=modes,
)
modes = lab043.analyze_prbs_modes(
bpsk_samples,
plan.payload_bits,
calibration.carrier_offset_hz,
calibration.clock_scale,
actual_rx_rate,
known_pilot_bits=plan.pilot_bits,
)
mode_d = modes["D"]
success = bool(mode_d.detected and mode_d.matched_bit_count == len(plan.payload_bits))
reason = "" if success else (mode_d.failure_reason or "режим D не восстановил полную PRBS11")
return CapturedPrbsAnalysis(
processing_success=success,
invalid_reason=reason,
sections=sections,
calibration=calibration,
continuity=continuity,
modes=modes,
)
def run_one_capture(label: str, output_directory: Path) -> dict:
if label != "S":
raise RuntimeError("На текущем этапе разрешён только один короткий опыт S")
duration = (
lab043.PRBS_SHORT_DURATION_SECONDS
if label == "S"
else lab043.PRBS_LONG_DURATION_SECONDS
)
plan = lab043.build_prbs11_transmission_plan(label, duration)
capture_started = datetime.now(timezone.utc)
pluto_preflight = preflight_pluto()
rtl_preflight = preflight_rtlsdr()
time.sleep(0.25)
pluto, tx_parameters = configure_pluto()
from rtlsdr import RtlSdr
sdr = RtlSdr(device_index=0)
rx_parameters = _configure_rtlsdr(sdr)
actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"])
actual_rx_rate = float(rx_parameters["actual_sample_rate_hz"])
expected_rx_samples = lab043.receive_sample_count(
len(plan.tx_samples),
actual_tx_rate,
actual_rx_rate,
)
ready = threading.Event()
tx_errors: list[str] = []
tx_buffer_hold_seconds: list[float] = []
def transmit_once() -> None:
if not ready.wait(timeout=10.0):
tx_errors.append("RTL-SDR async-приём не подтвердил готовность")
return
time.sleep(lab043.RX_LEADING_MARGIN_SECONDS)
try:
hold_seconds = transmit_noncyclic_buffer_once(
pluto,
lab043.to_pluto_tx_samples(plan.tx_samples),
actual_tx_rate,
)
tx_buffer_hold_seconds.append(hold_seconds)
except Exception as error: # pragma: no cover - аппаратный путь
tx_errors.append(f"{type(error).__name__}: {error}")
tx_thread = threading.Thread(target=transmit_once, daemon=True)
tx_thread.start()
samples, boundaries, async_diagnostics = lab043.capture_continuous_rtlsdr_async(
sdr,
expected_rx_samples,
capture_ready_event=ready,
buffer_bytes=lab043.RTL_ASYNC_BUFFER_BYTES,
buffer_count=lab043.RTL_ASYNC_BUFFER_COUNT,
warmup_callback_count=lab043.RTL_ASYNC_WARMUP_CALLBACK_COUNT,
)
tx_thread.join(timeout=15.0)
if tx_thread.is_alive():
raise RuntimeError("Поток единственной передачи не завершился")
if tx_errors:
raise RuntimeError("; ".join(tx_errors))
received = np.asarray(samples, dtype=np.complex64)
if received.ndim != 1 or len(received) != expected_rx_samples:
raise RuntimeError("RX завершён, но размер массива не совпал с ожидаемым")
if not np.all(np.isfinite(received)):
raise RuntimeError("RX завершён, но массив содержит нечисловые значения")
boundary_rows = [asdict(row) for row in boundaries]
waveform_sha256 = hashlib.sha256(
np.asarray(plan.tx_samples, dtype=np.complex64).tobytes()
).hexdigest()
tx_waveform_duration_seconds = len(plan.tx_samples) / actual_tx_rate
calibration_end_seconds = plan.calibration_sample_count / actual_tx_rate
guard_end_seconds = plan.bpsk_start_sample / actual_tx_rate
prbs_end_seconds = len(plan.tx_samples) / actual_tx_rate
overload = {
"peak_magnitude": float(np.max(np.abs(received))),
"clipped_component_fraction": float(
np.mean((np.abs(received.real) >= 0.999) | (np.abs(received.imag) >= 0.999))
),
}
metadata = {
"experiment": "Lab043 PRBS11 continuous async",
"capture_label": label,
"capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"),
"capture_order": 1 if label == "S" else 2,
"capture_status": "captured",
"processing_status": "pending",
"processing_error": None,
"actual_sample_rate_hz": actual_rx_rate,
"center_frequency_hz": rx_parameters["actual_center_frequency_hz"],
"rx_gain_db": rx_parameters["actual_gain_db"],
"pluto_preflight": pluto_preflight,
"rtl_preflight": rtl_preflight,
"tx_parameters": tx_parameters,
"rx_parameters": rx_parameters,
"physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> RTL-SDR RX; antennas removed",
"waveform_sha256": waveform_sha256,
"tx_waveform_duration_seconds": tx_waveform_duration_seconds,
"callback_count": int(async_diagnostics["callback_count"]),
"callback_boundaries": boundary_rows,
"expected_intervals": {
"relative_to_tx_start_seconds": {
"calibration": [0.0, calibration_end_seconds],
"guard": [calibration_end_seconds, guard_end_seconds],
"bpsk_marker_pilot_prbs": [guard_end_seconds, prbs_end_seconds],
},
"rx_leading_margin_seconds": lab043.RX_LEADING_MARGIN_SECONDS,
"rx_trailing_margin_seconds": lab043.RX_TRAILING_MARGIN_SECONDS,
},
"waveform": {
"calibration_tones_hz": [-lab043.F_CAL_HZ, lab043.F_CAL_HZ],
"calibration_duration_seconds": lab043.CALIBRATION_TONE_DURATION_SECONDS,
"fixed_guard_seconds": lab043.CALIBRATION_TO_BPSK_GUARD_SECONDS,
"single_initial_marker_bit_count": len(plan.marker_bits),
"pilot_symbol_count": len(plan.pilot_bits),
"pilot_duration_seconds": len(plan.pilot_bits) / lab043.SYMBOL_RATE,
"pilot_polynomial": lab043.PILOT_POLYNOMIAL,
"pilot_initial_state_hex": f"0x{lab043.PILOT_INITIAL_STATE:02X}",
"pilot_sha256": hashlib.sha256(plan.pilot_bits.tobytes()).hexdigest(),
"periodic_resynchronization": False,
"prbs_polynomial": lab043.PRBS11_POLYNOMIAL,
"prbs_initial_state_hex": f"0x{lab043.PRBS11_INITIAL_STATE:03X}",
"prbs_useful_duration_seconds": plan.useful_duration_seconds,
"transmitted_prbs_bit_count": len(plan.payload_bits),
"transmitted_prbs_sha256": hashlib.sha256(plan.payload_bits.tobytes()).hexdigest(),
"tx_sample_count": len(plan.tx_samples),
"tx_samples_sha256": waveform_sha256,
},
"async_capture": async_diagnostics | {
"expected_sample_count": expected_rx_samples,
"callback_boundaries": boundary_rows,
},
"tx_buffer_hold_seconds": (
tx_buffer_hold_seconds[0] if tx_buffer_hold_seconds else None
),
"overload": overload,
}
stamp = capture_started.strftime("%Y%m%d_%H%M%S")
base = output_directory / f"lab043_prbs11_{label.lower()}_{stamp}"
iq_path, json_path, analysis, processing_error = lab043.save_then_process_capture(
base,
received,
metadata,
lambda stored: analyze_captured_prbs(
stored,
plan,
actual_rx_rate,
actual_tx_rate,
boundaries,
),
)
if analysis is None:
return {
"iq_path": str(iq_path),
"metadata_path": str(json_path),
"capture_status": "captured",
"processing_status": "processing_failed",
"processing_error": processing_error,
"tx_parameters": tx_parameters,
"rx_parameters": rx_parameters,
"async_capture": async_diagnostics,
"overload": overload,
}
result = {
"iq_path": str(iq_path),
"metadata_path": str(json_path),
"capture_status": "captured",
"processing_status": "success",
"processing_error": None,
"processing_success": analysis.processing_success,
"invalid_reason": analysis.invalid_reason,
"calibration": asdict(analysis.calibration),
"continuity": analysis.continuity,
"modes": {mode: asdict(value) for mode, value in analysis.modes.items()},
"tx_parameters": tx_parameters,
"rx_parameters": rx_parameters,
"async_capture": async_diagnostics,
"overload": overload,
}
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--label", choices=("S",), required=True)
parser.add_argument("--output-directory", type=Path, default=Path("data/raw/lab043"))
arguments = parser.parse_args()
result = run_one_capture(arguments.label, arguments.output_directory)
print(json.dumps(result, ensure_ascii=False, indent=2))
if result["processing_status"] != "success":
return 2
mode_d = result["modes"]["D"]
return 0 if result["calibration"]["valid"] and mode_d["detected"] else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -17,6 +17,7 @@ Lab019 и Lab023 при импорте создают каталоги и сох
from __future__ import annotations from __future__ import annotations
import struct import struct
from dataclasses import dataclass
import numpy as np import numpy as np
@@ -38,8 +39,48 @@ CFO_REFINEMENT_STEP_HZ = 1.0
CFO_DEAD_ZONE_HZ = 15.0 CFO_DEAD_ZONE_HZ = 15.0
MINIMUM_PHASE_CONSISTENCY = 0.55 MINIMUM_PHASE_CONSISTENCY = 0.55
# Критерии рабочей оценки малой остаточной CFO по отдельному известному
# пилоту Lab043. Они зафиксированы до нового аппаратного опыта.
KNOWN_PILOT_BLOCK_SYMBOL_COUNT = 20
MINIMUM_KNOWN_PILOT_BLOCK_COUNT = 8
MINIMUM_KNOWN_PILOT_COHERENCE = 0.50
MAXIMUM_KNOWN_PILOT_PHASE_FIT_RMSE_RAD = 0.25
MAXIMUM_PROTOCOL_PACKET_BYTES = 4096 MAXIMUM_PROTOCOL_PACKET_BYTES = 4096
@dataclass(frozen=True)
class TwoTonePhaseRefinement:
"""Явный результат фазового уточнения двух известных тонов."""
valid: bool
invalid_reason: str
low_frequency_hz: float
high_frequency_hz: float
carrier_offset_hz: float
tone_spacing_hz: float
residual_cfo_hz: float
phase_fit_rmse_rad: float
low_phase_fit_rms_rad: float
high_phase_fit_rms_rad: float
paired_phase_fit_rms_rad: float
block_count: int
@dataclass(frozen=True)
class KnownPilotCarrierEstimate:
"""Оценка малой остаточной CFO по отдельному известному BPSK-пилоту."""
valid: bool
invalid_reason: str
frequency_hz: float
phase_increment_rad_per_symbol: float
initial_phase_rad: float
phase_fit_rmse_rad: float
mean_block_coherence: float
block_count: int
known_symbol_count: int
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py). # Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def bytes_to_bits( def bytes_to_bits(
data: bytes, data: bytes,
@@ -136,6 +177,496 @@ def bpsk_demodulate(
).astype(np.uint8) ).astype(np.uint8)
def find_known_tone_peaks(
frequency_axis_hz: np.ndarray,
power_spectrum: np.ndarray,
expected_offset_hz: float,
search_half_width_hz: float,
) -> dict:
"""Найти максимумы мощности около двух известных симметричных тонов.
Функция не задаёт способ оценки шумового фона и порог достоверности:
эти решения принадлежат конкретному эксперименту. Если в одном из
поисковых окон нет конечных значений мощности, соответствующие частота
и мощность возвращаются как ``NaN``.
"""
frequencies = np.asarray(frequency_axis_hz, dtype=np.float64)
powers = np.asarray(power_spectrum, dtype=np.float64)
if frequencies.ndim != 1 or powers.ndim != 1:
raise ValueError("Ось частот и спектр мощности должны быть одномерными")
if len(frequencies) != len(powers):
raise ValueError("Ось частот и спектр мощности должны иметь одинаковую длину")
if not np.isfinite(expected_offset_hz) or expected_offset_hz <= 0.0:
raise ValueError("Ожидаемый отступ тона должен быть положительным")
if not np.isfinite(search_half_width_hz) or search_half_width_hz <= 0.0:
raise ValueError("Полуширина окна поиска должна быть положительной")
def peak_in_window(center_hz: float) -> tuple[float, float]:
in_window = (
(frequencies >= center_hz - search_half_width_hz)
& (frequencies <= center_hz + search_half_width_hz)
& np.isfinite(frequencies)
& np.isfinite(powers)
& (powers >= 0.0)
)
indexes = np.flatnonzero(in_window)
if indexes.size == 0:
return float("nan"), float("nan")
peak_index = int(indexes[np.argmax(powers[indexes])])
return float(frequencies[peak_index]), float(powers[peak_index])
low_frequency_hz, low_power = peak_in_window(-expected_offset_hz)
high_frequency_hz, high_power = peak_in_window(expected_offset_hz)
return {
"low_frequency_hz": low_frequency_hz,
"high_frequency_hz": high_frequency_hz,
"low_power": low_power,
"high_power": high_power,
}
def estimate_two_tone_offsets(
low_frequency_hz: float,
high_frequency_hz: float,
known_tone_offset_hz: float,
) -> dict:
"""Оценить грубую CFO и отношение тактов по двум известным тонам.
``clock_scale`` определён как отношение масштаба TX к масштабу RX.
Поэтому для перехода принятой последовательности на временную сетку
передатчика её ожидаемая длина равна ``round(N * clock_scale)``.
Невычислимая оценка представляется только значениями ``NaN``.
"""
if not np.isfinite(known_tone_offset_hz) or known_tone_offset_hz <= 0.0:
raise ValueError("Известный отступ тона должен быть положительным")
invalid = {
"carrier_offset_hz": float("nan"),
"clock_scale": float("nan"),
"sample_clock_error_ppm": float("nan"),
}
if not np.isfinite(low_frequency_hz) or not np.isfinite(high_frequency_hz):
return invalid
if high_frequency_hz <= low_frequency_hz:
return invalid
carrier_offset_hz = (low_frequency_hz + high_frequency_hz) / 2.0
clock_scale = (
(high_frequency_hz - low_frequency_hz)
/ (2.0 * known_tone_offset_hz)
)
if not np.isfinite(clock_scale) or clock_scale <= 0.0:
return invalid
return {
"carrier_offset_hz": float(carrier_offset_hz),
"clock_scale": float(clock_scale),
"sample_clock_error_ppm": float((clock_scale - 1.0) * 1e6),
}
def _tone_block_projections(
samples: np.ndarray,
sample_rate_hz: float,
coarse_frequency_hz: float,
block_samples: int,
hop_samples: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Спроецировать фазово-непрерывный сигнал на грубую частоту тона."""
received = np.asarray(samples, dtype=np.complex128)
if received.ndim != 1:
raise ValueError("Комплексные отсчёты должны быть одномерными")
if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0:
raise ValueError("Частота дискретизации должна быть положительной")
if not np.isfinite(coarse_frequency_hz):
raise ValueError("Грубая частота тона должна быть конечной")
if not isinstance(block_samples, (int, np.integer)) or block_samples < 3:
raise ValueError("Размер блока должен быть целым числом не меньше трёх")
if not isinstance(hop_samples, (int, np.integer)) or hop_samples <= 0:
raise ValueError("Шаг блоков должен быть положительным целым числом")
if len(received) < block_samples + 2 * hop_samples:
raise ValueError("Для фазовой оценки нужны не менее трёх блоков")
starts = np.arange(
0,
len(received) - block_samples + 1,
hop_samples,
dtype=np.int64,
)
sample_indexes = np.arange(len(received), dtype=np.float64)
mixed = received * np.exp(
-1j * 2.0 * np.pi * coarse_frequency_hz * sample_indexes / sample_rate_hz
)
window = np.hanning(block_samples)
projections = np.asarray(
[
np.sum(mixed[start : start + block_samples] * window)
for start in starts
],
dtype=np.complex128,
)
center_times_seconds = (
starts.astype(np.float64) + (block_samples - 1) / 2.0
) / sample_rate_hz
return center_times_seconds, projections
def _weighted_phase_frequency(
center_times_seconds: np.ndarray,
projections: np.ndarray,
) -> tuple[float, float]:
"""Оценить наклон развёрнутой фазы и СКО остатка линейной модели."""
times = np.asarray(center_times_seconds, dtype=np.float64)
values = np.asarray(projections, dtype=np.complex128)
weights = np.abs(values) ** 2
weight_sum = float(np.sum(weights))
if (
times.ndim != 1
or values.ndim != 1
or len(times) != len(values)
or len(times) < 3
or not np.all(np.isfinite(times))
or not np.all(np.isfinite(values))
or not np.isfinite(weight_sum)
or weight_sum <= 0.0
):
raise ValueError("Фазовые проекции не позволяют оценить частоту")
phases = np.unwrap(np.angle(values))
mean_time = float(np.sum(weights * times) / weight_sum)
mean_phase = float(np.sum(weights * phases) / weight_sum)
centered_times = times - mean_time
denominator = float(np.sum(weights * centered_times**2))
if not np.isfinite(denominator) or denominator <= 0.0:
raise ValueError("Временные точки не позволяют оценить наклон фазы")
slope_rad_per_second = float(
np.sum(weights * centered_times * (phases - mean_phase)) / denominator
)
intercept = mean_phase - slope_rad_per_second * mean_time
residuals = phases - (intercept + slope_rad_per_second * times)
phase_fit_rms_rad = float(
np.sqrt(np.sum(weights * residuals**2) / weight_sum)
)
return slope_rad_per_second / (2.0 * np.pi), phase_fit_rms_rad
def refine_tone_frequency_from_phase(
samples: np.ndarray,
sample_rate_hz: float,
coarse_frequency_hz: float,
block_samples: int,
hop_samples: int | None = None,
) -> dict:
"""Уточнить частоту тона по наклону фазы когерентных проекций.
Входной участок должен быть фазово-непрерывным. Функцию нельзя применять
через границы отдельных аппаратных чтений, если непрерывность их фазы не
доказана. Грубая частота должна быть достаточно точной, чтобы остаточная
фаза между соседними блоками разворачивалась без неоднозначности.
"""
hop = block_samples // 2 if hop_samples is None else hop_samples
times, projections = _tone_block_projections(
samples,
sample_rate_hz,
coarse_frequency_hz,
block_samples,
hop,
)
residual_frequency_hz, phase_fit_rms_rad = _weighted_phase_frequency(
times,
projections,
)
return {
"frequency_hz": float(coarse_frequency_hz + residual_frequency_hz),
"residual_frequency_hz": float(residual_frequency_hz),
"phase_fit_rms_rad": float(phase_fit_rms_rad),
"block_count": int(len(projections)),
}
def refine_two_tone_frequencies_from_phase(
samples: np.ndarray,
sample_rate_hz: float,
low_coarse_frequency_hz: float,
high_coarse_frequency_hz: float,
block_samples: int,
hop_samples: int | None = None,
) -> TwoTonePhaseRefinement:
"""Уточнить несущую и расстояние двух тонов на непрерывном участке.
Несущая получается из двух индивидуальных наклонов фазы. Расстояние тонов
оценивается по фазе произведения верхней проекции на сопряжённую нижнюю:
общая фазовая ошибка приёмника при этом сокращается. Итоговые частоты
строятся из общей оценки центра и парной оценки расстояния.
"""
if high_coarse_frequency_hz <= low_coarse_frequency_hz:
raise ValueError("Частота верхнего тона должна быть выше частоты нижнего")
hop = block_samples // 2 if hop_samples is None else hop_samples
times, low_projections = _tone_block_projections(
samples,
sample_rate_hz,
low_coarse_frequency_hz,
block_samples,
hop,
)
high_times, high_projections = _tone_block_projections(
samples,
sample_rate_hz,
high_coarse_frequency_hz,
block_samples,
hop,
)
if not np.array_equal(times, high_times):
raise RuntimeError("Временные сетки двух тонов не совпали")
low_residual_hz, low_rms_rad = _weighted_phase_frequency(times, low_projections)
high_residual_hz, high_rms_rad = _weighted_phase_frequency(times, high_projections)
spacing_residual_hz, paired_rms_rad = _weighted_phase_frequency(
times,
high_projections * np.conj(low_projections),
)
individual_low_hz = low_coarse_frequency_hz + low_residual_hz
individual_high_hz = high_coarse_frequency_hz + high_residual_hz
center_hz = (individual_low_hz + individual_high_hz) / 2.0
spacing_hz = (
high_coarse_frequency_hz
- low_coarse_frequency_hz
+ spacing_residual_hz
)
low_frequency_hz = float(center_hz - spacing_hz / 2.0)
high_frequency_hz = float(center_hz + spacing_hz / 2.0)
residual_cfo_hz = float(
center_hz
- (low_coarse_frequency_hz + high_coarse_frequency_hz) / 2.0
)
phase_fit_rmse_rad = float(
np.sqrt(np.mean(np.square((low_rms_rad, high_rms_rad, paired_rms_rad))))
)
numeric = (
low_frequency_hz,
high_frequency_hz,
center_hz,
spacing_hz,
residual_cfo_hz,
phase_fit_rmse_rad,
low_rms_rad,
high_rms_rad,
paired_rms_rad,
)
valid = bool(all(np.isfinite(value) for value in numeric))
if not valid:
nan = float("nan")
return TwoTonePhaseRefinement(
valid=False,
invalid_reason="фазовое уточнение вернуло невычислимое значение",
low_frequency_hz=nan,
high_frequency_hz=nan,
carrier_offset_hz=nan,
tone_spacing_hz=nan,
residual_cfo_hz=nan,
phase_fit_rmse_rad=nan,
low_phase_fit_rms_rad=nan,
high_phase_fit_rms_rad=nan,
paired_phase_fit_rms_rad=nan,
block_count=int(len(low_projections)),
)
return TwoTonePhaseRefinement(
valid=True,
invalid_reason="",
low_frequency_hz=low_frequency_hz,
high_frequency_hz=high_frequency_hz,
carrier_offset_hz=float(center_hz),
tone_spacing_hz=float(spacing_hz),
residual_cfo_hz=residual_cfo_hz,
phase_fit_rmse_rad=phase_fit_rmse_rad,
low_phase_fit_rms_rad=float(low_rms_rad),
high_phase_fit_rms_rad=float(high_rms_rad),
paired_phase_fit_rms_rad=float(paired_rms_rad),
block_count=int(len(low_projections)),
)
def apply_coarse_frequency_correction(
samples: np.ndarray,
carrier_offset_hz: float,
sample_rate_hz: float,
) -> np.ndarray:
"""Убрать измеренный сдвиг несущей из комплексных отсчётов."""
received = np.asarray(samples, dtype=np.complex128)
if received.ndim != 1:
raise ValueError("Комплексные отсчёты должны быть одномерными")
if not np.isfinite(carrier_offset_hz):
raise ValueError("Сдвиг несущей должен быть конечным")
if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0:
raise ValueError("Частота дискретизации должна быть положительной")
sample_indexes = np.arange(len(received), dtype=np.float64)
correction = np.exp(
-1j * 2.0 * np.pi * carrier_offset_hz * sample_indexes / sample_rate_hz
)
return received * correction
def resample_for_clock_scale(
samples: np.ndarray,
clock_scale: float,
) -> np.ndarray:
"""Передискретизировать отсчёты на временную сетку передатчика.
Используется комплексная линейная интерполяция. Она детерминирована,
не требует рационального приближения ppm-отношения и подходит для
сильно передискретизированного BPSK-тракта. Направление преобразования
закрепляется синтетическими тестами для ошибок обоих знаков.
"""
received = np.asarray(samples, dtype=np.complex128)
if received.ndim != 1:
raise ValueError("Комплексные отсчёты должны быть одномерными")
if not np.isfinite(clock_scale) or clock_scale <= 0.0:
raise ValueError("Масштаб такта должен быть положительным")
if received.size == 0:
return received.copy()
if received.size == 1:
return np.repeat(received, max(1, round(clock_scale)))
output_length = max(1, round(len(received) * clock_scale))
output_indexes = np.arange(output_length, dtype=np.float64)
source_positions = np.minimum(output_indexes / clock_scale, len(received) - 1.0)
source_indexes = np.arange(len(received), dtype=np.float64)
real = np.interp(source_positions, source_indexes, received.real)
imaginary = np.interp(source_positions, source_indexes, received.imag)
return real + 1j * imaginary
def estimate_known_pilot_carrier(
received_symbols: np.ndarray,
known_symbols: np.ndarray,
symbol_rate: float = SYMBOL_RATE,
block_symbol_count: int = KNOWN_PILOT_BLOCK_SYMBOL_COUNT,
minimum_block_coherence: float = MINIMUM_KNOWN_PILOT_COHERENCE,
maximum_phase_fit_rmse_rad: float = MAXIMUM_KNOWN_PILOT_PHASE_FIT_RMSE_RAD,
) -> KnownPilotCarrierEstimate:
"""Оценить малую остаточную CFO по отдельному известному BPSK-пилоту.
Известные знаки удаляются, затем символы объединяются в короткие
когерентные блоки. Развёрнутая фаза блоков аппроксимируется устойчивой
линейной моделью Huber. Полезная нагрузка для оценки не используется.
"""
received = np.asarray(received_symbols, dtype=np.complex128)
known = np.asarray(known_symbols, dtype=np.complex128)
if received.ndim != 1 or known.ndim != 1:
raise ValueError("Принятый и известный пилоты должны быть одномерными")
if len(received) != len(known):
raise ValueError("Принятый и известный пилоты должны иметь одинаковую длину")
if not np.isfinite(symbol_rate) or symbol_rate <= 0.0:
raise ValueError("Символьная скорость должна быть положительной")
if not isinstance(block_symbol_count, (int, np.integer)) or block_symbol_count < 2:
raise ValueError("Размер когерентного блока должен быть целым и не меньше двух")
if not 0.0 < minimum_block_coherence <= 1.0:
raise ValueError("Порог когерентности должен находиться в интервале (0, 1]")
if not np.isfinite(maximum_phase_fit_rmse_rad) or maximum_phase_fit_rmse_rad <= 0.0:
raise ValueError("Порог ошибки фазовой модели должен быть положительным")
if not np.all(np.isfinite(received)) or not np.all(np.isfinite(known)):
raise ValueError("Пилот не должен содержать нечисловые значения")
if np.any(np.abs(known) <= 0.0):
raise ValueError("Все известные символы пилота должны быть ненулевыми")
block_count = len(received) // block_symbol_count
if block_count < MINIMUM_KNOWN_PILOT_BLOCK_COUNT:
raise ValueError(
f"Для оценки нужны не менее {MINIMUM_KNOWN_PILOT_BLOCK_COUNT} когерентных блоков"
)
usable_count = block_count * block_symbol_count
despread = received[:usable_count] * np.conj(known[:usable_count])
blocks = despread.reshape(block_count, block_symbol_count)
block_sums = np.sum(blocks, axis=1)
block_magnitude_sums = np.sum(np.abs(blocks), axis=1) + 1e-12
block_coherences = np.abs(block_sums) / block_magnitude_sums
mean_block_coherence = float(np.mean(block_coherences))
centers_symbols = (
np.arange(block_count, dtype=np.float64) * block_symbol_count
+ (block_symbol_count - 1) / 2.0
)
center_times_seconds = centers_symbols / symbol_rate
phases = np.unwrap(np.angle(block_sums))
base_weights = np.maximum(np.abs(block_sums), 1e-12)
design = np.column_stack((np.ones(block_count), center_times_seconds))
weights = base_weights.copy()
coefficients = np.zeros(2, dtype=np.float64)
for _iteration in range(12):
root_weights = np.sqrt(weights)
new_coefficients, *_unused = np.linalg.lstsq(
design * root_weights[:, np.newaxis],
phases * root_weights,
rcond=None,
)
residuals = phases - design @ new_coefficients
residual_median = float(np.median(residuals))
robust_scale = (
1.4826 * float(np.median(np.abs(residuals - residual_median)))
+ 1e-9
)
huber_limit = 1.345 * robust_scale
robust_weights = np.ones_like(residuals)
outliers = np.abs(residuals) > huber_limit
robust_weights[outliers] = huber_limit / np.abs(residuals[outliers])
weights = base_weights * robust_weights
if np.allclose(coefficients, new_coefficients, rtol=0.0, atol=1e-12):
coefficients = new_coefficients
break
coefficients = new_coefficients
residuals = phases - design @ coefficients
phase_fit_rmse_rad = float(
np.sqrt(np.sum(base_weights * residuals**2) / np.sum(base_weights))
)
frequency_hz = float(coefficients[1] / (2.0 * np.pi))
initial_phase_rad = float(coefficients[0])
phase_increment = float(2.0 * np.pi * frequency_hz / symbol_rate)
invalid_reasons: list[str] = []
if not all(
np.isfinite(value)
for value in (frequency_hz, initial_phase_rad, phase_fit_rmse_rad, mean_block_coherence)
):
invalid_reasons.append("оценка содержит нечисловое значение")
if mean_block_coherence < minimum_block_coherence:
invalid_reasons.append(
f"когерентность {mean_block_coherence:.4f} ниже порога {minimum_block_coherence:.2f}"
)
if phase_fit_rmse_rad > maximum_phase_fit_rmse_rad:
invalid_reasons.append(
f"RMSE фазы {phase_fit_rmse_rad:.4f} выше порога {maximum_phase_fit_rmse_rad:.2f} рад"
)
valid = not invalid_reasons
nan = float("nan")
return KnownPilotCarrierEstimate(
valid=valid,
invalid_reason="; ".join(invalid_reasons),
frequency_hz=frequency_hz if valid else nan,
phase_increment_rad_per_symbol=phase_increment if valid else nan,
initial_phase_rad=initial_phase_rad if valid else nan,
phase_fit_rmse_rad=phase_fit_rmse_rad,
mean_block_coherence=mean_block_coherence,
block_count=block_count,
known_symbol_count=len(known),
)
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py). # Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def estimate_carrier_parameters( def estimate_carrier_parameters(
received_symbols: np.ndarray, received_symbols: np.ndarray,

View File

@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import math
import pathlib import pathlib
import struct import struct
@@ -210,3 +211,158 @@ def test_frame_search_finds_the_start_in_a_shaped_signal() -> None:
recovered = found["symbol_samples"][start : start + len(bits)] recovered = found["symbol_samples"][start : start + len(bits)]
assert radio.parse_radio_frame(radio.bpsk_demodulate(recovered)) == packet assert radio.parse_radio_frame(radio.bpsk_demodulate(recovered)) == packet
# ------------------------------------------------------ Lab043: общие примитивы
def test_known_tone_peak_search_finds_both_windows() -> None:
frequencies = np.linspace(-100_000.0, 100_000.0, 4001)
powers = np.ones_like(frequencies)
powers[np.argmin(np.abs(frequencies + 51_200.0))] = 100.0
powers[np.argmin(np.abs(frequencies - 49_300.0))] = 80.0
peaks = radio.find_known_tone_peaks(frequencies, powers, 50_000.0, 30_000.0)
assert math.isclose(peaks["low_frequency_hz"], -51_200.0, abs_tol=1.0)
assert math.isclose(peaks["high_frequency_hz"], 49_300.0, abs_tol=1.0)
assert peaks["low_power"] == 100.0
assert peaks["high_power"] == 80.0
def test_known_tone_peak_search_returns_nan_without_bins() -> None:
frequencies = np.linspace(-1_000.0, 1_000.0, 101)
powers = np.ones_like(frequencies)
peaks = radio.find_known_tone_peaks(frequencies, powers, 50_000.0, 1_000.0)
assert math.isnan(peaks["low_frequency_hz"])
assert math.isnan(peaks["high_frequency_hz"])
@pytest.mark.parametrize("ppm", [20.0, -20.0, 100.0, -100.0])
def test_two_tone_clock_estimate_has_correct_sign_and_magnitude(ppm: float) -> None:
scale = 1.0 + ppm * 1e-6
carrier_offset_hz = -1_250.0
low = carrier_offset_hz - 50_000.0 * scale
high = carrier_offset_hz + 50_000.0 * scale
estimate = radio.estimate_two_tone_offsets(low, high, 50_000.0)
assert math.isclose(estimate["carrier_offset_hz"], carrier_offset_hz, abs_tol=1e-9)
assert math.isclose(estimate["clock_scale"], scale, abs_tol=1e-12)
assert math.isclose(estimate["sample_clock_error_ppm"], ppm, abs_tol=1e-6)
def test_two_tone_invalid_estimate_is_nan_not_zero() -> None:
estimate = radio.estimate_two_tone_offsets(float("nan"), 50_000.0, 50_000.0)
assert all(math.isnan(value) for value in estimate.values())
@pytest.mark.parametrize("carrier_offset_hz", [730.0, -730.0])
def test_coarse_frequency_correction_handles_both_signs(carrier_offset_hz: float) -> None:
sample_rate_hz = 20_000.0
indexes = np.arange(20_000, dtype=np.float64)
impaired = np.exp(1j * 2.0 * np.pi * carrier_offset_hz * indexes / sample_rate_hz)
corrected = radio.apply_coarse_frequency_correction(
impaired,
carrier_offset_hz,
sample_rate_hz,
)
assert np.max(np.abs(corrected - 1.0)) < 1e-9
@pytest.mark.parametrize(
"carrier_offset_hz",
[-10.0, -5.0, -2.0, -1.2, -1.0, -0.5, 0.5, 1.0, 1.2, 2.0, 5.0, 10.0],
)
def test_known_pilot_estimator_resolves_sub_hertz_cfo_with_hardware_like_phase_noise(
carrier_offset_hz: float,
) -> None:
symbol_count = 1_280
indexes = np.arange(symbol_count, dtype=np.float64)
known = np.where((indexes.astype(np.int64) * 73 + 19) % 127 < 64, 1.0, -1.0).astype(
np.complex128
)
estimates: list[float] = []
valid_flags: list[bool] = []
for repetition in range(96):
random_generator = np.random.default_rng(
43_000_000
+ int(round((carrier_offset_hz + 20.0) * 1_000.0))
+ repetition
)
phase_noise = random_generator.normal(0.0, 0.4691, symbol_count)
received = known * np.exp(
1j
* (
2.0 * np.pi * carrier_offset_hz * indexes / radio.SYMBOL_RATE
+ phase_noise
)
)
estimate = radio.estimate_known_pilot_carrier(received, known)
valid_flags.append(estimate.valid)
estimates.append(estimate.frequency_hz)
errors = np.asarray(estimates) - carrier_offset_hz
assert all(valid_flags)
assert np.all(np.sign(estimates) == np.sign(carrier_offset_hz))
assert abs(float(np.mean(errors))) < 0.08
assert float(np.std(errors, ddof=1)) < 0.18
assert float(np.percentile(np.abs(errors), 95.0)) < 0.35
def test_known_pilot_estimator_rejects_noise_instead_of_reporting_false_cfo() -> None:
random_generator = np.random.default_rng(43_043)
known = np.resize(np.asarray([-1.0, 1.0], dtype=np.complex128), 1_280)
noise = (
random_generator.normal(0.0, 1.0, len(known))
+ 1j * random_generator.normal(0.0, 1.0, len(known))
)
estimate = radio.estimate_known_pilot_carrier(noise, known)
assert not estimate.valid
assert estimate.invalid_reason
assert math.isnan(estimate.frequency_hz)
assert math.isnan(estimate.phase_increment_rad_per_symbol)
def _sample_at_positions(signal: np.ndarray, positions: np.ndarray) -> np.ndarray:
indexes = np.arange(len(signal), dtype=np.float64)
real = np.interp(positions, indexes, signal.real)
imaginary = np.interp(positions, indexes, signal.imag)
return real + 1j * imaginary
@pytest.mark.parametrize("ppm", [20.0, -20.0, 100.0, -100.0])
def test_clock_resampling_direction_reduces_timing_error(ppm: float) -> None:
scale = 1.0 + ppm * 1e-6
sample_count = 200_000
indexes = np.arange(sample_count, dtype=np.float64)
reference = np.exp(1j * 2.0 * np.pi * 0.071 * indexes)
received_length = math.floor(sample_count / scale)
received_positions = np.arange(received_length, dtype=np.float64) * scale
received = _sample_at_positions(reference, received_positions)
corrected = radio.resample_for_clock_scale(received, scale)
wrong_direction = radio.resample_for_clock_scale(received, 1.0 / scale)
uncorrected_count = min(len(received), len(reference))
corrected_count = min(len(corrected), len(reference)) - 2
wrong_count = min(len(wrong_direction), len(reference)) - 2
uncorrected_error = float(
np.mean(np.abs(received[:uncorrected_count] - reference[:uncorrected_count]) ** 2)
)
corrected_error = float(
np.mean(np.abs(corrected[:corrected_count] - reference[:corrected_count]) ** 2)
)
wrong_error = float(
np.mean(np.abs(wrong_direction[:wrong_count] - reference[:wrong_count]) ** 2)
)
assert len(corrected) == round(len(received) * scale)
assert corrected_error < uncorrected_error
assert corrected_error < wrong_error

View File

@@ -0,0 +1,891 @@
"""Быстрые синтетические проверки программной части Lab043."""
from __future__ import annotations
import hashlib
import json
import math
import numpy as np
import pytest
from scipy.signal import fftconvolve
from experiments import lab043_pluto_to_rtlsdr as lab043
from experiments import lab043_prbs11_hardware as lab043_hardware
from protocol import bpsk_radio as radio
from protocol.image_fragments import split_image_bytes
from protocol.packet import CRCError, MESSAGE_TYPE_TEXT, build_packet, parse_packet
def test_receive_duration_is_derived_from_actual_transmit_length() -> None:
assert lab043.receive_duration_seconds(4_800_000, 2_400_000.0) == 3.0
assert lab043.receive_sample_count(4_800_000, 2_400_000.0, 2_399_900.0) == math.ceil(
3.0 * 2_399_900.0
)
def test_continuous_blocks_are_joined_once_and_trimmed_at_end() -> None:
blocks = (
np.asarray([0, 1, 2], dtype=np.complex64),
np.asarray([3, 4, 5], dtype=np.complex64),
)
combined = lab043.combine_continuous_blocks(blocks, expected_sample_count=5)
assert np.array_equal(combined.real, np.arange(5))
def test_short_continuous_capture_is_rejected() -> None:
with pytest.raises(ValueError, match="короче"):
lab043.combine_continuous_blocks((np.zeros(9),), expected_sample_count=10)
def test_async_collector_preserves_order_trims_target_and_records_boundaries() -> None:
collector = lab043.ContinuousAsyncIqCollector(
expected_sample_count=7,
warmup_callback_count=1,
)
assert not collector.add_callback(0, np.asarray([100, 101], dtype=complex))
assert not collector.add_callback(1, np.asarray([0, 1, 2], dtype=complex))
assert not collector.add_callback(2, np.asarray([3, 4, 5], dtype=complex))
assert collector.add_callback(3, np.asarray([6, 7, 8], dtype=complex))
combined = collector.finalize()
assert np.array_equal(combined.real, np.arange(7))
assert len(combined) == 7
boundaries = collector.callback_boundaries
assert [row.callback_index for row in boundaries] == [0, 1, 2, 3]
assert [row.accepted_start_sample for row in boundaries] == [0, 0, 3, 6]
assert [row.accepted_end_sample for row in boundaries] == [0, 3, 6, 7]
assert boundaries[0].warmup_discarded
assert boundaries[-1].trimmed_at_target
assert boundaries[-1].source_sample_count == 3
assert boundaries[-1].accepted_sample_count == 1
def test_async_collector_rejects_duplicate_callback() -> None:
collector = lab043.ContinuousAsyncIqCollector(4, warmup_callback_count=0)
collector.add_callback(0, np.asarray([0, 1], dtype=complex))
with pytest.raises(ValueError, match="продублирован"):
collector.add_callback(0, np.asarray([2, 3], dtype=complex))
def test_async_collector_rejects_missing_callback() -> None:
collector = lab043.ContinuousAsyncIqCollector(4, warmup_callback_count=0)
collector.add_callback(0, np.asarray([0, 1], dtype=complex))
with pytest.raises(ValueError, match="пропуск"):
collector.add_callback(2, np.asarray([2, 3], dtype=complex))
def test_async_collector_rejects_early_finalize_and_extra_callback() -> None:
collector = lab043.ContinuousAsyncIqCollector(2, warmup_callback_count=0)
collector.add_callback(0, np.asarray([0], dtype=complex))
with pytest.raises(RuntimeError, match="короче"):
collector.finalize()
assert collector.add_callback(1, np.asarray([1], dtype=complex))
with pytest.raises(RuntimeError, match="уже набрал"):
collector.add_callback(2, np.asarray([2], dtype=complex))
def test_frame_concatenation_adds_no_lab043_gap() -> None:
first = lab043.shape_frame_like_lab042(np.ones(16, dtype=complex), samples_per_symbol=8)
second = lab043.shape_frame_like_lab042(-np.ones(12, dtype=complex), samples_per_symbol=8)
continuous = lab043.concatenate_frames_without_new_gaps((first, second))
assert len(continuous) == len(first) + len(second)
assert np.array_equal(continuous[: len(first)], first)
assert np.array_equal(continuous[len(first) :], second)
def test_spectrum_method_finds_two_strong_tones_and_cfo() -> None:
sample_rate_hz = 2_400_000.0
sample_count = 2 * lab043.CALIBRATION_NFFT
indexes = np.arange(sample_count, dtype=np.float64)
carrier_offset_hz = 1_250.0
clock_scale = 1.0 + 100.0e-6
low_hz = carrier_offset_hz - lab043.F_CAL_HZ * clock_scale
high_hz = carrier_offset_hz + lab043.F_CAL_HZ * clock_scale
random_generator = np.random.default_rng(43)
samples = (
np.exp(1j * 2.0 * np.pi * low_hz * indexes / sample_rate_hz)
+ 0.8 * np.exp(1j * 2.0 * np.pi * high_hz * indexes / sample_rate_hz)
+ 0.002
* (
random_generator.standard_normal(sample_count)
+ 1j * random_generator.standard_normal(sample_count)
)
)
estimate = lab043.estimate_calibration(samples, sample_rate_hz)
assert estimate.valid
assert estimate.low_peak_excess_db > 10.0
assert estimate.high_peak_excess_db > 10.0
assert math.isclose(estimate.carrier_offset_hz, carrier_offset_hz, abs_tol=5.0)
assert math.isclose(estimate.sample_clock_error_ppm, 100.0, abs_tol=60.0)
def _synthetic_two_tones(
low_hz: float,
high_hz: float,
seed: int,
) -> tuple[np.ndarray, float]:
sample_rate_hz = 240_000.0
sample_count = 120_000
indexes = np.arange(sample_count, dtype=np.float64)
random_generator = np.random.default_rng(seed)
samples = (
np.exp(1j * 2.0 * np.pi * low_hz * indexes / sample_rate_hz)
+ 0.8 * np.exp(1j * 2.0 * np.pi * high_hz * indexes / sample_rate_hz)
+ 0.003
* (
random_generator.standard_normal(sample_count)
+ 1j * random_generator.standard_normal(sample_count)
)
)
return samples, sample_rate_hz
def _phase_refinement_from_spectrum(
samples: np.ndarray,
sample_rate_hz: float,
) -> tuple[lab043.CalibrationResult, radio.TwoTonePhaseRefinement]:
spectrum_estimate = lab043.estimate_calibration(samples, sample_rate_hz)
assert spectrum_estimate.valid
phase_estimate = radio.refine_two_tone_frequencies_from_phase(
samples,
sample_rate_hz,
spectrum_estimate.f_low_hz,
spectrum_estimate.f_high_hz,
block_samples=240,
hop_samples=120,
)
return spectrum_estimate, phase_estimate
@pytest.mark.parametrize(
"tone_shift_hz",
[-1.0, -0.5, -0.25, -0.1, 0.1, 0.25, 0.5, 1.0],
)
def test_phase_refinement_improves_known_sub_hertz_common_shift(
tone_shift_hz: float,
) -> None:
true_low_hz = -lab043.F_CAL_HZ + tone_shift_hz
true_high_hz = lab043.F_CAL_HZ + tone_shift_hz
samples, sample_rate_hz = _synthetic_two_tones(
true_low_hz,
true_high_hz,
seed=43_000 + int((tone_shift_hz + 2.0) * 100),
)
old, refined = _phase_refinement_from_spectrum(samples, sample_rate_hz)
old_max_error_hz = max(
abs(old.f_low_hz - true_low_hz),
abs(old.f_high_hz - true_high_hz),
)
refined_max_error_hz = max(
abs(refined.low_frequency_hz - true_low_hz),
abs(refined.high_frequency_hz - true_high_hz),
)
assert refined_max_error_hz < 0.001
assert refined_max_error_hz < old_max_error_hz
@pytest.mark.parametrize(
"half_spacing_shift_hz",
[-1.0, -0.5, -0.25, -0.1, 0.1, 0.25, 0.5, 1.0],
)
def test_phase_refinement_improves_known_sub_hertz_spacing_shift(
half_spacing_shift_hz: float,
) -> None:
true_low_hz = -lab043.F_CAL_HZ - half_spacing_shift_hz
true_high_hz = lab043.F_CAL_HZ + half_spacing_shift_hz
true_offsets = radio.estimate_two_tone_offsets(
true_low_hz,
true_high_hz,
lab043.F_CAL_HZ,
)
samples, sample_rate_hz = _synthetic_two_tones(
true_low_hz,
true_high_hz,
seed=44_000 + int((half_spacing_shift_hz + 2.0) * 100),
)
old, refined = _phase_refinement_from_spectrum(samples, sample_rate_hz)
refined_offsets = radio.estimate_two_tone_offsets(
refined.low_frequency_hz,
refined.high_frequency_hz,
lab043.F_CAL_HZ,
)
old_error_ppm = abs(
old.sample_clock_error_ppm - true_offsets["sample_clock_error_ppm"]
)
refined_error_ppm = abs(
refined_offsets["sample_clock_error_ppm"]
- true_offsets["sample_clock_error_ppm"]
)
assert refined_error_ppm < 0.001
assert refined_error_ppm < old_error_ppm
@pytest.mark.parametrize("sample_clock_error_ppm", [100.0, -100.0])
def test_public_refined_calibration_returns_complete_contract(
sample_clock_error_ppm: float,
) -> None:
carrier_offset_hz = 1_234.5
scale = 1.0 + sample_clock_error_ppm * 1e-6
samples, sample_rate_hz = _synthetic_two_tones(
carrier_offset_hz - lab043.F_CAL_HZ * scale,
carrier_offset_hz + lab043.F_CAL_HZ * scale,
seed=45_000 + int(sample_clock_error_ppm),
)
result = lab043.estimate_refined_calibration(samples, sample_rate_hz)
assert isinstance(result, lab043.CalibrationResult)
assert result.valid
assert result.invalid_reason == ""
assert math.isclose(result.carrier_offset_hz, carrier_offset_hz, abs_tol=0.01)
assert math.isclose(
result.sample_clock_error_ppm,
sample_clock_error_ppm,
abs_tol=0.01,
)
assert math.isfinite(result.residual_cfo_hz)
assert math.isfinite(result.phase_fit_rmse_rad)
assert result.peak_margin_low_db >= lab043.MINIMUM_TONE_EXCESS_DB
assert result.peak_margin_high_db >= lab043.MINIMUM_TONE_EXCESS_DB
def test_public_refined_calibration_rejects_weak_or_missing_tone_without_exception() -> None:
sample_rate_hz = 240_000.0
sample_count = 120_000
indexes = np.arange(sample_count, dtype=np.float64)
random_generator = np.random.default_rng(46_000)
noise = 0.003 * (
random_generator.standard_normal(sample_count)
+ 1j * random_generator.standard_normal(sample_count)
)
one_tone = np.exp(1j * 2.0 * np.pi * lab043.F_CAL_HZ * indexes / sample_rate_hz)
weak = lab043.estimate_refined_calibration(noise, sample_rate_hz)
missing = lab043.estimate_refined_calibration(one_tone + noise, sample_rate_hz)
for result in (weak, missing):
assert isinstance(result, lab043.CalibrationResult)
assert not result.valid
assert result.invalid_reason
assert math.isnan(result.f_low_hz)
assert math.isnan(result.f_high_hz)
assert math.isnan(result.carrier_offset_hz)
assert math.isnan(result.sample_clock_error_ppm)
def test_tones_below_ten_decibels_are_rejected_with_nan() -> None:
frequencies = np.linspace(-100_000.0, 100_000.0, 4001)
powers = np.ones_like(frequencies)
powers[np.argmin(np.abs(frequencies + lab043.F_CAL_HZ))] = 9.0
powers[np.argmin(np.abs(frequencies - lab043.F_CAL_HZ))] = 9.0
estimate = lab043.estimate_calibration_from_spectrum(frequencies, powers)
assert not estimate.valid
assert math.isnan(estimate.f_low_hz)
assert math.isnan(estimate.f_high_hz)
assert math.isnan(estimate.carrier_offset_hz)
assert estimate.failure_reason
def _estimate(carrier_offset_hz: float, valid: bool = True) -> lab043.CalibrationResult:
if not valid:
return lab043._invalid_calibration(
"нет тонов",
noise_power=1.0,
)
return lab043.CalibrationResult(
valid=True,
invalid_reason="",
f_low_hz=-50_000.0 + carrier_offset_hz,
f_high_hz=50_000.0 + carrier_offset_hz,
carrier_offset_hz=carrier_offset_hz,
carrier_offset_ppm=carrier_offset_hz / lab043.CARRIER_HZ * 1e6,
clock_scale=1.0,
sample_clock_error_ppm=0.0,
residual_cfo_hz=0.0,
phase_fit_rmse_rad=0.0,
peak_margin_low_db=20.0,
peak_margin_high_db=21.0,
noise_power=1.0,
)
def test_three_calibrations_keep_individual_failures_and_finite_statistics() -> None:
summary = lab043.summarize_calibrations((_estimate(10.0), _estimate(14.0), _estimate(0, False)))
assert summary["capture_count"] == 3
assert summary["failure_count"] == 1
carrier = summary["carrier_offset_hz"]
assert carrier["mean"] == 12.0
assert carrier["minimum"] == 10.0
assert carrier["maximum"] == 14.0
assert carrier["standard_deviation"] == 2.0
def test_all_invalid_calibrations_keep_nan_aggregates() -> None:
summary = lab043.summarize_calibrations((_estimate(0, False),) * 3)
assert summary["failure_count"] == 3
assert math.isnan(summary["carrier_offset_hz"]["mean"])
def test_calibration_summary_requires_exactly_three_captures() -> None:
with pytest.raises(ValueError, match="ровно три"):
lab043.summarize_calibrations((_estimate(10.0), _estimate(12.0)))
def test_prbs11_has_full_2047_bit_period() -> None:
sequence = lab043.prbs11(2 * lab043.PRBS11_LENGTH)
first = sequence[: lab043.PRBS11_LENGTH]
second = sequence[lab043.PRBS11_LENGTH :]
assert np.array_equal(first, second)
assert not np.array_equal(first, np.roll(first, 23))
assert not np.array_equal(first, np.roll(first, 89))
def test_separate_pilot_is_fixed_balanced_and_independent_from_prbs11() -> None:
pilot = lab043.pilot_prbs7()
first_period = pilot[:127]
second_period = pilot[127:254]
assert len(pilot) == 1_280
assert np.array_equal(first_period, second_period)
assert int(np.count_nonzero(first_period)) == 64
assert not np.array_equal(pilot, lab043.prbs11(len(pilot)))
longest_run = max(
len(group)
for group in np.split(pilot, np.flatnonzero(np.diff(pilot)) + 1)
)
assert longest_run <= 7
@pytest.mark.parametrize(
"carrier_offset_hz",
[-10.0, -5.0, -2.0, -1.2, -1.0, -0.5, 0.5, 1.0, 1.2, 2.0, 5.0, 10.0],
)
def test_fixed_pilot_estimator_statistics_match_hardware_s_phase_dispersion(
carrier_offset_hz: float,
) -> None:
known = radio.bpsk_modulate(lab043.pilot_prbs7())
indexes = np.arange(len(known), dtype=np.float64)
estimates: list[float] = []
for repetition in range(96):
random_generator = np.random.default_rng(
43_043_000
+ int(round((carrier_offset_hz + 20.0) * 1_000.0))
+ repetition
)
phase_noise = random_generator.normal(0.0, 0.4691, len(known))
received = known * np.exp(
1j
* (
2.0 * np.pi * carrier_offset_hz * indexes / lab043.SYMBOL_RATE
+ phase_noise
)
)
estimate = radio.estimate_known_pilot_carrier(received, known)
assert estimate.valid
estimates.append(estimate.frequency_hz)
errors = np.asarray(estimates) - carrier_offset_hz
assert np.all(np.sign(estimates) == np.sign(carrier_offset_hz))
assert abs(float(np.mean(errors))) < 0.08
assert float(np.std(errors, ddof=1)) < 0.18
assert float(np.percentile(np.abs(errors), 95.0)) < 0.35
def test_fixed_pilot_estimator_rejects_noise_only_input() -> None:
random_generator = np.random.default_rng(43_043)
known = radio.bpsk_modulate(lab043.pilot_prbs7())
noise = (
random_generator.normal(0.0, 1.0, len(known))
+ 1j * random_generator.normal(0.0, 1.0, len(known))
)
estimate = radio.estimate_known_pilot_carrier(noise, known)
assert not estimate.valid
assert math.isnan(estimate.frequency_hz)
@pytest.mark.parametrize(
("label", "duration_seconds", "expected_bits"),
[("S", 0.25, 5_000), ("L", 2.0, 40_000)],
)
def test_prbs_transmission_plan_is_fixed_and_has_one_initial_marker(
label: str,
duration_seconds: float,
expected_bits: int,
) -> None:
plan = lab043.build_prbs11_transmission_plan(label, duration_seconds)
assert len(plan.payload_bits) == expected_bits
assert np.array_equal(
plan.payload_bits,
lab043.prbs11(expected_bits, lab043.PRBS11_INITIAL_STATE),
)
assert plan.bpsk_start_sample == (
plan.calibration_sample_count + plan.fixed_guard_sample_count
)
assert plan.calibration_sample_count == 600_000
assert plan.fixed_guard_sample_count == 240_000
assert np.max(np.abs(plan.tx_samples)) <= 1.0
if label == "S":
assert len(plan.pilot_bits) == lab043.PILOT_SYMBOL_COUNT
assert len(plan.pilot_bits) / lab043.SYMBOL_RATE == 0.064
else:
assert len(plan.pilot_bits) == 0
def test_noncyclic_tx_buffer_is_held_for_full_waveform_duration() -> None:
events: list[object] = []
class FakePluto:
def tx_destroy_buffer(self) -> None:
events.append("destroy")
def tx(self, samples: np.ndarray) -> None:
events.append(("tx", samples.copy()))
samples = np.arange(12, dtype=np.float32).astype(np.complex64)
duration = lab043_hardware.transmit_noncyclic_buffer_once(
FakePluto(),
samples,
actual_sample_rate_hz=48.0,
sleep_function=lambda seconds: events.append(("sleep", seconds)),
)
assert duration == 0.25
assert events[0] == "destroy"
assert events[1][0] == "tx"
assert np.array_equal(events[1][1], samples)
assert events[2] == ("sleep", 0.25)
assert events[3] == "destroy"
def _synthetic_prbs_channel(
transmitted: np.ndarray,
sample_rate_hz: float,
carrier_offset_hz: float,
clock_scale: float,
) -> np.ndarray:
received_length = max(1, math.floor(len(transmitted) / clock_scale))
receive_indexes = np.arange(received_length, dtype=np.float64)
source_positions = np.minimum(receive_indexes * clock_scale, len(transmitted) - 1.0)
source_indexes = np.arange(len(transmitted), dtype=np.float64)
received = np.interp(source_positions, source_indexes, transmitted.real) + 1j * np.interp(
source_positions,
source_indexes,
transmitted.imag,
)
return received * np.exp(
1j * 2.0 * np.pi * carrier_offset_hz * receive_indexes / sample_rate_hz
)
def test_prbs_modes_measure_drift_and_resampling_reduces_it() -> None:
samples_per_symbol = 16
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
clock_scale = 1.0 - 100.0e-6
carrier_offset_hz = 730.0
plan = lab043.build_prbs11_transmission_plan(
"S",
0.25,
sample_rate_hz=sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
transmitted_bpsk = plan.tx_samples[plan.bpsk_start_sample :]
received = _synthetic_prbs_channel(
transmitted_bpsk,
sample_rate_hz,
carrier_offset_hz,
clock_scale,
)
modes = lab043.analyze_prbs_modes(
received,
plan.payload_bits,
carrier_offset_hz,
clock_scale,
sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
assert modes["B"].detected
assert modes["C"].detected
assert modes["D"].detected
assert modes["B"].matched_bit_count == len(plan.payload_bits)
assert modes["D"].bit_error_rate == 0.0
assert modes["B"].timing_sro_ppm < 0.0
assert math.isclose(modes["B"].timing_sro_ppm, -100.0, abs_tol=40.0)
assert abs(modes["D"].accumulated_timing_drift_samples) < abs(
modes["B"].accumulated_timing_drift_samples
)
def test_marker_pilot_correction_and_unknown_prbs_follow_one_software_path() -> None:
samples_per_symbol = 16
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
coarse_cfo_hz = 700.0
residual_cfo_hz = 1.2
plan = lab043.build_prbs11_transmission_plan(
"S",
0.25,
sample_rate_hz=sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
transmitted_bpsk = plan.tx_samples[plan.bpsk_start_sample :]
received = _synthetic_prbs_channel(
transmitted_bpsk,
sample_rate_hz,
coarse_cfo_hz + residual_cfo_hz,
1.0,
)
modes = lab043.analyze_prbs_modes(
received,
plan.payload_bits,
coarse_cfo_hz,
1.0,
sample_rate_hz,
samples_per_symbol=samples_per_symbol,
known_pilot_bits=plan.pilot_bits,
)
wrong_reference_modes = lab043.analyze_prbs_modes(
received,
np.zeros_like(plan.payload_bits),
coarse_cfo_hz,
1.0,
sample_rate_hz,
samples_per_symbol=samples_per_symbol,
known_pilot_bits=plan.pilot_bits,
)
assert set(modes) == {"A", "B", "C", "D"}
assert modes["C"].pilot_estimate_valid
assert modes["C"].pilot_cfo_applied
assert math.isclose(
modes["C"].estimated_pilot_cfo_hz,
residual_cfo_hz,
abs_tol=0.25,
)
assert abs(modes["C"].residual_pilot_cfo_hz) < 0.05
assert modes["C"].bit_error_rate < modes["B"].bit_error_rate
assert modes["C"].bit_error_rate == 0.0
assert math.isclose(
wrong_reference_modes["C"].estimated_pilot_cfo_hz,
modes["C"].estimated_pilot_cfo_hz,
abs_tol=1e-12,
)
assert wrong_reference_modes["C"].bit_error_rate > 0.4
def test_calibration_and_bpsk_are_split_from_the_same_capture() -> None:
samples_per_symbol = 16
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
plan = lab043.build_prbs11_transmission_plan(
"S",
0.025,
sample_rate_hz=sample_rate_hz,
samples_per_symbol=samples_per_symbol,
)
leading = np.zeros(round(0.5 * sample_rate_hz), dtype=np.complex128)
trailing = np.zeros(round(0.5 * sample_rate_hz), dtype=np.complex128)
capture = np.concatenate((leading, plan.tx_samples, trailing))
calibration, bpsk, sections = lab043.split_calibration_and_bpsk(
capture,
plan,
sample_rate_hz,
sample_rate_hz,
)
assert len(calibration) > 0.15 * sample_rate_hz
assert len(bpsk) > len(plan.tx_samples) - plan.bpsk_start_sample
assert sections["calibration_end_sample"] < sections["bpsk_nominal_start_sample"]
def _continuous_synthetic_capture(
plan: lab043.PrbsTransmissionPlan,
tx_samples: np.ndarray | None = None,
) -> np.ndarray:
leading = np.zeros(
round(lab043.RX_LEADING_MARGIN_SECONDS * lab043.SAMPLE_RATE_HZ),
dtype=np.complex64,
)
trailing = np.zeros(
round(lab043.RX_TRAILING_MARGIN_SECONDS * lab043.SAMPLE_RATE_HZ),
dtype=np.complex64,
)
waveform = plan.tx_samples if tx_samples is None else tx_samples
return np.concatenate((leading, waveform.astype(np.complex64), trailing))
def test_full_short_s_processing_path_returns_calibration_and_modes() -> None:
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
capture = _continuous_synthetic_capture(plan)
result = lab043_hardware.analyze_captured_prbs(
capture,
plan,
lab043.SAMPLE_RATE_HZ,
lab043.SAMPLE_RATE_HZ,
)
assert result.processing_success
assert result.calibration.valid
assert set(result.modes) == {"A", "B", "C", "D"}
assert result.modes["D"].detected
assert result.modes["D"].matched_bit_count == len(plan.payload_bits)
assert result.modes["D"].bit_error_rate == 0.0
assert math.isfinite(result.modes["D"].evm_percent)
def test_full_short_s_missing_prbs_is_not_reported_as_zero_ber() -> None:
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
without_prbs = plan.tx_samples.copy()
without_prbs[plan.bpsk_start_sample :] = 0.0
capture = _continuous_synthetic_capture(plan, without_prbs)
result = lab043_hardware.analyze_captured_prbs(
capture,
plan,
lab043.SAMPLE_RATE_HZ,
lab043.SAMPLE_RATE_HZ,
)
assert result.calibration.valid
assert not result.processing_success
assert not result.modes["D"].detected
assert math.isnan(result.modes["D"].bit_error_rate)
def test_full_short_s_invalid_calibration_makes_all_modes_na() -> None:
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
one_tone_waveform = plan.tx_samples.copy()
indexes = np.arange(plan.calibration_sample_count, dtype=np.float64)
one_tone_waveform[: plan.calibration_sample_count] = 0.35 * np.exp(
1j * 2.0 * np.pi * lab043.F_CAL_HZ * indexes / lab043.SAMPLE_RATE_HZ
)
capture = _continuous_synthetic_capture(plan, one_tone_waveform)
result = lab043_hardware.analyze_captured_prbs(
capture,
plan,
lab043.SAMPLE_RATE_HZ,
lab043.SAMPLE_RATE_HZ,
)
assert not result.calibration.valid
assert result.calibration.invalid_reason
assert not result.processing_success
for mode in result.modes.values():
assert not mode.detected
assert math.isnan(mode.bit_error_rate)
def test_raw_survives_processing_exception_and_json_records_failure(tmp_path) -> None:
samples = np.asarray([0.25 + 0.5j, -0.5 + 0.25j], dtype=np.complex64)
metadata = {
"capture_status": "captured",
"processing_status": "pending",
"processing_error": None,
"capture_started_utc": "2026-08-19T12:00:00Z",
"tx_parameters": {"gain_db": -30.0},
"rx_parameters": {"gain_db": None},
"waveform_sha256": "a" * 64,
"callback_count": 2,
"callback_boundaries": [{"callback_index": 0}, {"callback_index": 1}],
"tx_waveform_duration_seconds": 0.25,
"expected_intervals": {"calibration": [0.0, 0.1]},
}
iq_path, json_path, result, error = lab043.save_then_process_capture(
tmp_path / "failed_after_rx",
samples,
metadata,
lambda _samples: (_ for _ in ()).throw(RuntimeError("synthetic failure")),
)
document = json.loads(json_path.read_text(encoding="utf-8"))
assert iq_path.exists()
assert json_path.exists()
assert result is None
assert error == "RuntimeError: synthetic failure"
assert document["capture_status"] == "captured"
assert document["processing_status"] == "processing_failed"
assert document["processing_error"] == error
assert document["raw_verified"]
assert document["iq_sha256"] == hashlib.sha256(iq_path.read_bytes()).hexdigest()
def test_ber_is_measured_before_crc() -> None:
expected = lab043.prbs11()
received = expected.copy()
received[[0, 100, 1000]] ^= 1
errors, bit_error_rate = lab043.bit_error_rate(expected, received)
assert errors == 3
assert bit_error_rate == 3 / lab043.PRBS11_LENGTH
def test_ber_is_nan_for_truncated_known_sequence() -> None:
expected = lab043.prbs11()
errors, bit_error_rate = lab043.bit_error_rate(expected, expected[:-1])
assert errors == 0
assert math.isnan(bit_error_rate)
@pytest.mark.parametrize("initial_sample_phase", [0, 3, 7, 15])
def test_symbol_sample_phase_is_selected_separately(initial_sample_phase: int) -> None:
samples_per_symbol = 16
_, marker_symbols = radio.build_frame_marker()
taps = radio.root_raised_cosine_taps(0.35, samples_per_symbol, 10)
upsampled = np.zeros(len(marker_symbols) * samples_per_symbol, dtype=complex)
upsampled[::samples_per_symbol] = marker_symbols
transmitted = fftconvolve(upsampled, taps, mode="full")
shifted = np.concatenate((np.zeros(initial_sample_phase), transmitted))
matched = fftconvolve(shifted, taps, mode="full")
found = radio.find_radio_frame(matched, marker_symbols, samples_per_symbol)
assert found["sample_phase"] == initial_sample_phase
assert found["score"] > 0.99
def test_one_control_packet_passes_crc() -> None:
assert lab043.control_packet_crc_roundtrip(b"known payload")
def test_corrupted_control_packet_fails_crc() -> None:
packet = bytearray(build_packet(b"known payload", MESSAGE_TYPE_TEXT, 43))
packet[-1] ^= 1
bits, _, _ = radio.build_radio_frame(bytes(packet))
recovered = radio.parse_radio_frame(bits)
assert recovered is not None
with pytest.raises(CRCError):
parse_packet(recovered)
def test_modes_a_b_c_process_the_same_capture_and_c_recovers_prbs() -> None:
clock_scale = 1.0 + 100.0e-6
capture, expected = lab043.synthesize_known_bpsk_capture(
carrier_offset_hz=730.0,
clock_scale=clock_scale,
samples_per_symbol=16,
)
results = lab043.process_bpsk_modes(
capture,
expected,
coarse_carrier_offset_hz=700.0,
clock_scale=clock_scale,
samples_per_symbol=16,
)
assert set(results) == {"A", "B", "C"}
assert results["C"].bit_error_rate == 0.0
assert results["C"].fine_cfo_applied
assert not math.isfinite(results["A"].bit_error_rate) or (
results["A"].bit_error_rate > results["C"].bit_error_rate
)
assert results["B"].bit_error_rate > results["C"].bit_error_rate
def test_missing_jpeg_fragment_never_produces_an_image() -> None:
fragments = split_image_bytes(bytes(range(250)) * 5, image_id=43, fragment_data_size=128)
assert len(fragments) == 10
assert lab043.try_reassemble_complete_image(fragments[:-1]) is None
def _complete_acceptance() -> lab043.AcceptanceResult:
return lab043.AcceptanceResult(10, 10, 10, 10, True, True, True, True)
@pytest.mark.parametrize(
"result",
[
lab043.AcceptanceResult(9, 10, 10, 10, True, True, True, True),
lab043.AcceptanceResult(10, 10, 9, 10, True, True, True, True),
lab043.AcceptanceResult(10, 10, 10, 9, False, False, False, True),
lab043.AcceptanceResult(10, 10, 10, 10, True, True, True, False),
],
)
def test_exit_zero_requires_every_acceptance_layer(result: lab043.AcceptanceResult) -> None:
assert lab043.exit_code_for_acceptance(result) == 1
def test_exit_zero_is_allowed_for_complete_acceptance() -> None:
result = _complete_acceptance()
assert result.radio_passed
assert result.application_passed
assert lab043.exit_code_for_acceptance(result) == 0
def test_reference_iq_format_contains_reprocessing_metadata(tmp_path) -> None:
metadata = {
"actual_sample_rate_hz": 2_400_000.0,
"center_frequency_hz": 435_000_000.0,
"rx_gain_db": 19.7,
"capture_started_utc": "2026-08-19T12:00:00Z",
"capture_order": 1,
"tx_parameters": {"gain_db": -10.0},
"calibration": {"carrier_offset_hz": 123.0, "clock_scale": 1.00002},
}
samples = np.asarray([1 + 2j, 3 + 4j], dtype=np.complex64)
iq_path, metadata_path = lab043.save_reference_iq_capture(
tmp_path / "reference",
samples,
metadata,
)
restored = np.load(iq_path, allow_pickle=False)
document = json.loads(metadata_path.read_text(encoding="utf-8"))
assert np.array_equal(restored, samples)
assert document["sample_count"] == 2
assert document["dtype"] == "complex64"
assert len(document["iq_sha256"]) == 64
assert document["calibration"]["clock_scale"] == 1.00002
def test_reference_iq_rejects_incomplete_metadata(tmp_path) -> None:
with pytest.raises(ValueError, match="Не хватает метаданных"):
lab043.save_reference_iq_capture(tmp_path / "reference", np.zeros(4), {})
def test_diagnostic_csv_txt_and_png_are_created_from_supplied_results(tmp_path) -> None:
calibrations = (_estimate(10.0), _estimate(12.0), _estimate(14.0))
capture, expected = lab043.synthesize_known_bpsk_capture(30.0, 1.0, 16)
modes = lab043.process_bpsk_modes(capture, expected, 0.0, 1.0, 16)
paths = lab043.save_diagnostic_artifacts(
tmp_path,
calibrations,
modes,
_complete_acceptance(),
)
assert len(paths) == 5
assert all(path.exists() and path.stat().st_size > 0 for path in paths)
summary = (tmp_path / "lab043_summary.csv").read_text(encoding="utf-8")
assert "radio_passed" in summary
assert summary.rstrip().endswith(",0")
assert (tmp_path / "lab043_calibration.png").stat().st_size > 1000
def test_all_embedded_lab043_checks_pass_without_hardware() -> None:
results = lab043.run_functional_tests()
assert len(results) == 10
assert all(result.passed for result in results), results