Split experiments from tests

The tests/ directory held 50 laboratory programs and no tests. They model
channels, run hundreds of repetitions and write CSV, PNG and reports;
calling that a test suite blocked introducing a real one, because any
pytest run would have collected the labs and re-executed every
experiment.

- move all 50 lab programs to experiments/ with git mv, preserving history
- rewrite the 38 cross-imports between labs from tests.labNNN to
  experiments.labNNN
- leave tests/ empty for actual fast checks of protocol/
- point quick_gate and the hook at the new layout and add experiments/ to
  the syntax sweep
- update the paths quoted in the Lab042 specification and the verifier
  agent definition

This also defuses the import-time work finding without touching 41 files:
the labs still create directories and write files on import, but nothing
imports them now except the gate, which does so deliberately.

Gate passes: syntax clean, protocol imports, 15 lab modules import, 2
functional suites run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
LittleSam129
2026-08-10 14:34:58 +03:00
parent c9569164e0
commit c486039053
55 changed files with 63 additions and 62 deletions

View File

@@ -0,0 +1,528 @@
"""
Lab013. Первая BPSK-передача цифрового пакета в Python.
Программа:
1. Формирует пакет SDR Rover Link с CRC-32.
2. Преобразует байты пакета в отдельные биты.
3. Преобразует биты в BPSK-символы.
4. Представляет символы как комплексные IQ-сэмплы.
5. Добавляет комплексный белый гауссов шум.
6. Демодулирует BPSK.
7. Восстанавливает байты пакета.
8. Проверяет пакет и CRC.
9. Сравнивает результат при разных уровнях SNR.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from protocol.packet import (
CRCError,
MESSAGE_TYPE_TEXT,
PacketError,
build_packet,
parse_packet,
)
# ============================================================
# Настройки эксперимента
# ============================================================
MESSAGE = "ПРИВЕТ SDR"
SEQUENCE_NUMBER = 13
# Проверим несколько уровней отношения сигнал/шум.
SNR_VALUES_DB = [
12.0,
6.0,
2.0,
0.0,
]
# Фиксированное значение обеспечивает повторяемость.
RANDOM_SEED = 2026
OUTPUT_DIRECTORY = Path(
"data/processed/lab013"
)
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
# ============================================================
# Преобразование байтов в биты
# ============================================================
def bytes_to_bits(
data: bytes,
) -> np.ndarray:
"""
Преобразовать последовательность bytes
в массив отдельных битов 0 и 1.
Каждый исходный байт превращается в восемь битов.
"""
if not isinstance(data, bytes):
raise TypeError(
"data должен иметь тип bytes"
)
byte_array = np.frombuffer(
data,
dtype=np.uint8,
)
bits = np.unpackbits(
byte_array
)
return bits
# ============================================================
# Преобразование битов обратно в байты
# ============================================================
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(
"Массив должен содержать только 0 и 1"
)
packed_bytes = np.packbits(
bits
)
return packed_bytes.tobytes()
# ============================================================
# BPSK-модулятор
# ============================================================
def bpsk_modulate(
bits: np.ndarray,
) -> np.ndarray:
"""
Преобразовать биты в комплексные BPSK-символы.
Используем отображение:
0 → -1 + 0j
1 → +1 + 0j
"""
bits = np.asarray(
bits,
dtype=np.uint8,
)
real_symbols = (
2.0 * bits.astype(np.float64)
- 1.0
)
iq_symbols = real_symbols.astype(
np.complex128
)
return iq_symbols
# ============================================================
# Модель радиошума
# ============================================================
def add_awgn(
iq_samples: np.ndarray,
snr_db: float,
random_generator: np.random.Generator,
) -> np.ndarray:
"""
Добавить комплексный белый гауссов шум AWGN.
AWGN:
Additive White Gaussian Noise —
аддитивный белый гауссов шум.
snr_db:
Отношение средней мощности сигнала
к средней мощности шума в децибелах.
"""
signal_power = np.mean(
np.abs(iq_samples) ** 2
)
snr_linear = 10.0 ** (
snr_db / 10.0
)
noise_power = (
signal_power / snr_linear
)
# Комплексный шум имеет две составляющие:
# действительную I и мнимую Q.
#
# Поэтому мощность делится между ними пополам.
noise_sigma = np.sqrt(
noise_power / 2.0
)
noise = noise_sigma * (
random_generator.standard_normal(
len(iq_samples)
)
+ 1j
* random_generator.standard_normal(
len(iq_samples)
)
)
return iq_samples + noise
# ============================================================
# BPSK-демодулятор
# ============================================================
def bpsk_demodulate(
received_iq: np.ndarray,
) -> np.ndarray:
"""
Преобразовать принятые IQ-сэмплы в биты.
Правило решения:
I < 0 → бит 0
I >= 0 → бит 1
"""
received_bits = (
received_iq.real >= 0.0
).astype(np.uint8)
return received_bits
# ============================================================
# Формирование исходного пакета
# ============================================================
payload = MESSAGE.encode(
"utf-8"
)
original_packet = build_packet(
payload=payload,
message_type=MESSAGE_TYPE_TEXT,
sequence_number=SEQUENCE_NUMBER,
)
transmitted_bits = bytes_to_bits(
original_packet
)
transmitted_iq = bpsk_modulate(
transmitted_bits
)
print(
"=== Lab013. BPSK в Python ==="
)
print("\nИсходное сообщение:")
print(MESSAGE)
print("\nРазмер пакета:")
print(
len(original_packet),
"байт",
)
print("\nКоличество передаваемых битов:")
print(
len(transmitted_bits)
)
print("\nПервые 32 бита:")
print(
" ".join(
str(bit)
for bit in transmitted_bits[:32]
)
)
print("\nПервые 16 BPSK-символов:")
print(
transmitted_iq[:16]
)
# ============================================================
# Передача при разных уровнях SNR
# ============================================================
experiment_results = []
constellation_samples = {}
for experiment_index, snr_db in enumerate(
SNR_VALUES_DB
):
random_generator = np.random.default_rng(
RANDOM_SEED + experiment_index
)
received_iq = add_awgn(
iq_samples=transmitted_iq,
snr_db=snr_db,
random_generator=random_generator,
)
received_bits = bpsk_demodulate(
received_iq
)
bit_error_count = int(
np.count_nonzero(
transmitted_bits != received_bits
)
)
bit_error_rate = (
bit_error_count
/ len(transmitted_bits)
)
received_packet = bits_to_bytes(
received_bits
)
packet_status = "CRC OK"
restored_message = None
try:
parsed_packet = parse_packet(
received_packet
)
restored_message = (
parsed_packet.payload.decode(
"utf-8"
)
)
except CRCError:
packet_status = "CRC ERROR"
except PacketError:
packet_status = "PACKET ERROR"
except UnicodeDecodeError:
packet_status = "UTF-8 ERROR"
experiment_results.append(
{
"snr_db": snr_db,
"bit_errors": bit_error_count,
"ber": bit_error_rate,
"packet_status": packet_status,
"restored_message": restored_message,
}
)
# Для графика достаточно первых 300 символов.
constellation_samples[snr_db] = (
received_iq[:300]
)
# ============================================================
# Вывод результатов
# ============================================================
print("\nРезультаты передачи:")
print(
f"{'SNR':>8}"
f"{'Ошибки битов':>16}"
f"{'BER':>14}"
f"{'Пакет':>18}"
)
print("-" * 56)
for result in experiment_results:
print(
f"{result['snr_db']:>6.1f} дБ"
f"{result['bit_errors']:>16}"
f"{result['ber']:>14.6f}"
f"{result['packet_status']:>18}"
)
print("\nВосстановленные сообщения:")
for result in experiment_results:
print(
f"SNR {result['snr_db']:>4.1f} дБ: "
f"{result['restored_message']}"
)
# ============================================================
# График созвездия
# ============================================================
figure, axes = plt.subplots(
2,
2,
figsize=(10, 8),
)
axes = axes.ravel()
for axis, snr_db in zip(
axes,
SNR_VALUES_DB,
):
received_iq = constellation_samples[
snr_db
]
axis.scatter(
received_iq.real,
received_iq.imag,
s=12,
alpha=0.6,
)
axis.axvline(
0.0,
linewidth=1,
)
axis.set_title(
f"BPSK, SNR = {snr_db:.1f} дБ"
)
axis.set_xlabel(
"I — синфазная компонента"
)
axis.set_ylabel(
"Q — квадратурная компонента"
)
axis.set_xlim(
-2.5,
2.5,
)
axis.set_ylim(
-1.8,
1.8,
)
axis.grid(
True
)
figure.tight_layout()
constellation_path = (
OUTPUT_DIRECTORY
/ "lab013_bpsk_constellation.png"
)
figure.savefig(
constellation_path,
dpi=150,
)
plt.close(
figure
)
# ============================================================
# Автоматические проверки
# ============================================================
# Без добавления шума преобразование должно быть обратимым.
ideal_received_bits = bpsk_demodulate(
transmitted_iq
)
ideal_received_packet = bits_to_bytes(
ideal_received_bits
)
assert ideal_received_packet == original_packet
ideal_parsed_packet = parse_packet(
ideal_received_packet
)
assert (
ideal_parsed_packet.payload.decode(
"utf-8"
)
== MESSAGE
)
assert len(experiment_results) == len(
SNR_VALUES_DB
)
assert constellation_path.exists()
print("\nГрафик созвездия:")
print(constellation_path)
print(
"\nПроверка пройдена: "
"пакет преобразован в BPSK IQ-сэмплы "
"и демодулирован обратно."
)