Compare commits
6 Commits
0ae04ae4a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0fa7e8a46 | ||
|
|
fa2e473c6d | ||
|
|
fce14f038e | ||
|
|
13f183f7c7 | ||
|
|
a37ad1f7b3 | ||
|
|
46b10b1243 |
@@ -12,7 +12,7 @@ argument-hint: <номер лабы> [краткая постановка]
|
|||||||
|
|
||||||
Запусти агента `Explore` с запросом: что в `protocol/` уже относится к этой задаче,
|
Запусти агента `Explore` с запросом: что в `protocol/` уже относится к этой задаче,
|
||||||
какие модули придётся трогать, где есть пересечение или дублирование, как устроены
|
какие модули придётся трогать, где есть пересечение или дублирование, как устроены
|
||||||
две-три ближайшие по смыслу лабы в `tests/`.
|
две-три ближайшие по смыслу лабы в `experiments/`.
|
||||||
|
|
||||||
Прочитай сам последние записи в `PROJECT_LOG.md` — там указано, чем должен
|
Прочитай сам последние записи в `PROJECT_LOG.md` — там указано, чем должен
|
||||||
заниматься следующий этап.
|
заниматься следующий этап.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
WATCHED = re.compile(r"[\\/](?:protocol|tests)[\\/][^\\/]+\.py$")
|
WATCHED = re.compile(r"[\\/](?:protocol|experiments|tests|tools)[\\/][^\\/]+\.py$")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
60
.gitlab-ci.yml
Normal file
60
.gitlab-ci.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Непрерывная проверка SDR Rover Link.
|
||||||
|
#
|
||||||
|
# Здесь выполняются только быстрые проверки: они должны укладываться в
|
||||||
|
# секунды. Лабораторные эксперименты сюда не помещаются — они длятся
|
||||||
|
# минутами, требуют исходных данных из data/raw и в части случаев
|
||||||
|
# подключённого PlutoSDR. Их запуск остаётся ручным.
|
||||||
|
#
|
||||||
|
# Оборудование конвейеру не нужно: устанавливается только расчётное
|
||||||
|
# окружение, без pyadi-iio и libiio.
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- gate
|
||||||
|
- test
|
||||||
|
- lint
|
||||||
|
|
||||||
|
default:
|
||||||
|
image: python:3.13
|
||||||
|
before_script:
|
||||||
|
- python --version
|
||||||
|
- python -m pip install --quiet --upgrade pip
|
||||||
|
- python -m pip install --quiet -r requirements.txt
|
||||||
|
cache:
|
||||||
|
key: "python-$CI_COMMIT_REF_SLUG"
|
||||||
|
paths:
|
||||||
|
- .cache/pip
|
||||||
|
|
||||||
|
variables:
|
||||||
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||||
|
PYTHONIOENCODING: "utf-8"
|
||||||
|
|
||||||
|
# Дешёвый шлюз: синтаксис всех файлов, импорт protocol и функциональные
|
||||||
|
# проверки тех лабораторных, которым не нужны данные эксперимента.
|
||||||
|
# Выполняется первым, потому что ловит самые грубые поломки за секунду.
|
||||||
|
быстрый-шлюз:
|
||||||
|
stage: gate
|
||||||
|
script:
|
||||||
|
- python tools/quick_gate.py
|
||||||
|
|
||||||
|
# Быстрые проверки ядра протокола.
|
||||||
|
тесты-ядра:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- python -m pytest tests/ -q --junitxml=report.xml
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
reports:
|
||||||
|
junit: report.xml
|
||||||
|
expire_in: 1 week
|
||||||
|
|
||||||
|
# Проверка стиля. Пока не блокирует конвейер: в лабораторных Lab001–Lab041
|
||||||
|
# принято своеобразное оформление с переносами аргументов, и приводить их
|
||||||
|
# к единому стилю сейчас означало бы тронуть 50 файлов с зафиксированными
|
||||||
|
# результатами. Ограничение снимается, когда договоримся о правилах.
|
||||||
|
стиль:
|
||||||
|
stage: lint
|
||||||
|
allow_failure: true
|
||||||
|
before_script:
|
||||||
|
- python -m pip install --quiet ruff
|
||||||
|
script:
|
||||||
|
- ruff check protocol/ tests/ tools/
|
||||||
103
PROJECT_LOG.md
103
PROJECT_LOG.md
@@ -488,3 +488,106 @@ git version 2.51.1.windows.1
|
|||||||
## Влияние на ближайшие работы
|
## Влияние на ближайшие работы
|
||||||
|
|
||||||
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний.
|
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Запись 019
|
||||||
|
|
||||||
|
## Дата
|
||||||
|
|
||||||
|
10 августа 2026 года
|
||||||
|
|
||||||
|
## Тема
|
||||||
|
|
||||||
|
Lab042: первая передача изображения через реальный радиотракт. Эфирная проверка на паре антенн.
|
||||||
|
|
||||||
|
## Причина эфирного прогона
|
||||||
|
|
||||||
|
Кабельная петля требует переходника SMA папа-папа, которого пока нет: пигтейлы к антеннам оканчиваются папой, а разъёмы Pluto — мамы. Чтобы не простаивать, выполнена вспомогательная эфирная проверка на двух антеннах, разнесённых на 60 см. Она не заменяет кабельный опыт: уровни в помещении не контролируются строго, возможны переотражения. Её задача — снять последнюю неизвестность в коде, а именно обвязку вокруг устройства, которая в моделях не проверяется.
|
||||||
|
|
||||||
|
## Условия
|
||||||
|
|
||||||
|
- Устройство Analog Devices PlutoSDR Rev.C, чип AD9361, прошивка v0.32.
|
||||||
|
- Несущая 225 МГц, внутри целевого диапазона Записи 018.
|
||||||
|
- Антенны, рассчитанные на 108 МГц, работают на несущей как полуволновые. Частота передачи и частота, на которую рассчитана антенна, различны, поэтому в авиационный диапазон 108–137 МГц опыт не заходит.
|
||||||
|
- Изображение 320x180 в градациях серого, JPEG 4756 байт при качестве 35, десять фрагментов.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
- Изображение передано и собрано побайтово идентично исходному. Контрольная сумма MD5 переданного и принятого файла совпала.
|
||||||
|
- Десять фрагментов из десяти, ни одной несошедшейся контрольной суммы.
|
||||||
|
- Средняя нормированная корреляция при поиске кадра 0,99951.
|
||||||
|
- Средняя оценка ухода несущей минус 0,368 Гц, что отражает общий опорный генератор передатчика и приёмника.
|
||||||
|
- Канальная скорость 18,404 кбит/с при длительности сигнала в эфире около 2,07 секунды.
|
||||||
|
- Результат воспроизведён тремя запусками подряд с одинаковой контрольной суммой.
|
||||||
|
|
||||||
|
## Дефекты, вскрытые железом
|
||||||
|
|
||||||
|
Программная модель проходила чисто, но на устройстве обнаружились четыре дефекта, каждый воспроизведён и устранён.
|
||||||
|
|
||||||
|
- Кадр длиной 566 528 отсчётов не помещался в приёмный буфер 262 144. Между отдельными чтениями поток отсчётов не является непрерывным, и кадр на стыке разрушался. Приём переведён на один буфер, размер которого рассчитывается по длине кадра.
|
||||||
|
- Частотная коррекция применялась всегда, тогда как при общем опорном генераторе истинный уход близок к нулю. Оценка по маркеру длиной 80 символов состояла из шума, и коррекция им поворачивала фазу вдоль всего кадра: измерено 65 процентов ошибочных битов. Перенесена защита should_apply_cfo_correction из Lab023, разрешающая коррекцию только вне мёртвой зоны.
|
||||||
|
- Усиление приёмника 20 дБ давало сигнал около 0,0003 от шкалы АЦП. Рабочие уровни подобраны измерением: приёмник 50 дБ, передатчик минус 10 дБ дают ноль ошибочных битов с запасом.
|
||||||
|
- Величина вектора ошибки при неудачном приёме возвращала ноль, то есть показывала идеальный сигнал там, где сигнала не было. Это тот же класс ошибки, что вписанные показатели Lab041: метрика при отказе рапортует лучшее значение. Заменена на явный признак невычислимости.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Эфирный прогон является проверкой тракта, а не измерением: уровни не контролируются как в кабельной сборке.
|
||||||
|
- Потолок усиления передатчика в эфирном режиме рассчитывается из бюджета линии по расстоянию между антеннами, а не задан константой.
|
||||||
|
|
||||||
|
## Следующий этап
|
||||||
|
|
||||||
|
Кабельная петля через аттенюатор 30 дБ после получения переходника папа-папа. Это и есть заявленный опыт Lab042 с контролируемыми уровнями, его результат станет основным для лабораторной. Затем Lab043 — разведение на два устройства.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Запись 020
|
||||||
|
|
||||||
|
## Дата
|
||||||
|
|
||||||
|
11 августа 2026 года
|
||||||
|
|
||||||
|
## Тема
|
||||||
|
|
||||||
|
Lab042 завершена: кабельная петля через аттенюатор, зависимость доставки от мощности, устранение трёх дефектов отчётности.
|
||||||
|
|
||||||
|
## Схема опыта
|
||||||
|
|
||||||
|
Выход передатчика PlutoSDR через кабель SMA, аттенюатор AT30S 30 дБ, переходник и второй кабель на вход приёмника того же устройства. Антенны сняты. Это заявленный основной опыт лабораторной с контролируемыми уровнями, в отличие от вспомогательной эфирной проверки Записи 019.
|
||||||
|
|
||||||
|
## Зависимость доставки от мощности передатчика
|
||||||
|
|
||||||
|
Кабельный тракт при неизменной схеме исследован по усилению передатчика. Получен отчётливый переход от отказа к устойчивой передаче:
|
||||||
|
|
||||||
|
- TX минус 30 дБ: 0 из 10 фрагментов, контрольная сумма не сходится ни разу, средняя корреляция 0,936. Сигнал ниже порога уверенного приёма.
|
||||||
|
- TX минус 20 дБ: 8 из 10 фрагментов, средняя корреляция 0,992. Пограничный режим: часть кадров проходит.
|
||||||
|
- TX минус 10 дБ: 10 из 10 фрагментов, изображение собрано побайтово. Устойчивая передача.
|
||||||
|
|
||||||
|
Значения TX минус 30 и минус 20 дБ — исследование порога, повторять их не требуется. Рабочая точка кабельной петли: TX минус 10 дБ, RX плюс 20 дБ, аттенюатор 30 дБ, несущая 435 МГц.
|
||||||
|
|
||||||
|
Начальные умолчания кабельного режима TX минус 30 и RX плюс 20 дБ на железе прежде не проверялись; отказ первого прогона объяснялся именно этим, а не дефектом обработки.
|
||||||
|
|
||||||
|
## Результат рабочей точки
|
||||||
|
|
||||||
|
- Найдено 10 кадров из 10, контрольная сумма сошлась у всех десяти, восстановлены все фрагменты.
|
||||||
|
- Изображение собрано и совпадает с переданным. Совпадение подтверждено независимо: SHA-256 принятого файла сверен с SHA-256 переданного JPEG, пересобранного из исходника отдельно от прогона. Оба равны 751d7ea0d8711e2ef54fc5cf209a809cfee5bbb14916dd20aa201d5285b40286.
|
||||||
|
- Размер JPEG 4756 байт, качество 30, десять фрагментов.
|
||||||
|
- Длительность сигнала около 2,07 секунды, канальная скорость 18,404 кбит/с.
|
||||||
|
- Кадр 8 прошёл контрольную сумму при пониженной корреляции поиска 0,866 против 0,998 у остальных: низкая корреляция маркера не означает испорченных данных, фаза дискретизации была подобрана и кадр демодулировался чисто.
|
||||||
|
|
||||||
|
## Устранённые дефекты отчётности
|
||||||
|
|
||||||
|
Разбор кабельного прогона вскрыл три дефекта, при которых провал мог быть принят за успех. Все три устранены, каждый закрыт быстрым тестом в tests/test_lab042_reporting.py.
|
||||||
|
|
||||||
|
- Величина вектора ошибки нормировалась на единичный эталон, из-за чего при слабом сигнале становилась искусственно малой: провалившийся прогон показывал EVM меньше, чем успешный. Нормировка исправлена на мощность подогнанного сигнала. При невозможности вычислить величину возвращается признак невычислимости, а не ноль.
|
||||||
|
- Отчёт перечислял lab042_received.jpg среди созданных файлов безусловно, даже когда изображение не собрано и файла нет. Теперь файл указывается только при фактической сборке.
|
||||||
|
- Программа завершалась кодом ноль при полном отказе передачи. Введён критерий приёмки: при несобранном изображении код завершения ненулевой, чтобы автоматика не приняла провал за успех.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- Быстрые тесты ядра расширены с 125 до 135. Добавленные проверяют неуспешный путь: провал даёт ненулевой код, отсутствующий файл не объявляется созданным, невычислимая метрика не превращается в ноль.
|
||||||
|
- Окончательный кабельный прогон рабочей точки выполнен уже исправленной версией программы, поэтому итоговый отчёт сформирован ею.
|
||||||
|
|
||||||
|
## Следующий этап
|
||||||
|
|
||||||
|
Lab043: передаёт PlutoSDR, принимает RTL-SDR. Общий опорный генератор исчезает, впервые появляется реальное рассогласование несущей и частоты дискретизации двух независимых устройств. Здесь понадобится защищённая частотная коррекция, перенесённая в Записи 019.
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
## 4. Текущее состояние
|
## 4. Текущее состояние
|
||||||
|
|
||||||
**Выполнено:** лабораторные Lab001–Lab041. Пройден путь от структуры пакета до сеансов связи с защитой от пакетов предыдущего сеанса.
|
**Выполнено:** лабораторные Lab001–Lab042. Пройден путь от структуры пакета до первой передачи изображения через реальный радиотракт.
|
||||||
|
|
||||||
Что реализовано и проверено:
|
Что реализовано и проверено:
|
||||||
|
|
||||||
@@ -45,11 +45,12 @@
|
|||||||
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
|
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
|
||||||
- приоритетное обслуживание команд, телеметрии и видео;
|
- приоритетное обслуживание команд, телеметрии и видео;
|
||||||
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
|
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
|
||||||
- идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса.
|
- идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса;
|
||||||
|
- передача JPEG через настоящий радиотракт на одном PlutoSDR: сначала по эфиру на паре антенн, затем по кабелю через аттенюатор 30 дБ. Принятое изображение совпало с переданным побайтово (Записи 019 и 020).
|
||||||
|
|
||||||
**Что ещё не сделано:** передатчик ни разу не включался. Весь тракт проверен на моделях и на приёме, но не в эфире.
|
**Что ещё не сделано:** два независимых устройства пока не разведены. Все передачи выполнены в петле одного PlutoSDR, где передатчик и приёмник делят опорный генератор, поэтому рассогласования несущей и частоты дискретизации между сторонами почти нет.
|
||||||
|
|
||||||
**Ближайшая работа:** Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR. Техническое задание: [docs/lab042_hardware_loopback_spec.md](docs/lab042_hardware_loopback_spec.md).
|
**Ближайшая работа:** Lab043 — передача с PlutoSDR на приём RTL-SDR. Общий опорный генератор исчезает, впервые появляется реальное рассогласование двух устройств. Техническое задание на завершённую Lab042: [docs/lab042_hardware_loopback_spec.md](docs/lab042_hardware_loopback_spec.md).
|
||||||
|
|
||||||
Хронология решений и результатов: [PROJECT_LOG.md](PROJECT_LOG.md).
|
Хронология решений и результатов: [PROJECT_LOG.md](PROJECT_LOG.md).
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ python -m pip install -r requirements-hardware.txt
|
|||||||
python -m pytest tests/ -q
|
python -m pytest tests/ -q
|
||||||
```
|
```
|
||||||
|
|
||||||
**Дешёвый шлюз** — синтаксис, импорт `protocol`, функциональные проверки лабораторных:
|
**Дешёвый шлюз** — синтаксис, импорт `protocol`, быстрые проверки ядра и функциональные проверки лабораторных. Отвечает на один вопрос: не сломана ли кодовая база прямо сейчас:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/quick_gate.py
|
python tools/quick_gate.py
|
||||||
|
|||||||
BIN
data/processed/lab042/lab042_constellation.png
Normal file
BIN
data/processed/lab042/lab042_constellation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
data/processed/lab042/lab042_fragment_delivery.png
Normal file
BIN
data/processed/lab042/lab042_fragment_delivery.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
data/processed/lab042/lab042_frame_correlation.png
Normal file
BIN
data/processed/lab042/lab042_frame_correlation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
11
data/processed/lab042/lab042_frames.csv
Normal file
11
data/processed/lab042/lab042_frames.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
index,packet_bytes,frame_bits,correlation_score,sample_phase,carrier_offset_hz,carrier_coherence,cfo_correction_applied,error_vector_magnitude,frame_found,header_parsed,crc_valid,fragment_recovered
|
||||||
|
0,536,4384,0.9991557547061216,19,-0.13990840791652204,0.9997935741467082,False,0.3400949142192574,True,True,True,True
|
||||||
|
1,536,4384,0.9986269709852709,51,-0.6704420071784511,0.999254107740255,False,0.1962803864132724,True,True,True,True
|
||||||
|
2,536,4384,0.9988503105697645,75,0.16739056727394086,0.9997145042457541,False,0.16392107712020013,True,True,True,True
|
||||||
|
3,536,4384,0.9984613689137739,69,0.12541328438695132,0.9993958830571172,False,0.18678168516977972,True,True,True,True
|
||||||
|
4,536,4384,0.9986165925127464,37,0.522370456657967,0.9996364225258064,False,0.15291774582164716,True,True,True,True
|
||||||
|
5,536,4384,0.9988383991200348,32,-0.1940305233482036,0.9995177607884775,False,0.1768680391093208,True,True,True,True
|
||||||
|
6,536,4384,0.9993940824417119,51,0.35917365385569155,0.9997260503953309,False,0.1442403116319193,True,True,True,True
|
||||||
|
7,536,4384,0.9986343538268945,75,0.2939902862233339,0.9996675003381007,False,0.17829502789805413,True,True,True,True
|
||||||
|
8,536,4384,0.9988608071024292,69,-0.7492708746413825,0.9995548745704333,False,0.15854008568562075,True,True,True,True
|
||||||
|
9,172,1472,0.9984590339381546,60,0.5753674284248689,0.9992453603986811,False,0.232235822035248,True,True,True,True
|
||||||
|
BIN
data/processed/lab042/lab042_received.jpg
Normal file
BIN
data/processed/lab042/lab042_received.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
85
data/processed/lab042/lab042_report.txt
Normal file
85
data/processed/lab042/lab042_report.txt
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR
|
||||||
|
|
||||||
|
Режим прогона
|
||||||
|
- Канал: cable.
|
||||||
|
- Трасса: кабель через аттенюатор 30 дБ.
|
||||||
|
- Несущая 435.000 МГц, одинаковая на передачу и приём. Сигнал в эфир не выходит, поэтому её значение произвольно.
|
||||||
|
- Усиление передатчика -10.0 дБ, приёмника 20.0 дБ, режим ручной.
|
||||||
|
- Антенны не подключались: они создали бы путь распространения параллельно кабельному.
|
||||||
|
|
||||||
|
Параметры тракта
|
||||||
|
- Символьная скорость 20000 симв/с, отсчётов на символ 128.
|
||||||
|
- Частота дискретизации 2560000 Гц. Минимум устройства 2083333 Гц.
|
||||||
|
- Скругление 0.35, длина фильтра 10 символов, защитный интервал 16 символов.
|
||||||
|
- Значения перенесены из Lab018 без изменений, кроме передискретизации: она поднята с 32 до 128,
|
||||||
|
потому что частота 640 000 Гц из Lab018 ниже предела устройства втрое.
|
||||||
|
|
||||||
|
Передаваемое изображение
|
||||||
|
- Источник data/raw/lab011_scene.jpg, 320x180, качество 30.
|
||||||
|
- Размер JPEG 4756 байт.
|
||||||
|
- Фрагментов 10 по 512 байт данных.
|
||||||
|
- Пакет протокола от 172 до 536 байт, радиокадр от 1472 до 4384 бит.
|
||||||
|
- Последний фрагмент изображения короче остальных, поэтому его кадр меньше. Размер каждого кадра приведён в lab042_frames.csv.
|
||||||
|
- Всего передано 5292544 отсчётов, что соответствует 2.067 с эфира.
|
||||||
|
|
||||||
|
Результат приёма
|
||||||
|
- Кадров найдено 10 из 10.
|
||||||
|
- Кадров с несошедшейся CRC32: 0.
|
||||||
|
- Фрагментов восстановлено 10, доля 100.000%.
|
||||||
|
- Изображение собрано: да.
|
||||||
|
- Совпадает с переданным побайтово: да.
|
||||||
|
- Размер принятого изображения 4756 байт.
|
||||||
|
|
||||||
|
Качество сигнала
|
||||||
|
- Средняя нормированная корреляция при поиске кадра 0.99879.
|
||||||
|
- Средняя оценка ухода несущей 0.029 Гц.
|
||||||
|
- Средняя величина вектора ошибки 19.302%.
|
||||||
|
|
||||||
|
Скорость
|
||||||
|
- Длительность сигнала в эфире 2.067 с.
|
||||||
|
- Канальная скорость 18.404 кбит/с — полезные байты, делённые на время эфира.
|
||||||
|
- Полное время работы программы 16.648 с, что даёт 2.285 кбит/с.
|
||||||
|
- В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала.
|
||||||
|
|
||||||
|
Кадры
|
||||||
|
номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент
|
||||||
|
0 | 0.99916 | 19 | -0.140 | 0.99979 | 34.009 | да | да | да | да
|
||||||
|
1 | 0.99863 | 51 | -0.670 | 0.99925 | 19.628 | да | да | да | да
|
||||||
|
2 | 0.99885 | 75 | 0.167 | 0.99971 | 16.392 | да | да | да | да
|
||||||
|
3 | 0.99846 | 69 | 0.125 | 0.99940 | 18.678 | да | да | да | да
|
||||||
|
4 | 0.99862 | 37 | 0.522 | 0.99964 | 15.292 | да | да | да | да
|
||||||
|
5 | 0.99884 | 32 | -0.194 | 0.99952 | 17.687 | да | да | да | да
|
||||||
|
6 | 0.99939 | 51 | 0.359 | 0.99973 | 14.424 | да | да | да | да
|
||||||
|
7 | 0.99863 | 75 | 0.294 | 0.99967 | 17.830 | да | да | да | да
|
||||||
|
8 | 0.99886 | 69 | -0.749 | 0.99955 | 15.854 | да | да | да | да
|
||||||
|
9 | 0.99846 | 60 | 0.575 | 0.99925 | 23.224 | да | да | да | да
|
||||||
|
|
||||||
|
Функциональные проверки
|
||||||
|
- ПРОЙДЕНО — 01. Размер подготовленного JPEG: JPEG занимает 4756 байт при пределе 5000, качество 30
|
||||||
|
- ПРОЙДЕНО — 02. Разбиение и сборка изображения: 10 фрагментов собираются в исходные байты
|
||||||
|
- ПРОЙДЕНО — 03. Кадр несёт пакет с CRC32: все 10 кадров несут пакет с сошедшейся CRC32
|
||||||
|
- ПРОЙДЕНО — 04. Длина сформированного кадра: кадр занимает 566528 отсчётов, что совпадает с расчётом
|
||||||
|
- ПРОЙДЕНО — 05. Масштабирование в целые отсчёты: пиковая амплитуда 1601 не превышает предела 16384
|
||||||
|
- ПРОЙДЕНО — 06. Программная петля восстанавливает изображение: программная петля вернула 4756 байт, идентичных исходным
|
||||||
|
- ПРОЙДЕНО — 07. Обнаружение искажённого кадра: одиночная битовая ошибка в данных кадра обнаружена контрольной суммой
|
||||||
|
- ПРОЙДЕНО — 08. Потеря фрагмента: потеря фрагмента не портит остальные, сборка требует всех
|
||||||
|
- ПРОЙДЕНО — 09. Достижимость частоты дискретизации: 2560000 Гц не ниже предела устройства 2083333 Гц
|
||||||
|
- ПРОЙДЕНО — 10. Настройки устройства в пределах: усиления, полоса 200000 Гц и несущая 435000000 Гц внутри допустимых пределов, сигнал занимает 27000 Гц
|
||||||
|
- ПРОЙДЕНО — 11. Потолок усиления в эфирном режиме: предел зависит от расстояния: -19.5 дБ на 5 см против 6.0 дБ на 200 см
|
||||||
|
|
||||||
|
Ограничения
|
||||||
|
- Передатчик и приёмник принадлежат одному устройству и работают от общего опорного
|
||||||
|
генератора, поэтому уход несущей между ними мал и не характеризует реальную линию.
|
||||||
|
- Повторная передача потерянных фрагментов не выполняется: потери фиксируются, но не восстанавливаются.
|
||||||
|
- Исправление стираний не подключалось.
|
||||||
|
- Разведение на два устройства и выход в эфир — предмет Lab043 и Lab044.
|
||||||
|
|
||||||
|
Созданные файлы
|
||||||
|
- data/processed/lab042/lab042_received.jpg
|
||||||
|
- data/processed/lab042/lab042_summary.csv
|
||||||
|
- data/processed/lab042/lab042_frames.csv
|
||||||
|
- data/processed/lab042/lab042_report.txt
|
||||||
|
- data/processed/lab042/lab042_constellation.png
|
||||||
|
- data/processed/lab042/lab042_spectrum.png
|
||||||
|
- data/processed/lab042/lab042_frame_correlation.png
|
||||||
|
- data/processed/lab042/lab042_fragment_delivery.png
|
||||||
BIN
data/processed/lab042/lab042_spectrum.png
Normal file
BIN
data/processed/lab042/lab042_spectrum.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
2
data/processed/lab042/lab042_summary.csv
Normal file
2
data/processed/lab042/lab042_summary.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
loopback_mode,path_description,antenna_distance_cm,source_image,image_width,image_height,jpeg_quality,jpeg_bytes,fragment_data_size,fragment_count,packet_bytes_max,packet_bytes_min,frame_bits_max,frame_bits_min,symbol_rate,samples_per_symbol,sample_rate_hz,carrier_hz,transmitted_samples,air_time_seconds,frames_received,frames_failed_crc,fragments_recovered,fragment_delivery_fraction,image_reassembled,image_identical,received_image_bytes,transfer_seconds,channel_bitrate_kbps,wall_clock_bitrate_kbps,mean_correlation_score,mean_carrier_offset_hz,mean_error_vector_magnitude,tx_gain_db,rx_gain_db,attenuator_db
|
||||||
|
cable,кабель через аттенюатор 30 дБ,0.0,data/raw/lab011_scene.jpg,320,180,30,4756,512,10,536,172,4384,1472,20000,128,2560000,435000000,5292544,2.0674,10,0,10,1.0,True,True,4756,16.64845450001303,18.40379220276676,2.285377300335609,0.9987897674116901,0.02900538637381942,0.193017509510432,-10.0,20.0,30.0
|
||||||
|
521
docs/lab043_pluto_to_rtlsdr_spec.md
Normal file
521
docs/lab043_pluto_to_rtlsdr_spec.md
Normal 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` также
|
||||||
|
не выполняются.
|
||||||
1136
experiments/lab042_pluto_image_loopback.py
Normal file
1136
experiments/lab042_pluto_image_loopback.py
Normal file
File diff suppressed because it is too large
Load Diff
1832
experiments/lab043_pluto_to_rtlsdr.py
Normal file
1832
experiments/lab043_pluto_to_rtlsdr.py
Normal file
File diff suppressed because it is too large
Load Diff
485
experiments/lab043_prbs11_hardware.py
Normal file
485
experiments/lab043_prbs11_hardware.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -31,8 +32,55 @@ SYMBOL_RATE = 20_000
|
|||||||
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0
|
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0
|
||||||
CFO_REFINEMENT_STEP_HZ = 1.0
|
CFO_REFINEMENT_STEP_HZ = 1.0
|
||||||
|
|
||||||
|
# Пороги разрешения частотной коррекции. Значения из Lab023. Смысл в том,
|
||||||
|
# что при малом истинном уходе оценка по короткому маркеру состоит почти
|
||||||
|
# целиком из шума, и коррекция таким значением портит длинный кадр сильнее,
|
||||||
|
# чем отсутствие коррекции вообще.
|
||||||
|
CFO_DEAD_ZONE_HZ = 15.0
|
||||||
|
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,
|
||||||
@@ -129,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,
|
||||||
@@ -800,3 +1338,35 @@ def radio_frame_bit_count(protocol_packet_bytes: int) -> int:
|
|||||||
+ RADIO_HEADER_BIT_COUNT
|
+ RADIO_HEADER_BIT_COUNT
|
||||||
+ protocol_packet_bytes * 8
|
+ protocol_packet_bytes * 8
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Перенесено дословно из Lab023 (experiments/lab023_guarded_cfo_correction.py).
|
||||||
|
def should_apply_cfo_correction(
|
||||||
|
estimated_frequency_hz: float,
|
||||||
|
phase_consistency: float,
|
||||||
|
coherence_gain: float,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Разрешить CFO-коррекцию только при наличии
|
||||||
|
достаточных оснований.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
1. Оценка находится вне мёртвой зоны.
|
||||||
|
2. Межсимвольное вращение достаточно согласованно.
|
||||||
|
3. Компенсация CFO действительно повышает
|
||||||
|
когерентность известного маркера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MINIMUM_COHERENCE_GAIN = 0.02
|
||||||
|
|
||||||
|
return (
|
||||||
|
abs(
|
||||||
|
estimated_frequency_hz
|
||||||
|
)
|
||||||
|
>= CFO_DEAD_ZONE_HZ
|
||||||
|
and phase_consistency
|
||||||
|
>= MINIMUM_PHASE_CONSISTENCY
|
||||||
|
and coherence_gain
|
||||||
|
>= MINIMUM_COHERENCE_GAIN
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
125
tests/test_lab042_reporting.py
Normal file
125
tests/test_lab042_reporting.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Проверки отчётности Lab042: неуспешный путь не должен маскироваться.
|
||||||
|
|
||||||
|
Три дефекта, найденные при разборе кабельного прогона, закрыты правками в
|
||||||
|
experiments/lab042_pluto_image_loopback.py. Эти тесты фиксируют их, чтобы
|
||||||
|
провал лаборатории нельзя было принять за успех:
|
||||||
|
|
||||||
|
1. EVM не занижается искусственно при слабом сигнале и не превращается в
|
||||||
|
ноль, когда её нельзя вычислить;
|
||||||
|
2. отсутствующее принятое изображение не объявляется созданным файлом;
|
||||||
|
3. невыполнение критерия лаборатории даёт ненулевой код завершения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from experiments.lab042_pluto_image_loopback import (
|
||||||
|
RECEIVED_IMAGE_PATH,
|
||||||
|
FunctionalTestResult,
|
||||||
|
_error_vector_magnitude,
|
||||||
|
created_files,
|
||||||
|
transfer_meets_criteria,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference(length: int = 512) -> np.ndarray:
|
||||||
|
bits = np.random.default_rng(length).integers(0, 2, size=length).astype(np.float64)
|
||||||
|
return (2.0 * bits - 1.0).astype(np.complex128)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ EVM
|
||||||
|
|
||||||
|
|
||||||
|
def test_evm_is_near_zero_for_a_clean_signal() -> None:
|
||||||
|
reference = _reference()
|
||||||
|
assert _error_vector_magnitude(reference.copy(), reference) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_evm_is_invariant_to_overall_gain() -> None:
|
||||||
|
"""Одно и то же отношение сигнал/шум при разной амплитуде — одна EVM."""
|
||||||
|
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
reference = _reference()
|
||||||
|
noise = 0.1 * (rng.standard_normal(len(reference)) + 1j * rng.standard_normal(len(reference)))
|
||||||
|
|
||||||
|
strong = _error_vector_magnitude(reference + noise, reference)
|
||||||
|
weak = _error_vector_magnitude(0.001 * (reference + noise), reference)
|
||||||
|
|
||||||
|
assert math.isclose(strong, weak, rel_tol=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_evm_grows_when_the_signal_is_weak_and_noisy() -> None:
|
||||||
|
"""Регрессия: прежняя нормировка на единичный эталон занижала EVM у
|
||||||
|
|
||||||
|
слабого сигнала почти до нуля. Теперь слабый шумный сигнал обязан дать
|
||||||
|
заметную EVM, а не подделку под идеальный приём.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rng = np.random.default_rng(2)
|
||||||
|
reference = _reference()
|
||||||
|
noise = 0.5 * (rng.standard_normal(len(reference)) + 1j * rng.standard_normal(len(reference)))
|
||||||
|
weak_noisy = 0.001 * (reference + noise)
|
||||||
|
|
||||||
|
assert _error_vector_magnitude(weak_noisy, reference) > 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_evm_is_not_a_number_when_it_cannot_be_computed() -> None:
|
||||||
|
reference = _reference()
|
||||||
|
assert math.isnan(_error_vector_magnitude(reference[:10], reference))
|
||||||
|
assert math.isnan(_error_vector_magnitude(np.zeros(0, dtype=complex), np.zeros(0, dtype=complex)))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ состав файлов
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(**overrides) -> SimpleNamespace:
|
||||||
|
base = dict(
|
||||||
|
image_reassembled=True,
|
||||||
|
image_identical=True,
|
||||||
|
fragments_recovered=10,
|
||||||
|
fragment_count=10,
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_received_image_listed_only_when_reassembled() -> None:
|
||||||
|
with_image = created_files(_metrics(image_reassembled=True))
|
||||||
|
without_image = created_files(_metrics(image_reassembled=False, image_identical=False))
|
||||||
|
|
||||||
|
assert RECEIVED_IMAGE_PATH in with_image
|
||||||
|
assert RECEIVED_IMAGE_PATH not in without_image
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ критерий и код возврата
|
||||||
|
|
||||||
|
|
||||||
|
def _all_passed() -> tuple[FunctionalTestResult, ...]:
|
||||||
|
return tuple(FunctionalTestResult(f"{index:02d}", True, "ок") for index in range(11))
|
||||||
|
|
||||||
|
|
||||||
|
def test_criteria_met_on_a_clean_transfer() -> None:
|
||||||
|
assert transfer_meets_criteria(_metrics(), _all_passed()) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_criteria_failed_when_image_differs() -> None:
|
||||||
|
assert transfer_meets_criteria(_metrics(image_identical=False), _all_passed()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_criteria_failed_when_a_fragment_is_missing() -> None:
|
||||||
|
metrics = _metrics(image_identical=False, fragments_recovered=9)
|
||||||
|
assert transfer_meets_criteria(metrics, _all_passed()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_criteria_failed_when_a_functional_check_failed() -> None:
|
||||||
|
tests = _all_passed()[:-1] + (FunctionalTestResult("bad", False, "провал"),)
|
||||||
|
assert transfer_meets_criteria(_metrics(), tests) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_criteria_failed_without_any_tests() -> None:
|
||||||
|
assert transfer_meets_criteria(_metrics(), ()) is False
|
||||||
891
tests/test_lab043_calibration.py
Normal file
891
tests/test_lab043_calibration.py
Normal 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
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
Проверяет за одну-две секунды то, что ломается чаще всего:
|
Проверяет за одну-две секунды то, что ломается чаще всего:
|
||||||
|
|
||||||
1. Синтаксис всех файлов в protocol, tests и tools.
|
1. Синтаксис всех файлов в protocol, tests, experiments и tools.
|
||||||
2. Импорт каждого модуля protocol.
|
2. Импорт каждого модуля protocol.
|
||||||
3. Функциональные проверки тех лаб, которым не нужны данные эксперимента.
|
3. Быстрые проверки ядра из tests под pytest.
|
||||||
|
4. Функциональные проверки тех лаб, которым не нужны данные эксперимента.
|
||||||
|
|
||||||
Полный прогон лабы шлюз не заменяет.
|
Полный прогон лабы шлюз не заменяет.
|
||||||
Он отвечает на один вопрос: не сломана ли кодовая база прямо сейчас.
|
Он отвечает на один вопрос: не сломана ли кодовая база прямо сейчас.
|
||||||
@@ -19,7 +20,10 @@ from __future__ import annotations
|
|||||||
import ast
|
import ast
|
||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -62,6 +66,41 @@ def check_protocol_imports() -> list[str]:
|
|||||||
return failures
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def check_core_tests() -> tuple[list[str], int]:
|
||||||
|
"""
|
||||||
|
Прогнать быстрые проверки ядра из tests под pytest.
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
failures:
|
||||||
|
Список сообщений об ошибках.
|
||||||
|
|
||||||
|
passed:
|
||||||
|
Количество пройденных проверок, ноль если разобрать вывод не удалось.
|
||||||
|
"""
|
||||||
|
|
||||||
|
environment = dict(os.environ, PYTHONIOENCODING="utf-8")
|
||||||
|
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pytest", "tests", "-q"],
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = (completed.stdout + completed.stderr).strip()
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return [f"pytest: тесты ядра не пройдены:\n{output}"], 0
|
||||||
|
|
||||||
|
match = re.search(r"(\d+) passed", output)
|
||||||
|
passed = int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
return [], passed
|
||||||
|
|
||||||
|
|
||||||
def check_functional_tests() -> tuple[list[str], int, int]:
|
def check_functional_tests() -> tuple[list[str], int, int]:
|
||||||
"""
|
"""
|
||||||
Выполнить функциональные проверки лаб, не требующие данных эксперимента.
|
Выполнить функциональные проверки лаб, не требующие данных эксперимента.
|
||||||
@@ -128,8 +167,9 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
import_failures = check_protocol_imports()
|
import_failures = check_protocol_imports()
|
||||||
|
core_failures, passed = check_core_tests()
|
||||||
test_failures, executed, skipped = check_functional_tests()
|
test_failures, executed, skipped = check_functional_tests()
|
||||||
failures = import_failures + test_failures
|
failures = import_failures + core_failures + test_failures
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
print("Шлюз не пройден")
|
print("Шлюз не пройден")
|
||||||
@@ -137,7 +177,11 @@ def main() -> int:
|
|||||||
print(f" {failure}")
|
print(f" {failure}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"Шлюз пройден: синтаксис в порядке, protocol импортируется, функциональных проверок выполнено {executed}, пропущено {skipped}")
|
print(
|
||||||
|
"Шлюз пройден: синтаксис в порядке, protocol импортируется, "
|
||||||
|
f"тестов ядра пройдено {passed}, "
|
||||||
|
f"функциональных проверок лаб выполнено {executed}, пропущено {skipped}"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user