Files
SDR-Rover/protocol/bpsk_radio.py
LittleSam129 fce14f038e Lab042: transmit an image over the air, add the air loopback mode
First transmission through a real radio path in the project. A 4756-byte
JPEG went from transmitter to receiver over two antennas 60 cm apart at
225 MHz and reassembled byte-identical, reproduced across three runs.

The cable loopback still needs an SMA male-to-male adapter that has not
arrived, so this air run is a track verification, not the controlled
measurement: room levels are not held the way a cable holds them. Its
purpose was to exercise the device wrapper the models cannot cover.

Four defects the software model passed but the hardware exposed, each
reproduced and fixed:

- a 566 528-sample frame did not fit the 262 144 receive buffer, and the
  sample stream is not continuous across separate reads, so a frame on the
  seam was destroyed. Reception now uses one buffer sized to the frame
- carrier correction was always applied, but with a shared reference the
  true offset is near zero and the 80-symbol marker estimate is noise;
  correcting by it rotated phase along the whole frame, 65% bit errors
  measured. Ported should_apply_cfo_correction from Lab023
- receiver gain of 20 dB left the signal at 0.0003 of full scale. Levels
  set by measurement: RX 50 dB, TX -10 dB give zero bit errors with margin
- the error-vector metric returned zero on a failed receive, reporting a
  perfect signal where there was none, the same class of defect as the
  written-in figures of Lab041. Replaced with an explicit not-computed value

The air TX gain ceiling is now derived from the link budget by antenna
distance rather than a constant. Three loopback modes: software, cable,
air. PROJECT_LOG entry 019 records the run and the defects.

Software and cable results stay reproducible; 125 tests pass, gate passes.
2026-08-17 11:50:04 +03:00

842 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверенные примитивы BPSK-радиотракта, вынесенные из лабораторных.
Модуль не проводит экспериментов и ничего не пишет на диск: его можно
безопасно импортировать. Это и было причиной его появления — Lab018,
Lab019 и Lab023 при импорте создают каталоги и сохраняют файлы, поэтому
переиспользовать их функции напрямую невозможно.
Тела функций перенесены дословно из соответствующих лабораторных, чтобы
результаты остались сопоставимыми. Источник указан над каждой функцией.
Совпадение с оригиналами проверяется в tests/test_bpsk_radio.py.
Универсальные функции формирования и разбора радиокадра добавлены здесь
заново: в лабораторных они были жёстко привязаны к демонстрационному
текстовому сообщению и не принимали произвольные данные.
"""
from __future__ import annotations
import struct
import numpy as np
# Параметры радиокадра. Значения совпадают с Lab018, Lab019 и Lab023.
RADIO_SYNC_WORD = 0xD391
PREAMBLE_BIT_COUNT = 64
RADIO_HEADER_BIT_COUNT = 32
MARKER_BIT_COUNT = PREAMBLE_BIT_COUNT + 16
# Параметры оценки частотного рассогласования. Значения из Lab023.
SYMBOL_RATE = 20_000
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0
CFO_REFINEMENT_STEP_HZ = 1.0
# Пороги разрешения частотной коррекции. Значения из Lab023. Смысл в том,
# что при малом истинном уходе оценка по короткому маркеру состоит почти
# целиком из шума, и коррекция таким значением портит длинный кадр сильнее,
# чем отсутствие коррекции вообще.
CFO_DEAD_ZONE_HZ = 15.0
MINIMUM_PHASE_CONSISTENCY = 0.55
MAXIMUM_PROTOCOL_PACKET_BYTES = 4096
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def bytes_to_bits(
data: bytes,
) -> np.ndarray:
"""
Преобразовать bytes в одномерный массив битов.
"""
if not isinstance(data, bytes):
raise TypeError(
"data должен иметь тип bytes"
)
return np.unpackbits(
np.frombuffer(
data,
dtype=np.uint8,
)
)
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def bits_to_bytes(
bits: np.ndarray,
) -> bytes:
"""
Упаковать массив битов обратно в bytes.
"""
bits = np.asarray(
bits,
dtype=np.uint8,
)
if bits.ndim != 1:
raise ValueError(
"bits должен быть одномерным массивом"
)
if len(bits) % 8 != 0:
raise ValueError(
"Количество битов должно быть кратно восьми"
)
if not np.all(
(bits == 0) | (bits == 1)
):
raise ValueError(
"bits должен содержать только 0 и 1"
)
return np.packbits(
bits
).tobytes()
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def bpsk_modulate(
bits: np.ndarray,
) -> np.ndarray:
"""
Преобразовать биты в BPSK-символы.
0 → -1
1 → +1
"""
bits = np.asarray(
bits,
dtype=np.uint8,
)
symbols = (
2.0
* bits.astype(np.float64)
- 1.0
)
return symbols.astype(
np.complex128
)
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def bpsk_demodulate(
symbols: np.ndarray,
) -> np.ndarray:
"""
Демодулировать BPSK по знаку компоненты I.
"""
return (
symbols.real >= 0.0
).astype(np.uint8)
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def estimate_carrier_parameters(
received_symbols: np.ndarray,
marker_symbols: np.ndarray,
) -> dict:
"""
Оценить фазу и частотное рассогласование по маркеру.
Алгоритм:
1. Удалить известные BPSK-знаки маркера.
2. Получить грубую оценку CFO по соседним символам.
3. Выполнить уточняющий частотный поиск.
4. Оценить начальную фазу после компенсации CFO.
5. Рассчитать достоверность оценки.
"""
marker_length = len(
marker_symbols
)
received_marker = received_symbols[
:marker_length
]
if len(received_marker) != marker_length:
raise ValueError(
"Недостаточно символов маркера"
)
# Известные BPSK-знаки равны -1 или +1.
# Умножение удаляет переданную манипуляцию,
# оставляя фазу канала и шум.
despread_marker = (
received_marker
* marker_symbols
)
marker_indexes = np.arange(
marker_length,
dtype=np.float64,
)
marker_magnitude_sum = float(
np.sum(
np.abs(
despread_marker
)
)
)
# ========================================================
# Постоянная фаза без CFO-компенсации
# ========================================================
constant_coherent_sum = np.sum(
despread_marker
)
constant_phase = float(
np.angle(
constant_coherent_sum
)
)
constant_coherence = float(
np.abs(
constant_coherent_sum
)
/ (
marker_magnitude_sum
+ 1e-12
)
)
# ========================================================
# Грубая оценка CFO
# ========================================================
adjacent_products = (
despread_marker[1:]
* np.conj(
despread_marker[:-1]
)
)
adjacent_sum = np.sum(
adjacent_products
)
coarse_phase_increment = float(
np.angle(
adjacent_sum
)
)
coarse_frequency_hz = (
coarse_phase_increment
* SYMBOL_RATE
/ (2.0 * np.pi)
)
phase_consistency = float(
np.abs(
adjacent_sum
)
/ (
np.sum(
np.abs(
adjacent_products
)
)
+ 1e-12
)
)
# ========================================================
# Уточняющий поиск CFO
# ========================================================
frequency_candidates_hz = np.arange(
(
coarse_frequency_hz
- CFO_REFINEMENT_HALF_WIDTH_HZ
),
(
coarse_frequency_hz
+ CFO_REFINEMENT_HALF_WIDTH_HZ
+ CFO_REFINEMENT_STEP_HZ / 2.0
),
CFO_REFINEMENT_STEP_HZ,
dtype=np.float64,
)
phase_increment_candidates = (
2.0
* np.pi
* frequency_candidates_hz
/ SYMBOL_RATE
)
candidate_compensation = np.exp(
-1j
* phase_increment_candidates[
:, np.newaxis
]
* marker_indexes[
np.newaxis, :
]
)
coherent_sums = np.sum(
despread_marker[
np.newaxis, :
]
* candidate_compensation,
axis=1,
)
best_candidate_index = int(
np.argmax(
np.abs(
coherent_sums
)
)
)
estimated_frequency_hz = float(
frequency_candidates_hz[
best_candidate_index
]
)
phase_increment = float(
phase_increment_candidates[
best_candidate_index
]
)
best_coherent_sum = (
coherent_sums[
best_candidate_index
]
)
initial_phase_after_cfo = float(
np.angle(
best_coherent_sum
)
)
cfo_coherence = float(
np.abs(
best_coherent_sum
)
/ (
marker_magnitude_sum
+ 1e-12
)
)
coherence_gain = (
cfo_coherence
- constant_coherence
)
return {
"constant_phase": constant_phase,
"constant_coherence": constant_coherence,
"phase_increment": phase_increment,
"estimated_frequency_hz": (
estimated_frequency_hz
),
"initial_phase_after_cfo": (
initial_phase_after_cfo
),
"phase_consistency": phase_consistency,
"cfo_coherence": cfo_coherence,
"coherence_gain": coherence_gain,
}
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def correct_phase_and_frequency(
received_symbols: np.ndarray,
initial_phase_radians: float,
phase_increment: float,
) -> np.ndarray:
"""
Компенсировать постоянную фазу и CFO.
"""
symbol_indexes = np.arange(
len(received_symbols),
dtype=np.float64,
)
phase_model = (
initial_phase_radians
+ phase_increment
* symbol_indexes
)
return (
received_symbols
* np.exp(
-1j * phase_model
)
)
# Перенесено дословно из Lab019 (tests/lab019_bpsk_receiver.py).
def build_frame_marker(
) -> tuple[np.ndarray, np.ndarray]:
"""
Сформировать:
PREAMBLE + RADIO SYNC
Возвращает биты и BPSK-символы маркера.
"""
preamble_bits = np.tile(
np.array(
[1, 0],
dtype=np.uint8,
),
PREAMBLE_BIT_COUNT // 2,
)
sync_bytes = struct.pack(
">H",
RADIO_SYNC_WORD,
)
sync_bits = bytes_to_bits(
sync_bytes
)
marker_bits = np.concatenate(
[
preamble_bits,
sync_bits,
]
)
marker_symbols = bpsk_modulate(
marker_bits
)
return marker_bits, marker_symbols
# Перенесено дословно из Lab019 (tests/lab019_bpsk_receiver.py).
def find_radio_frame(
matched_iq: np.ndarray,
marker_symbols: np.ndarray,
samples_per_symbol: int,
) -> dict:
"""
Найти фазу дискретизации и начало радиокадра.
Для каждой возможной фазы:
0, 1, 2, ... SPS - 1
берём по одному сэмплу на символ и вычисляем
нормированную корреляцию с известным маркером.
Использование комплексной корреляции позволяет
одновременно оценить постоянный фазовый поворот.
"""
marker_energy = float(
np.sum(
np.abs(marker_symbols) ** 2
)
)
best_result = None
for sample_phase in range(
samples_per_symbol
):
symbol_samples = matched_iq[
sample_phase::samples_per_symbol
]
if len(symbol_samples) < len(
marker_symbols
):
continue
correlation = np.correlate(
symbol_samples,
marker_symbols,
mode="valid",
)
window_energy = np.convolve(
np.abs(symbol_samples) ** 2,
np.ones(
len(marker_symbols)
),
mode="valid",
)
denominator = (
np.sqrt(
window_energy
* marker_energy
)
+ 1e-12
)
normalized_correlation = (
np.abs(correlation)
/ denominator
)
start_symbol_index = int(
np.argmax(
normalized_correlation
)
)
correlation_score = float(
normalized_correlation[
start_symbol_index
]
)
complex_correlation = correlation[
start_symbol_index
]
if (
best_result is None
or correlation_score
> best_result["score"]
):
best_result = {
"score": correlation_score,
"sample_phase": sample_phase,
"start_symbol_index": (
start_symbol_index
),
"symbol_samples": (
symbol_samples
),
"correlation": correlation,
"normalized_correlation": (
normalized_correlation
),
"complex_correlation": (
complex_correlation
),
}
if best_result is None:
raise RuntimeError(
"Не удалось выполнить поиск радиокадра"
)
return best_result
# Перенесено дословно из Lab018 (tests/lab018_bpsk_radio_frame.py).
def root_raised_cosine_taps(
rolloff: float,
samples_per_symbol: int,
span_symbols: int,
) -> np.ndarray:
"""
Рассчитать коэффициенты Root Raised Cosine-фильтра.
Параметры
----------
rolloff:
Коэффициент скругления beta.
samples_per_symbol:
Количество сэмплов на символ.
span_symbols:
Полная длина фильтра в символах.
Возвращает
----------
Одномерный массив коэффициентов фильтра.
"""
if not 0.0 < rolloff <= 1.0:
raise ValueError(
"rolloff должен находиться в диапазоне 0...1"
)
if samples_per_symbol <= 0:
raise ValueError(
"samples_per_symbol должен быть положительным"
)
if span_symbols <= 0:
raise ValueError(
"span_symbols должен быть положительным"
)
if span_symbols % 2 != 0:
raise ValueError(
"span_symbols должен быть чётным"
)
half_sample_count = (
span_symbols
* samples_per_symbol
// 2
)
sample_indexes = np.arange(
-half_sample_count,
half_sample_count + 1,
dtype=np.float64,
)
# Время нормировано к длительности одного символа.
time_values = (
sample_indexes
/ samples_per_symbol
)
taps = np.zeros_like(
time_values
)
beta = rolloff
for index, time_value in enumerate(
time_values
):
# Особая точка t = 0.
if np.isclose(
time_value,
0.0,
):
taps[index] = (
1.0
- beta
+ (
4.0
* beta
/ np.pi
)
)
continue
# Особые точки t = ±1/(4 beta).
if np.isclose(
abs(time_value),
1.0 / (4.0 * beta),
):
taps[index] = (
beta
/ np.sqrt(2.0)
* (
(
1.0
+ 2.0 / np.pi
)
* np.sin(
np.pi
/ (4.0 * beta)
)
+ (
1.0
- 2.0 / np.pi
)
* np.cos(
np.pi
/ (4.0 * beta)
)
)
)
continue
numerator = (
np.sin(
np.pi
* time_value
* (1.0 - beta)
)
+ (
4.0
* beta
* time_value
* np.cos(
np.pi
* time_value
* (1.0 + beta)
)
)
)
denominator = (
np.pi
* time_value
* (
1.0
- (
4.0
* beta
* time_value
) ** 2
)
)
taps[index] = (
numerator
/ denominator
)
# Нормируем энергию фильтра.
taps /= np.sqrt(
np.sum(
taps ** 2
)
)
return taps
# Ниже — функции, добавленные при выделении модуля. В Lab018, Lab019 и
# Lab023 формирование кадра не принимало аргументов и собирало жёстко
# заданное текстовое сообщение, а разбор возвращал строку состояния.
# Структура кадра сохранена без изменений:
#
# [преамбула 64 бита] [синхрослово 16 | длина 16] [пакет протокола]
def build_radio_frame(
protocol_packet: bytes,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Сформировать радиокадр вокруг готового пакета протокола.
Возвращает биты кадра, символы кадра и известные символы маркера,
состоящего из преамбулы и синхрослова.
"""
if not isinstance(protocol_packet, (bytes, bytearray)):
raise TypeError("protocol_packet должен иметь тип bytes")
protocol_packet = bytes(protocol_packet)
if not 1 <= len(protocol_packet) <= MAXIMUM_PROTOCOL_PACKET_BYTES:
raise ValueError(
"Длина пакета протокола должна быть от 1 до "
f"{MAXIMUM_PROTOCOL_PACKET_BYTES} байт"
)
radio_header = struct.pack(">HH", RADIO_SYNC_WORD, len(protocol_packet))
preamble_bits = np.tile(
np.array([1, 0], dtype=np.uint8),
PREAMBLE_BIT_COUNT // 2,
)
frame_bits = np.concatenate(
[
preamble_bits,
bytes_to_bits(radio_header),
bytes_to_bits(protocol_packet),
]
)
frame_symbols = bpsk_modulate(frame_bits)
marker_symbols = frame_symbols[:MARKER_BIT_COUNT]
return frame_bits, frame_symbols, marker_symbols
def parse_radio_frame(received_bits: np.ndarray) -> bytes | None:
"""Извлечь пакет протокола из битов принятого кадра.
Возвращает пакет либо None, если заголовок кадра не разобран.
Проверка контрольной суммы самого пакета в задачу не входит и
выполняется отдельно через protocol.packet.parse_packet.
"""
received_bits = np.asarray(received_bits, dtype=np.uint8)
header_start = PREAMBLE_BIT_COUNT
header_end = header_start + RADIO_HEADER_BIT_COUNT
if len(received_bits) < header_end:
return None
try:
radio_header = bits_to_bytes(received_bits[header_start:header_end])
received_sync, packet_length = struct.unpack(">HH", radio_header)
except (ValueError, struct.error):
return None
if received_sync != RADIO_SYNC_WORD:
return None
if not 1 <= packet_length <= MAXIMUM_PROTOCOL_PACKET_BYTES:
return None
packet_start = header_end
packet_end = packet_start + packet_length * 8
if len(received_bits) < packet_end:
return None
try:
return bits_to_bytes(received_bits[packet_start:packet_end])
except ValueError:
return None
def radio_frame_bit_count(protocol_packet_bytes: int) -> int:
"""Вернуть длину радиокадра в битах для пакета заданного размера."""
return (
PREAMBLE_BIT_COUNT
+ RADIO_HEADER_BIT_COUNT
+ 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
)