Compare commits
3 Commits
1e4adc0a98
...
2da80d0908
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da80d0908 | ||
|
|
c486039053 | ||
|
|
c9569164e0 |
@@ -7,7 +7,7 @@ model: sonnet
|
||||
|
||||
Ты проверяешь чужую работу в проекте SDR Rover Link: учебно-исследовательский
|
||||
цифровой радиоканал для наземного ровера. 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`.
|
||||
Полный прогон лабы: `python tests/labNNN_имя.py`. Не верь чужому «тесты проходят».
|
||||
Полный прогон лабы: `python experiments/labNNN_имя.py`. Не верь чужому «тесты проходят».
|
||||
|
||||
3. **Пересчитай ключевые числа независимым способом.** Это главный инструмент.
|
||||
Симуляция BER проверяется аналитической формулой, счётчик отброшенных пакетов —
|
||||
|
||||
@@ -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` |
|
||||
| Подбор размера и качества JPEG | `tests/lab010_image_optimization.py`, готовые профили в `data/processed/lab010` |
|
||||
| Формирование радиокадра с преамбулой и маркером | `build_radio_frame` в `tests/lab018_bpsk_radio_frame.py` |
|
||||
| Подбор размера и качества JPEG | `experiments/lab010_image_optimization.py`, готовые профили в `data/processed/lab010` |
|
||||
| Формирование радиокадра с преамбулой и маркером | `build_radio_frame` в `experiments/lab018_bpsk_radio_frame.py` |
|
||||
| Фильтр приподнятого косинуса | `root_raised_cosine_taps` там же |
|
||||
| Модуляция 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` там же |
|
||||
| Демодуляция и разбор кадра | `bpsk_demodulate`, `decode_radio_frame` там же |
|
||||
| Работа с Pluto на приём | `tests/lab024a_pluto_rx_capture.py` |
|
||||
| Работа с Pluto на приём | `experiments/lab024a_pluto_rx_capture.py` |
|
||||
|
||||
**Единственное, чего нет в проекте — передача.** Это и есть основной объём работы.
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
- Параметры тракта из раздела 5 не менять.
|
||||
- Все константы вынести в начало файла с указанием источника, как это сделано в 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
0
experiments/__init__.py
Normal file
@@ -27,7 +27,7 @@ from protocol.video_packet import (
|
||||
PacketCRCError,
|
||||
VideoPacketError,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
EncodedComposite,
|
||||
SOURCE_VIDEO_PATH,
|
||||
VideoMetadata,
|
||||
@@ -32,14 +32,14 @@ from protocol.video_packet import (
|
||||
ObjectType,
|
||||
decode_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
EncodedComposite,
|
||||
SOURCE_VIDEO_PATH,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PAYLOAD_SIZES,
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
@@ -44,18 +44,18 @@ from protocol.video_packet import (
|
||||
ObjectType,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
)
|
||||
from tests.lab029b_time_based_burst_simulation import (
|
||||
from experiments.lab029b_time_based_burst_simulation import (
|
||||
BAD_TIME_FRACTION,
|
||||
CONTROL_STREAM_BITRATE_BPS,
|
||||
CONTROL_STREAM_BITRATE_KBPS,
|
||||
@@ -36,18 +36,18 @@ from protocol.video_packet import (
|
||||
CompositeReassembler,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
)
|
||||
from tests.lab029b_time_based_burst_simulation import (
|
||||
from experiments.lab029b_time_based_burst_simulation import (
|
||||
BAD_TIME_FRACTION,
|
||||
CONTROL_STREAM_BITRATE_BPS,
|
||||
CONTROL_STREAM_BITRATE_KBPS,
|
||||
@@ -55,7 +55,7 @@ from tests.lab029b_time_based_burst_simulation import (
|
||||
generate_bad_intervals,
|
||||
percentile,
|
||||
)
|
||||
from tests.lab030_packet_erasure_fec import (
|
||||
from experiments.lab030_packet_erasure_fec import (
|
||||
FECBlockPlan,
|
||||
FECMode,
|
||||
FunctionalTestResult,
|
||||
@@ -34,18 +34,18 @@ from protocol.video_packet import (
|
||||
CompositeReassembler,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
)
|
||||
from tests.lab029b_time_based_burst_simulation import (
|
||||
from experiments.lab029b_time_based_burst_simulation import (
|
||||
BAD_TIME_FRACTION,
|
||||
CONTROL_STREAM_BITRATE_BPS,
|
||||
CONTROL_STREAM_BITRATE_KBPS,
|
||||
@@ -53,7 +53,7 @@ from tests.lab029b_time_based_burst_simulation import (
|
||||
generate_bad_intervals,
|
||||
percentile,
|
||||
)
|
||||
from tests.lab030_packet_erasure_fec import (
|
||||
from experiments.lab030_packet_erasure_fec import (
|
||||
FECBlockPlan,
|
||||
FECMode,
|
||||
FunctionalTestResult,
|
||||
@@ -47,21 +47,21 @@ from protocol.video_packet import (
|
||||
CompositeReassembler,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import prepare_profiles
|
||||
from tests.lab030_packet_erasure_fec import (
|
||||
from experiments.lab029_packet_channel_simulation import prepare_profiles
|
||||
from experiments.lab030_packet_erasure_fec import (
|
||||
FECBlockPlan,
|
||||
SourcePacket,
|
||||
TransmissionUnit,
|
||||
prepare_source_packets,
|
||||
)
|
||||
from tests.lab032_fec_parameter_sweep import (
|
||||
from experiments.lab032_fec_parameter_sweep import (
|
||||
SweepMode,
|
||||
build_parameter_units,
|
||||
)
|
||||
@@ -994,7 +994,7 @@ def write_report(
|
||||
"8. Созданные файлы и итоговый Git status",
|
||||
"- protocol/link_packet.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_class_metrics.csv",
|
||||
"- data/processed/lab033/lab033_report.txt",
|
||||
@@ -34,10 +34,10 @@ from protocol.video_age_policy import (
|
||||
schedule_with_video_age,
|
||||
)
|
||||
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS
|
||||
from tests.lab029_packet_channel_simulation import prepare_profiles
|
||||
from tests.lab030_packet_erasure_fec import SourcePacket, prepare_source_packets
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab028_video_packetization import COMPOSITE_FPS
|
||||
from experiments.lab029_packet_channel_simulation import prepare_profiles
|
||||
from experiments.lab030_packet_erasure_fec import SourcePacket, prepare_source_packets
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
CHANNEL_RATES_KBPS,
|
||||
STREAM_VIDEO,
|
||||
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(["", "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([
|
||||
"",
|
||||
"9. Итоговый Git status",
|
||||
@@ -25,8 +25,8 @@ from protocol.video_frame_scheduler import (
|
||||
schedule_video_frames,
|
||||
)
|
||||
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS
|
||||
from tests.lab034_stale_video_drop import (
|
||||
from experiments.lab028_video_packetization import COMPOSITE_FPS
|
||||
from experiments.lab034_stale_video_drop import (
|
||||
PolicyDefinition as Lab034Policy,
|
||||
build_aligned_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(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
|
||||
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])
|
||||
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")
|
||||
|
||||
@@ -31,12 +31,12 @@ from protocol.packet_erasure_fec import (
|
||||
from protocol.video_age_policy import AgePolicyPacket
|
||||
from protocol.video_frame_scheduler import FramePolicy, VideoFrameGroup
|
||||
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,
|
||||
EncodedComposite,
|
||||
packets_for_composite,
|
||||
)
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
STREAM_CONTROL,
|
||||
STREAM_EMERGENCY,
|
||||
STREAM_TELEMETRY,
|
||||
@@ -44,7 +44,7 @@ from tests.lab033_priority_channel_scheduler import (
|
||||
build_workload as build_lab033_workload,
|
||||
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")
|
||||
@@ -1318,7 +1318,7 @@ def write_report(
|
||||
f"- CRC-проверено общих пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}.",
|
||||
"",
|
||||
"7. Созданные файлы",
|
||||
"- tests/lab036_long_duration_scheduler.py",
|
||||
"- experiments/lab036_long_duration_scheduler.py",
|
||||
"- data/processed/lab036/lab036_summary.csv",
|
||||
"- data/processed/lab036/lab036_video_metrics.csv",
|
||||
"- data/processed/lab036/lab036_control_metrics.csv",
|
||||
@@ -42,8 +42,8 @@ from protocol.packet_erasure_fec import (
|
||||
from protocol.video_packet import decode_packet as decode_inner_packet
|
||||
from protocol.video_frame_scheduler import VideoFrameGroup
|
||||
from protocol.video_age_policy import AgePolicyPacket
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
STREAM_CONTROL,
|
||||
STREAM_EMERGENCY,
|
||||
STREAM_TELEMETRY,
|
||||
@@ -51,7 +51,7 @@ from tests.lab033_priority_channel_scheduler import (
|
||||
build_workload as build_lab033_workload,
|
||||
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")
|
||||
@@ -1186,13 +1186,13 @@ def write_report(
|
||||
"",
|
||||
"Созданные файлы",
|
||||
"- 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)],
|
||||
"",
|
||||
"Итоговый git status",
|
||||
"?? data/processed/lab037/",
|
||||
"?? protocol/control_repetition.py",
|
||||
"?? tests/lab037_lossy_full_link.py",
|
||||
"?? experiments/lab037_lossy_full_link.py",
|
||||
"",
|
||||
"Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.",
|
||||
"Режим защиты автоматически не выбирается: все три режима представлены отдельно.",
|
||||
@@ -43,12 +43,12 @@ from protocol.packet_erasure_fec import (
|
||||
decode_outer_symbol,
|
||||
)
|
||||
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_EMERGENCY,
|
||||
deterministic_payload,
|
||||
)
|
||||
from tests.lab037_lossy_full_link import (
|
||||
from experiments.lab037_lossy_full_link import (
|
||||
BAD_TIME_FRACTION,
|
||||
CHANNEL_RATES_KBPS,
|
||||
DURATION_SECONDS,
|
||||
@@ -1577,7 +1577,7 @@ def write_report(
|
||||
created = (
|
||||
Path("protocol/control_failsafe.py"),
|
||||
Path("protocol/emergency_ack.py"),
|
||||
Path("tests/lab038_control_failsafe.py"),
|
||||
Path("experiments/lab038_control_failsafe.py"),
|
||||
SUMMARY_CSV_PATH,
|
||||
COMMAND_CSV_PATH,
|
||||
EMERGENCY_CSV_PATH,
|
||||
@@ -1596,8 +1596,8 @@ def write_report(
|
||||
"?? protocol/control_failsafe.py",
|
||||
"?? protocol/control_repetition.py",
|
||||
"?? protocol/emergency_ack.py",
|
||||
"?? tests/lab037_lossy_full_link.py",
|
||||
"?? tests/lab038_control_failsafe.py",
|
||||
"?? experiments/lab037_lossy_full_link.py",
|
||||
"?? experiments/lab038_control_failsafe.py",
|
||||
"",
|
||||
"Lab038 не добавлен в Git и не закоммичен. Двоичные пакеты, JPEG, дампы и подробные пакетные журналы не сохранялись.",
|
||||
]
|
||||
@@ -24,9 +24,9 @@ from protocol.two_stage_failsafe import (
|
||||
forced_stop,
|
||||
integrate_kinematics,
|
||||
)
|
||||
from tests.lab033_priority_channel_scheduler import STREAM_EMERGENCY
|
||||
from tests.lab037_lossy_full_link import bad_intervals
|
||||
from tests.lab038_control_failsafe import (
|
||||
from experiments.lab033_priority_channel_scheduler import STREAM_EMERGENCY
|
||||
from experiments.lab037_lossy_full_link import bad_intervals
|
||||
from experiments.lab038_control_failsafe import (
|
||||
DURATION_SECONDS,
|
||||
EMERGENCY_SPREAD_US,
|
||||
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)
|
||||
created = (
|
||||
Path("protocol/two_stage_failsafe.py"),
|
||||
Path("tests/lab039_two_stage_braking.py"),
|
||||
Path("experiments/lab039_two_stage_braking.py"),
|
||||
SUMMARY_CSV_PATH,
|
||||
WATCHDOG_CSV_PATH,
|
||||
DISTANCE_CSV_PATH,
|
||||
@@ -37,9 +37,9 @@ from protocol.two_stage_failsafe import (
|
||||
TwoStageFailsafe,
|
||||
integrate_kinematics,
|
||||
)
|
||||
from tests.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY
|
||||
from tests.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.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY
|
||||
from experiments.lab037_lossy_full_link import bad_intervals
|
||||
from experiments.lab038_control_failsafe import DURATION_SECONDS, Frame, Unit, Workload, build_workload, packet_is_lost
|
||||
|
||||
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab040")
|
||||
@@ -1650,7 +1650,7 @@ def write_report(
|
||||
created = (
|
||||
Path("protocol/persistent_emergency.py"),
|
||||
Path("protocol/safe_reset.py"),
|
||||
Path("tests/lab040_persistent_emergency.py"),
|
||||
Path("experiments/lab040_persistent_emergency.py"),
|
||||
SUMMARY_CSV,
|
||||
EMERGENCY_CSV,
|
||||
RESET_CSV,
|
||||
@@ -94,7 +94,7 @@ VIDEO_OFFERED_LOAD_KBPS = 243.39286666666666
|
||||
TELEMETRY_OFFERED_LOAD_KBPS = 7.68
|
||||
|
||||
# Параметры торможения взяты из профиля "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_NOMINAL_STAGE2_MPS2 = 3.0
|
||||
@@ -1417,7 +1417,7 @@ def write_report(
|
||||
created = (
|
||||
Path("protocol/control_messages.py"),
|
||||
Path("protocol/session_state.py"),
|
||||
Path("tests/lab041_session_restart_safety.py"),
|
||||
Path("experiments/lab041_session_restart_safety.py"),
|
||||
SUMMARY_CSV,
|
||||
SESSION_CSV,
|
||||
REJECTION_CSV,
|
||||
802
protocol/bpsk_radio.py
Normal file
802
protocol/bpsk_radio.py
Normal 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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Быстрые автоматические проверки ядра протокола."""
|
||||
|
||||
212
tests/test_bpsk_radio.py
Normal file
212
tests/test_bpsk_radio.py
Normal 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
97
tests/test_packet.py
Normal 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)
|
||||
@@ -24,7 +24,7 @@ import sys
|
||||
import traceback
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CHECKED_DIRECTORIES = ("protocol", "tests", "tools")
|
||||
CHECKED_DIRECTORIES = ("protocol", "tests", "experiments", "tools")
|
||||
|
||||
|
||||
def check_syntax() -> list[str]:
|
||||
@@ -81,13 +81,13 @@ def check_functional_tests() -> tuple[list[str], int, int]:
|
||||
executed = 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")
|
||||
|
||||
if "def run_functional_tests" not in source:
|
||||
continue
|
||||
|
||||
module_name = f"tests.{path.stem}"
|
||||
module_name = f"experiments.{path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
Reference in New Issue
Block a user