Compare commits

..

3 Commits

Author SHA1 Message Date
LittleSam129
2da80d0908 Add the first real test suite for protocol/
56 fast checks, 0.2 s, covering packet.py and the new bpsk_radio.py.

test_bpsk_radio exists mainly to prove the extraction changed nothing. It
loads the original function definitions out of Lab018, Lab019 and Lab023
in isolation, compiling only the wanted AST nodes so the labs' module
level file writing never runs, then compares outputs: bit conversion,
modulation, shaping filter, frame marker and the carrier estimator over
random phase and frequency offsets.

It also covers the frame functions written fresh for the module:
roundtrip at four payload sizes, size and type rejection, broken sync
word, truncation, carrying a real CRC32 packet, and a shaped-signal
search that recovers the packet through matched filtering.

test_packet covers roundtrip across all four message types and five
payload sizes, sync word, length arithmetic, sequence range, truncation,
oversized payload, and single-bit corruption at six positions, which is
the property CRC32 is there to provide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:38:12 +03:00
LittleSam129
c486039053 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>
2026-08-10 14:34:58 +03:00
LittleSam129
c9569164e0 Add protocol/bpsk_radio: reusable BPSK radio primitives
Lab042 cannot reuse the DSP from the earlier labs the way its
specification assumed. Three blockers turned up on inspection:

- importing lab018, lab019 or lab023 creates directories and writes .npy,
  .png and report files at module level, so importing them silently
  re-runs their experiments
- build_radio_frame() takes no arguments and encodes a fixed text message
- decode_radio_frame() returns a status string rather than the payload

So the verified primitives move into a module that is safe to import and
does no work of its own. Function bodies are carried over verbatim, and
the source lab is named above each one:

- from Lab023: bytes_to_bits, bits_to_bytes, bpsk_modulate,
  bpsk_demodulate, estimate_carrier_parameters, correct_phase_and_frequency
- from Lab019: build_frame_marker, find_radio_frame
- from Lab018: root_raised_cosine_taps

build_radio_frame and parse_radio_frame are written fresh here to carry
arbitrary payloads; the on-air structure is unchanged, still preamble,
sync word, length, packet.

Verified against the originals by loading their function definitions in
isolation and comparing outputs: 31 comparisons, no divergence, including
the CFO estimator over random phase and frequency offsets.

End-to-end check with no hardware: a 5828-byte JPEG through fragments,
packets, frames, shaping at 128 samples per symbol and matched filtering
recovers 12 of 12 fragments and reassembles byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:16:32 +03:00
58 changed files with 1174 additions and 62 deletions

View File

@@ -7,7 +7,7 @@ model: sonnet
Ты проверяешь чужую работу в проекте SDR Rover Link: учебно-исследовательский Ты проверяешь чужую работу в проекте SDR Rover Link: учебно-исследовательский
цифровой радиоканал для наземного ровера. Python, модули протокола в `protocol/`, цифровой радиоканал для наземного ровера. Python, модули протокола в `protocol/`,
лабораторные работы в `tests/labNNN_*.py`, журнал проекта в `PROJECT_LOG.md`. лабораторные работы в `experiments/labNNN_*.py`, журнал проекта в `PROJECT_LOG.md`.
Ты не автор кода. Твоя ценность именно в том, что ты не участвовал в его написании. Ты не автор кода. Твоя ценность именно в том, что ты не участвовал в его написании.
@@ -18,7 +18,7 @@ model: sonnet
Если пересказ расходится с первоисточником — это находка, и часто самая важная. Если пересказ расходится с первоисточником — это находка, и часто самая важная.
2. **Запусти сам.** Быстрая проверка: `python tools/quick_gate.py`. 2. **Запусти сам.** Быстрая проверка: `python tools/quick_gate.py`.
Полный прогон лабы: `python tests/labNNN_имя.py`. Не верь чужому «тесты проходят». Полный прогон лабы: `python experiments/labNNN_имя.py`. Не верь чужому «тесты проходят».
3. **Пересчитай ключевые числа независимым способом.** Это главный инструмент. 3. **Пересчитай ключевые числа независимым способом.** Это главный инструмент.
Симуляция BER проверяется аналитической формулой, счётчик отброшенных пакетов — Симуляция BER проверяется аналитической формулой, счётчик отброшенных пакетов —

View File

@@ -22,16 +22,16 @@
| Звено | Где взять | | Звено | Где взять |
|---|---| |---|---|
| Разбиение JPEG на фрагменты и сборка обратно | `protocol/image_fragments.py`, примеры в `tests/lab007_image_fragmentation.py` | | Разбиение JPEG на фрагменты и сборка обратно | `protocol/image_fragments.py`, примеры в `experiments/lab007_image_fragmentation.py` |
| Пакет с CRC32, кодирование и разбор | `protocol/packet.py` | | Пакет с CRC32, кодирование и разбор | `protocol/packet.py` |
| Подбор размера и качества JPEG | `tests/lab010_image_optimization.py`, готовые профили в `data/processed/lab010` | | Подбор размера и качества JPEG | `experiments/lab010_image_optimization.py`, готовые профили в `data/processed/lab010` |
| Формирование радиокадра с преамбулой и маркером | `build_radio_frame` в `tests/lab018_bpsk_radio_frame.py` | | Формирование радиокадра с преамбулой и маркером | `build_radio_frame` в `experiments/lab018_bpsk_radio_frame.py` |
| Фильтр приподнятого косинуса | `root_raised_cosine_taps` там же | | Фильтр приподнятого косинуса | `root_raised_cosine_taps` там же |
| Модуляция BPSK | `bpsk_modulate` там же | | Модуляция BPSK | `bpsk_modulate` там же |
| Поиск кадра в потоке отсчётов | `find_radio_frame` в `tests/lab019_bpsk_receiver.py` | | Поиск кадра в потоке отсчётов | `find_radio_frame` в `experiments/lab019_bpsk_receiver.py` |
| Оценка и коррекция ухода частоты | `estimate_carrier_parameters`, `correct_phase_and_frequency` там же | | Оценка и коррекция ухода частоты | `estimate_carrier_parameters`, `correct_phase_and_frequency` там же |
| Демодуляция и разбор кадра | `bpsk_demodulate`, `decode_radio_frame` там же | | Демодуляция и разбор кадра | `bpsk_demodulate`, `decode_radio_frame` там же |
| Работа с Pluto на приём | `tests/lab024a_pluto_rx_capture.py` | | Работа с Pluto на приём | `experiments/lab024a_pluto_rx_capture.py` |
**Единственное, чего нет в проекте — передача.** Это и есть основной объём работы. **Единственное, чего нет в проекте — передача.** Это и есть основной объём работы.
@@ -250,7 +250,7 @@
- Параметры тракта из раздела 5 не менять. - Параметры тракта из раздела 5 не менять.
- Все константы вынести в начало файла с указанием источника, как это сделано в Lab041. - Все константы вынести в начало файла с указанием источника, как это сделано в Lab041.
- Никаких вписанных руками результатов: каждое число в отчёте должно вычисляться из измерений текущего запуска. Это отдельно проверяется при приёмке. - Никаких вписанных руками результатов: каждое число в отчёте должно вычисляться из измерений текущего запуска. Это отдельно проверяется при приёмке.
- Файл лабораторной: `tests/lab042_pluto_image_loopback.py`, запуск через `python -m tests.lab042_pluto_image_loopback` из корня проекта. - Файл лабораторной: `experiments/lab042_pluto_image_loopback.py`, запуск через `python -m experiments.lab042_pluto_image_loopback` из корня проекта.
--- ---

0
experiments/__init__.py Normal file
View File

View File

@@ -27,7 +27,7 @@ from protocol.video_packet import (
PacketCRCError, PacketCRCError,
VideoPacketError, VideoPacketError,
) )
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
EncodedComposite, EncodedComposite,
SOURCE_VIDEO_PATH, SOURCE_VIDEO_PATH,
VideoMetadata, VideoMetadata,

View File

@@ -32,14 +32,14 @@ from protocol.video_packet import (
ObjectType, ObjectType,
decode_packet, decode_packet,
) )
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
COMPOSITE_FPS, COMPOSITE_FPS,
EncodedComposite, EncodedComposite,
SOURCE_VIDEO_PATH, SOURCE_VIDEO_PATH,
VideoMetadata, VideoMetadata,
load_video_profile, load_video_profile,
) )
from tests.lab029_packet_channel_simulation import ( from experiments.lab029_packet_channel_simulation import (
PAYLOAD_SIZES, PAYLOAD_SIZES,
PreparedProfile, PreparedProfile,
prepare_profiles, prepare_profiles,

View File

@@ -44,18 +44,18 @@ from protocol.video_packet import (
ObjectType, ObjectType,
decode_packet as decode_inner_packet, decode_packet as decode_inner_packet,
) )
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
COMPOSITE_FPS, COMPOSITE_FPS,
SOURCE_VIDEO_PATH, SOURCE_VIDEO_PATH,
EncodedComposite, EncodedComposite,
VideoMetadata, VideoMetadata,
load_video_profile, load_video_profile,
) )
from tests.lab029_packet_channel_simulation import ( from experiments.lab029_packet_channel_simulation import (
PreparedProfile, PreparedProfile,
prepare_profiles, prepare_profiles,
) )
from tests.lab029b_time_based_burst_simulation import ( from experiments.lab029b_time_based_burst_simulation import (
BAD_TIME_FRACTION, BAD_TIME_FRACTION,
CONTROL_STREAM_BITRATE_BPS, CONTROL_STREAM_BITRATE_BPS,
CONTROL_STREAM_BITRATE_KBPS, CONTROL_STREAM_BITRATE_KBPS,

View File

@@ -36,18 +36,18 @@ from protocol.video_packet import (
CompositeReassembler, CompositeReassembler,
decode_packet as decode_inner_packet, decode_packet as decode_inner_packet,
) )
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
COMPOSITE_FPS, COMPOSITE_FPS,
SOURCE_VIDEO_PATH, SOURCE_VIDEO_PATH,
EncodedComposite, EncodedComposite,
VideoMetadata, VideoMetadata,
load_video_profile, load_video_profile,
) )
from tests.lab029_packet_channel_simulation import ( from experiments.lab029_packet_channel_simulation import (
PreparedProfile, PreparedProfile,
prepare_profiles, prepare_profiles,
) )
from tests.lab029b_time_based_burst_simulation import ( from experiments.lab029b_time_based_burst_simulation import (
BAD_TIME_FRACTION, BAD_TIME_FRACTION,
CONTROL_STREAM_BITRATE_BPS, CONTROL_STREAM_BITRATE_BPS,
CONTROL_STREAM_BITRATE_KBPS, CONTROL_STREAM_BITRATE_KBPS,
@@ -55,7 +55,7 @@ from tests.lab029b_time_based_burst_simulation import (
generate_bad_intervals, generate_bad_intervals,
percentile, percentile,
) )
from tests.lab030_packet_erasure_fec import ( from experiments.lab030_packet_erasure_fec import (
FECBlockPlan, FECBlockPlan,
FECMode, FECMode,
FunctionalTestResult, FunctionalTestResult,

View File

@@ -34,18 +34,18 @@ from protocol.video_packet import (
CompositeReassembler, CompositeReassembler,
decode_packet as decode_inner_packet, decode_packet as decode_inner_packet,
) )
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
COMPOSITE_FPS, COMPOSITE_FPS,
SOURCE_VIDEO_PATH, SOURCE_VIDEO_PATH,
EncodedComposite, EncodedComposite,
VideoMetadata, VideoMetadata,
load_video_profile, load_video_profile,
) )
from tests.lab029_packet_channel_simulation import ( from experiments.lab029_packet_channel_simulation import (
PreparedProfile, PreparedProfile,
prepare_profiles, prepare_profiles,
) )
from tests.lab029b_time_based_burst_simulation import ( from experiments.lab029b_time_based_burst_simulation import (
BAD_TIME_FRACTION, BAD_TIME_FRACTION,
CONTROL_STREAM_BITRATE_BPS, CONTROL_STREAM_BITRATE_BPS,
CONTROL_STREAM_BITRATE_KBPS, CONTROL_STREAM_BITRATE_KBPS,
@@ -53,7 +53,7 @@ from tests.lab029b_time_based_burst_simulation import (
generate_bad_intervals, generate_bad_intervals,
percentile, percentile,
) )
from tests.lab030_packet_erasure_fec import ( from experiments.lab030_packet_erasure_fec import (
FECBlockPlan, FECBlockPlan,
FECMode, FECMode,
FunctionalTestResult, FunctionalTestResult,

View File

@@ -47,21 +47,21 @@ from protocol.video_packet import (
CompositeReassembler, CompositeReassembler,
decode_packet as decode_inner_packet, decode_packet as decode_inner_packet,
) )
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
COMPOSITE_FPS, COMPOSITE_FPS,
SOURCE_VIDEO_PATH, SOURCE_VIDEO_PATH,
EncodedComposite, EncodedComposite,
VideoMetadata, VideoMetadata,
load_video_profile, load_video_profile,
) )
from tests.lab029_packet_channel_simulation import prepare_profiles from experiments.lab029_packet_channel_simulation import prepare_profiles
from tests.lab030_packet_erasure_fec import ( from experiments.lab030_packet_erasure_fec import (
FECBlockPlan, FECBlockPlan,
SourcePacket, SourcePacket,
TransmissionUnit, TransmissionUnit,
prepare_source_packets, prepare_source_packets,
) )
from tests.lab032_fec_parameter_sweep import ( from experiments.lab032_fec_parameter_sweep import (
SweepMode, SweepMode,
build_parameter_units, build_parameter_units,
) )
@@ -994,7 +994,7 @@ def write_report(
"8. Созданные файлы и итоговый Git status", "8. Созданные файлы и итоговый Git status",
"- protocol/link_packet.py", "- protocol/link_packet.py",
"- protocol/priority_scheduler.py", "- protocol/priority_scheduler.py",
"- tests/lab033_priority_channel_scheduler.py", "- experiments/lab033_priority_channel_scheduler.py",
"- data/processed/lab033/lab033_summary.csv", "- data/processed/lab033/lab033_summary.csv",
"- data/processed/lab033/lab033_class_metrics.csv", "- data/processed/lab033/lab033_class_metrics.csv",
"- data/processed/lab033/lab033_report.txt", "- data/processed/lab033/lab033_report.txt",

View File

@@ -34,10 +34,10 @@ from protocol.video_age_policy import (
schedule_with_video_age, schedule_with_video_age,
) )
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
from tests.lab028_video_packetization import COMPOSITE_FPS from experiments.lab028_video_packetization import COMPOSITE_FPS
from tests.lab029_packet_channel_simulation import prepare_profiles from experiments.lab029_packet_channel_simulation import prepare_profiles
from tests.lab030_packet_erasure_fec import SourcePacket, prepare_source_packets from experiments.lab030_packet_erasure_fec import SourcePacket, prepare_source_packets
from tests.lab033_priority_channel_scheduler import ( from experiments.lab033_priority_channel_scheduler import (
CHANNEL_RATES_KBPS, CHANNEL_RATES_KBPS,
STREAM_VIDEO, STREAM_VIDEO,
VIDEO_PAYLOAD_SIZE, VIDEO_PAYLOAD_SIZE,
@@ -982,7 +982,7 @@ def write_report(lab033, continuous, aligned, results, tests):
]) ])
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests) lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
lines.extend(["", "8. Созданные файлы"]) lines.extend(["", "8. Созданные файлы"])
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_age_policy.py"), Path("tests/lab034_stale_video_drop.py"), SUMMARY_CSV_PATH, VIDEO_CSV_PATH, CONTROL_CSV_PATH, REPORT_PATH, *PLOT_PATHS)) lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_age_policy.py"), Path("experiments/lab034_stale_video_drop.py"), SUMMARY_CSV_PATH, VIDEO_CSV_PATH, CONTROL_CSV_PATH, REPORT_PATH, *PLOT_PATHS))
lines.extend([ lines.extend([
"", "",
"9. Итоговый Git status", "9. Итоговый Git status",

View File

@@ -25,8 +25,8 @@ from protocol.video_frame_scheduler import (
schedule_video_frames, schedule_video_frames,
) )
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
from tests.lab028_video_packetization import COMPOSITE_FPS from experiments.lab028_video_packetization import COMPOSITE_FPS
from tests.lab034_stale_video_drop import ( from experiments.lab034_stale_video_drop import (
PolicyDefinition as Lab034Policy, PolicyDefinition as Lab034Policy,
build_aligned_workload, build_aligned_workload,
build_lab033_workload, build_lab033_workload,
@@ -721,7 +721,7 @@ def write_report(aligned,results,tests):
lines.extend(["","5. Интерпретация","- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.","- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.","- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.","- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.","- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.","- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.","- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.","","6. Допущения","- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.","- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.","- Политика автоматически не выбирается.","","7. Функциональные проверки"]) lines.extend(["","5. Интерпретация","- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.","- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.","- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.","- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.","- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.","- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.","- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.","","6. Допущения","- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.","- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.","- Политика автоматически не выбирается.","","7. Функциональные проверки"])
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests) lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
lines.extend(["","8. Созданные файлы"]) lines.extend(["","8. Созданные файлы"])
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("tests/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS)) lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("experiments/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status]) lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8") REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")

View File

@@ -31,12 +31,12 @@ from protocol.packet_erasure_fec import (
from protocol.video_age_policy import AgePolicyPacket from protocol.video_age_policy import AgePolicyPacket
from protocol.video_frame_scheduler import FramePolicy, VideoFrameGroup from protocol.video_frame_scheduler import FramePolicy, VideoFrameGroup
from protocol.video_packet import decode_packet as decode_inner_packet from protocol.video_packet import decode_packet as decode_inner_packet
from tests.lab028_video_packetization import ( from experiments.lab028_video_packetization import (
COMPOSITE_FPS, COMPOSITE_FPS,
EncodedComposite, EncodedComposite,
packets_for_composite, packets_for_composite,
) )
from tests.lab033_priority_channel_scheduler import ( from experiments.lab033_priority_channel_scheduler import (
STREAM_CONTROL, STREAM_CONTROL,
STREAM_EMERGENCY, STREAM_EMERGENCY,
STREAM_TELEMETRY, STREAM_TELEMETRY,
@@ -44,7 +44,7 @@ from tests.lab033_priority_channel_scheduler import (
build_workload as build_lab033_workload, build_workload as build_lab033_workload,
deterministic_payload, deterministic_payload,
) )
from tests.lab034_stale_video_drop import parity_for_partial from experiments.lab034_stale_video_drop import parity_for_partial
OUTPUT_DIRECTORY = Path("data/processed/lab036") OUTPUT_DIRECTORY = Path("data/processed/lab036")
@@ -1318,7 +1318,7 @@ def write_report(
f"- CRC-проверено общих пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}.", f"- CRC-проверено общих пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}.",
"", "",
"7. Созданные файлы", "7. Созданные файлы",
"- tests/lab036_long_duration_scheduler.py", "- experiments/lab036_long_duration_scheduler.py",
"- data/processed/lab036/lab036_summary.csv", "- data/processed/lab036/lab036_summary.csv",
"- data/processed/lab036/lab036_video_metrics.csv", "- data/processed/lab036/lab036_video_metrics.csv",
"- data/processed/lab036/lab036_control_metrics.csv", "- data/processed/lab036/lab036_control_metrics.csv",

View File

@@ -42,8 +42,8 @@ from protocol.packet_erasure_fec import (
from protocol.video_packet import decode_packet as decode_inner_packet from protocol.video_packet import decode_packet as decode_inner_packet
from protocol.video_frame_scheduler import VideoFrameGroup from protocol.video_frame_scheduler import VideoFrameGroup
from protocol.video_age_policy import AgePolicyPacket from protocol.video_age_policy import AgePolicyPacket
from tests.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite from experiments.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
from tests.lab033_priority_channel_scheduler import ( from experiments.lab033_priority_channel_scheduler import (
STREAM_CONTROL, STREAM_CONTROL,
STREAM_EMERGENCY, STREAM_EMERGENCY,
STREAM_TELEMETRY, STREAM_TELEMETRY,
@@ -51,7 +51,7 @@ from tests.lab033_priority_channel_scheduler import (
build_workload as build_lab033_workload, build_workload as build_lab033_workload,
deterministic_payload, deterministic_payload,
) )
from tests.lab034_stale_video_drop import parity_for_partial from experiments.lab034_stale_video_drop import parity_for_partial
OUTPUT_DIRECTORY = Path("data/processed/lab037") OUTPUT_DIRECTORY = Path("data/processed/lab037")
@@ -1186,13 +1186,13 @@ def write_report(
"", "",
"Созданные файлы", "Созданные файлы",
"- protocol/control_repetition.py", "- protocol/control_repetition.py",
"- tests/lab037_lossy_full_link.py", "- experiments/lab037_lossy_full_link.py",
*[f"- {path.as_posix()}" for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS)], *[f"- {path.as_posix()}" for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS)],
"", "",
"Итоговый git status", "Итоговый git status",
"?? data/processed/lab037/", "?? data/processed/lab037/",
"?? protocol/control_repetition.py", "?? protocol/control_repetition.py",
"?? tests/lab037_lossy_full_link.py", "?? experiments/lab037_lossy_full_link.py",
"", "",
"Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.", "Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.",
"Режим защиты автоматически не выбирается: все три режима представлены отдельно.", "Режим защиты автоматически не выбирается: все три режима представлены отдельно.",

View File

@@ -43,12 +43,12 @@ from protocol.packet_erasure_fec import (
decode_outer_symbol, decode_outer_symbol,
) )
from protocol.video_packet import decode_packet as decode_inner_packet from protocol.video_packet import decode_packet as decode_inner_packet
from tests.lab033_priority_channel_scheduler import ( from experiments.lab033_priority_channel_scheduler import (
STREAM_CONTROL, STREAM_CONTROL,
STREAM_EMERGENCY, STREAM_EMERGENCY,
deterministic_payload, deterministic_payload,
) )
from tests.lab037_lossy_full_link import ( from experiments.lab037_lossy_full_link import (
BAD_TIME_FRACTION, BAD_TIME_FRACTION,
CHANNEL_RATES_KBPS, CHANNEL_RATES_KBPS,
DURATION_SECONDS, DURATION_SECONDS,
@@ -1577,7 +1577,7 @@ def write_report(
created = ( created = (
Path("protocol/control_failsafe.py"), Path("protocol/control_failsafe.py"),
Path("protocol/emergency_ack.py"), Path("protocol/emergency_ack.py"),
Path("tests/lab038_control_failsafe.py"), Path("experiments/lab038_control_failsafe.py"),
SUMMARY_CSV_PATH, SUMMARY_CSV_PATH,
COMMAND_CSV_PATH, COMMAND_CSV_PATH,
EMERGENCY_CSV_PATH, EMERGENCY_CSV_PATH,
@@ -1596,8 +1596,8 @@ def write_report(
"?? protocol/control_failsafe.py", "?? protocol/control_failsafe.py",
"?? protocol/control_repetition.py", "?? protocol/control_repetition.py",
"?? protocol/emergency_ack.py", "?? protocol/emergency_ack.py",
"?? tests/lab037_lossy_full_link.py", "?? experiments/lab037_lossy_full_link.py",
"?? tests/lab038_control_failsafe.py", "?? experiments/lab038_control_failsafe.py",
"", "",
"Lab038 не добавлен в Git и не закоммичен. Двоичные пакеты, JPEG, дампы и подробные пакетные журналы не сохранялись.", "Lab038 не добавлен в Git и не закоммичен. Двоичные пакеты, JPEG, дампы и подробные пакетные журналы не сохранялись.",
] ]

View File

@@ -24,9 +24,9 @@ from protocol.two_stage_failsafe import (
forced_stop, forced_stop,
integrate_kinematics, integrate_kinematics,
) )
from tests.lab033_priority_channel_scheduler import STREAM_EMERGENCY from experiments.lab033_priority_channel_scheduler import STREAM_EMERGENCY
from tests.lab037_lossy_full_link import bad_intervals from experiments.lab037_lossy_full_link import bad_intervals
from tests.lab038_control_failsafe import ( from experiments.lab038_control_failsafe import (
DURATION_SECONDS, DURATION_SECONDS,
EMERGENCY_SPREAD_US, EMERGENCY_SPREAD_US,
MASTER_SEED, MASTER_SEED,
@@ -1776,7 +1776,7 @@ def write_report(
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests) lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
created = ( created = (
Path("protocol/two_stage_failsafe.py"), Path("protocol/two_stage_failsafe.py"),
Path("tests/lab039_two_stage_braking.py"), Path("experiments/lab039_two_stage_braking.py"),
SUMMARY_CSV_PATH, SUMMARY_CSV_PATH,
WATCHDOG_CSV_PATH, WATCHDOG_CSV_PATH,
DISTANCE_CSV_PATH, DISTANCE_CSV_PATH,

View File

@@ -37,9 +37,9 @@ from protocol.two_stage_failsafe import (
TwoStageFailsafe, TwoStageFailsafe,
integrate_kinematics, integrate_kinematics,
) )
from tests.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY from experiments.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY
from tests.lab037_lossy_full_link import bad_intervals from experiments.lab037_lossy_full_link import bad_intervals
from tests.lab038_control_failsafe import DURATION_SECONDS, Frame, Unit, Workload, build_workload, packet_is_lost from experiments.lab038_control_failsafe import DURATION_SECONDS, Frame, Unit, Workload, build_workload, packet_is_lost
OUTPUT_DIRECTORY = Path("data/processed/lab040") OUTPUT_DIRECTORY = Path("data/processed/lab040")
@@ -1650,7 +1650,7 @@ def write_report(
created = ( created = (
Path("protocol/persistent_emergency.py"), Path("protocol/persistent_emergency.py"),
Path("protocol/safe_reset.py"), Path("protocol/safe_reset.py"),
Path("tests/lab040_persistent_emergency.py"), Path("experiments/lab040_persistent_emergency.py"),
SUMMARY_CSV, SUMMARY_CSV,
EMERGENCY_CSV, EMERGENCY_CSV,
RESET_CSV, RESET_CSV,

View File

@@ -94,7 +94,7 @@ VIDEO_OFFERED_LOAD_KBPS = 243.39286666666666
TELEMETRY_OFFERED_LOAD_KBPS = 7.68 TELEMETRY_OFFERED_LOAD_KBPS = 7.68
# Параметры торможения взяты из профиля "nominal" Lab039, а не подобраны заново. # Параметры торможения взяты из профиля "nominal" Lab039, а не подобраны заново.
# tests/lab039_two_stage_braking.py: DecelerationProfile("nominal", 1.0, 3.0). # experiments/lab039_two_stage_braking.py: DecelerationProfile("nominal", 1.0, 3.0).
# Оценка пути ниже одноступенчатая и потому консервативнее двухступенчатой # Оценка пути ниже одноступенчатая и потому консервативнее двухступенчатой
# модели Lab039: она не учитывает предварительное замедление Stage1. # модели Lab039: она не учитывает предварительное замедление Stage1.
LAB039_NOMINAL_STAGE2_MPS2 = 3.0 LAB039_NOMINAL_STAGE2_MPS2 = 3.0
@@ -1417,7 +1417,7 @@ def write_report(
created = ( created = (
Path("protocol/control_messages.py"), Path("protocol/control_messages.py"),
Path("protocol/session_state.py"), Path("protocol/session_state.py"),
Path("tests/lab041_session_restart_safety.py"), Path("experiments/lab041_session_restart_safety.py"),
SUMMARY_CSV, SUMMARY_CSV,
SESSION_CSV, SESSION_CSV,
REJECTION_CSV, REJECTION_CSV,

802
protocol/bpsk_radio.py Normal file
View File

@@ -0,0 +1,802 @@
"""Проверенные примитивы 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
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
)

View File

@@ -0,0 +1 @@
"""Быстрые автоматические проверки ядра протокола."""

212
tests/test_bpsk_radio.py Normal file
View File

@@ -0,0 +1,212 @@
"""Проверки protocol/bpsk_radio.py.
Модуль был собран переносом функций из Lab018, Lab019 и Lab023. Главная
задача этих проверок — доказать, что перенос ничего не изменил: результаты
сравниваются с оригиналами, загруженными из лабораторных в изоляции, без
выполнения их модульного кода.
"""
from __future__ import annotations
import ast
import pathlib
import struct
import numpy as np
import pytest
from protocol import bpsk_radio as radio
from protocol.packet import MESSAGE_TYPE_IMAGE_FRAGMENT, build_packet, parse_packet
PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
SHARED_CONSTANTS = {
"RADIO_SYNC_WORD",
"PREAMBLE_BIT_COUNT",
"SYMBOL_RATE",
"CFO_REFINEMENT_HALF_WIDTH_HZ",
"CFO_REFINEMENT_STEP_HZ",
}
def _load_isolated(relative_path: str, function_names: set[str]) -> dict:
"""Выполнить только заданные функции лабораторной, без кода модуля.
Лабораторные при импорте создают каталоги и пишут файлы, поэтому
импортировать их в тестах нельзя.
"""
path = PROJECT_ROOT / relative_path
tree = ast.parse(path.read_text(encoding="utf-8"))
kept = [
node
for node in tree.body
if (isinstance(node, ast.FunctionDef) and node.name in function_names)
or (isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) in SHARED_CONSTANTS)
]
namespace: dict = {"np": np, "struct": struct}
exec(compile(ast.Module(body=kept, type_ignores=[]), str(path), "exec"), namespace)
return namespace
@pytest.fixture(scope="module")
def original_lab023() -> dict:
return _load_isolated(
"experiments/lab023_guarded_cfo_correction.py",
{
"bytes_to_bits",
"bits_to_bytes",
"bpsk_modulate",
"bpsk_demodulate",
"estimate_carrier_parameters",
"correct_phase_and_frequency",
},
)
@pytest.fixture(scope="module")
def original_lab019() -> dict:
return _load_isolated(
"experiments/lab019_bpsk_receiver.py",
{
"build_frame_marker",
"find_radio_frame",
"bytes_to_bits",
"bits_to_bytes",
"bpsk_modulate",
"bpsk_demodulate",
"root_raised_cosine_taps",
},
)
@pytest.fixture(scope="module")
def original_lab018() -> dict:
return _load_isolated("experiments/lab018_bpsk_radio_frame.py", {"root_raised_cosine_taps"})
def _identical(left, right) -> bool:
if isinstance(left, np.ndarray) or isinstance(right, np.ndarray):
return np.array_equal(np.asarray(left), np.asarray(right))
if isinstance(left, dict):
return sorted(left) == sorted(right) and all(_identical(left[k], right[k]) for k in left)
if isinstance(left, (tuple, list)):
return len(left) == len(right) and all(_identical(a, b) for a, b in zip(left, right))
return left == right
# ---------------------------------------------------------------- эквивалентность
@pytest.mark.parametrize("length", [1, 7, 64, 255])
def test_bit_conversion_matches_lab023(original_lab023: dict, length: int) -> None:
data = bytes(np.random.default_rng(length).integers(0, 256, size=length, dtype=np.uint8))
ours = radio.bytes_to_bits(data)
assert _identical(ours, original_lab023["bytes_to_bits"](data))
assert _identical(radio.bits_to_bytes(ours), original_lab023["bits_to_bytes"](ours))
@pytest.mark.parametrize("length", [8, 64, 256])
def test_modulation_matches_lab023(original_lab023: dict, length: int) -> None:
bits = np.random.default_rng(length).integers(0, 2, size=length).astype(np.uint8)
ours = radio.bpsk_modulate(bits)
assert _identical(ours, original_lab023["bpsk_modulate"](bits))
assert _identical(radio.bpsk_demodulate(ours), original_lab023["bpsk_demodulate"](ours))
@pytest.mark.parametrize("rolloff,sps,span", [(0.35, 32, 10), (0.35, 128, 10), (0.5, 64, 8)])
def test_shaping_filter_matches_lab018(original_lab018: dict, rolloff: float, sps: int, span: int) -> None:
assert _identical(
radio.root_raised_cosine_taps(rolloff, sps, span),
original_lab018["root_raised_cosine_taps"](rolloff, sps, span),
)
def test_frame_marker_matches_lab019(original_lab019: dict) -> None:
"""Маркер возвращает пару: биты и символы. Совпасть должны обе."""
assert _identical(radio.build_frame_marker(), original_lab019["build_frame_marker"]())
@pytest.mark.parametrize("seed", [1, 2, 3])
def test_carrier_estimation_matches_lab023(original_lab023: dict, seed: int) -> None:
"""Оценка ухода частоты обязана совпасть при произвольных фазе и уходе."""
rng = np.random.default_rng(seed)
_, marker = radio.build_frame_marker()
phase = float(rng.uniform(-3.0, 3.0))
increment = float(rng.uniform(-0.005, 0.005))
impaired = marker * np.exp(1j * (phase + increment * np.arange(len(marker))))
impaired = impaired + 0.02 * rng.standard_normal(len(marker))
assert _identical(
radio.estimate_carrier_parameters(impaired, marker),
original_lab023["estimate_carrier_parameters"](impaired, marker),
)
assert _identical(
radio.correct_phase_and_frequency(impaired, phase, increment),
original_lab023["correct_phase_and_frequency"](impaired, phase, increment),
)
# ---------------------------------------------------------------- новые функции кадра
@pytest.mark.parametrize("payload_size", [1, 17, 512, radio.MAXIMUM_PROTOCOL_PACKET_BYTES])
def test_frame_roundtrip(payload_size: int) -> None:
packet = bytes(np.random.default_rng(payload_size).integers(0, 256, size=payload_size, dtype=np.uint8))
bits, symbols, marker = radio.build_radio_frame(packet)
assert len(bits) == radio.radio_frame_bit_count(payload_size)
assert len(symbols) == len(bits)
assert len(marker) == radio.MARKER_BIT_COUNT
assert radio.parse_radio_frame(bits) == packet
def test_frame_rejects_sizes_outside_range() -> None:
with pytest.raises(ValueError):
radio.build_radio_frame(b"")
with pytest.raises(ValueError):
radio.build_radio_frame(b"\x00" * (radio.MAXIMUM_PROTOCOL_PACKET_BYTES + 1))
with pytest.raises(TypeError):
radio.build_radio_frame("не байты")
def test_frame_with_broken_sync_word_is_rejected() -> None:
bits, _, _ = radio.build_radio_frame(b"payload")
broken = bits.copy()
broken[radio.PREAMBLE_BIT_COUNT] ^= 1
assert radio.parse_radio_frame(broken) is None
def test_truncated_frame_is_rejected() -> None:
bits, _, _ = radio.build_radio_frame(b"payload")
assert radio.parse_radio_frame(bits[:10]) is None
assert radio.parse_radio_frame(bits[:-8]) is None
def test_frame_carries_a_protocol_packet() -> None:
"""Кадр должен переносить настоящий пакет так, чтобы CRC32 сошлась."""
packet = build_packet(b"fragment payload", MESSAGE_TYPE_IMAGE_FRAGMENT, 5)
bits, _, _ = radio.build_radio_frame(packet)
assert parse_packet(radio.parse_radio_frame(bits)).payload == b"fragment payload"
def test_frame_search_finds_the_start_in_a_shaped_signal() -> None:
"""Сквозная проверка: формирование, фильтрация и поиск кадра."""
samples_per_symbol = 32
taps = radio.root_raised_cosine_taps(0.35, samples_per_symbol, 10)
packet = build_packet(b"HELLO SDR", MESSAGE_TYPE_IMAGE_FRAGMENT, 1)
bits, symbols, marker = radio.build_radio_frame(packet)
upsampled = np.zeros(len(symbols) * samples_per_symbol, dtype=complex)
upsampled[::samples_per_symbol] = symbols
matched = np.convolve(np.convolve(upsampled, taps, mode="full"), taps, mode="full")
found = radio.find_radio_frame(matched, marker, samples_per_symbol)
start = found["start_symbol_index"]
recovered = found["symbol_samples"][start : start + len(bits)]
assert radio.parse_radio_frame(radio.bpsk_demodulate(recovered)) == packet

97
tests/test_packet.py Normal file
View File

@@ -0,0 +1,97 @@
"""Быстрые проверки пакетного протокола: protocol/packet.py."""
from __future__ import annotations
import struct
import pytest
from protocol.packet import (
CRC_SIZE,
HEADER_SIZE,
MAX_PAYLOAD_SIZE,
MESSAGE_TYPE_ACK,
MESSAGE_TYPE_IMAGE_FRAGMENT,
MESSAGE_TYPE_TELEMETRY,
MESSAGE_TYPE_TEXT,
PROTOCOL_VERSION,
SYNC_WORD,
CRCError,
PacketError,
build_packet,
parse_packet,
)
MESSAGE_TYPES = (
MESSAGE_TYPE_TEXT,
MESSAGE_TYPE_TELEMETRY,
MESSAGE_TYPE_IMAGE_FRAGMENT,
MESSAGE_TYPE_ACK,
)
@pytest.mark.parametrize("message_type", MESSAGE_TYPES)
@pytest.mark.parametrize("payload", [b"", b"H", b"HELLO SDR", bytes(range(256)), b"\x00" * 1024])
def test_roundtrip_preserves_payload(payload: bytes, message_type: int) -> None:
parsed = parse_packet(build_packet(payload, message_type, 7))
assert parsed.payload == payload
assert parsed.message_type == message_type
assert parsed.sequence_number == 7
assert parsed.version == PROTOCOL_VERSION
def test_packet_starts_with_sync_word() -> None:
packet = build_packet(b"data", MESSAGE_TYPE_TEXT, 1)
assert struct.unpack(">H", packet[:2])[0] == SYNC_WORD
def test_length_is_header_plus_payload_plus_crc() -> None:
payload = b"x" * 300
packet = build_packet(payload, MESSAGE_TYPE_TEXT, 1)
assert len(packet) == HEADER_SIZE + len(payload) + CRC_SIZE
def test_sequence_number_survives_full_range() -> None:
for sequence_number in (0, 1, 65534, 65535):
assert parse_packet(build_packet(b"a", MESSAGE_TYPE_TEXT, sequence_number)).sequence_number == sequence_number
@pytest.mark.parametrize("bit_index", [0, 7, 8, 23, 64, 100])
def test_single_bit_corruption_is_detected(bit_index: int) -> None:
"""CRC32 обязан ловить одиночное искажение бита в любом месте пакета."""
packet = bytearray(build_packet(b"HELLO SDR TELEMETRY", MESSAGE_TYPE_TELEMETRY, 3))
byte_index, offset = divmod(bit_index, 8)
assert byte_index < len(packet)
packet[byte_index] ^= 1 << offset
with pytest.raises((CRCError, PacketError)):
parse_packet(bytes(packet))
def test_truncated_packet_is_rejected() -> None:
packet = build_packet(b"HELLO", MESSAGE_TYPE_TEXT, 1)
for cut in (0, 1, HEADER_SIZE - 1, len(packet) - 1):
with pytest.raises(PacketError):
parse_packet(packet[:cut])
def test_oversized_payload_is_rejected() -> None:
with pytest.raises((PacketError, ValueError)):
build_packet(b"\x00" * (MAX_PAYLOAD_SIZE + 1), MESSAGE_TYPE_TEXT, 1)
def test_wrong_sync_word_is_rejected() -> None:
packet = bytearray(build_packet(b"HELLO", MESSAGE_TYPE_TEXT, 1))
packet[0] ^= 0xFF
with pytest.raises(PacketError):
parse_packet(bytes(packet))
def test_payload_is_not_shared_with_caller() -> None:
"""Разбор не должен возвращать ссылку на изменяемый буфер."""
packet = bytearray(build_packet(b"HELLO", MESSAGE_TYPE_TEXT, 1))
parsed = parse_packet(bytes(packet))
assert isinstance(parsed.payload, bytes)

View File

@@ -24,7 +24,7 @@ import sys
import traceback import traceback
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
CHECKED_DIRECTORIES = ("protocol", "tests", "tools") CHECKED_DIRECTORIES = ("protocol", "tests", "experiments", "tools")
def check_syntax() -> list[str]: def check_syntax() -> list[str]:
@@ -81,13 +81,13 @@ def check_functional_tests() -> tuple[list[str], int, int]:
executed = 0 executed = 0
skipped = 0 skipped = 0
for path in sorted((PROJECT_ROOT / "tests").glob("lab*.py")): for path in sorted((PROJECT_ROOT / "experiments").glob("lab*.py")):
source = path.read_text(encoding="utf-8") source = path.read_text(encoding="utf-8")
if "def run_functional_tests" not in source: if "def run_functional_tests" not in source:
continue continue
module_name = f"tests.{path.stem}" module_name = f"experiments.{path.stem}"
try: try:
module = importlib.import_module(module_name) module = importlib.import_module(module_name)