Lab035: add proactive video frame admission
@@ -213,3 +213,29 @@ git version 2.51.1.windows.1
|
|||||||
- Подтверждено, что короткая очередь сама по себе не означает свежее изображение.
|
- Подтверждено, что короткая очередь сама по себе не означает свежее изображение.
|
||||||
- Текущая реактивная политика не принята.
|
- Текущая реактивная политика не принята.
|
||||||
- Следующий этап посвящён упреждающему допуску кадров и обслуживанию видео целыми кадрами.
|
- Следующий этап посвящён упреждающему допуску кадров и обслуживанию видео целыми кадрами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Запись 010
|
||||||
|
|
||||||
|
## Дата
|
||||||
|
|
||||||
|
3 августа 2026 года
|
||||||
|
|
||||||
|
## Тема
|
||||||
|
|
||||||
|
Завершение Lab035: упреждающий допуск и обслуживание видео целыми составными кадрами.
|
||||||
|
|
||||||
|
## Выполнено
|
||||||
|
|
||||||
|
- Lab035 исследует упреждающий допуск и обслуживание видео целыми составными кадрами.
|
||||||
|
- Начатый кадр всегда передаётся полностью.
|
||||||
|
- Команды и телеметрия могут передаваться между его пакетами.
|
||||||
|
- Реактивное удаление Lab034 окончательно не принято.
|
||||||
|
- Основная политика — хранение только самого свежего не начатого кадра.
|
||||||
|
- При 230 кбит/с опубликовано 56 из 63 кадров с частотой около 2,6 кадра/с.
|
||||||
|
- Бесполезно переданных видеобайтов нет.
|
||||||
|
- Очередь из двух кадров увеличивает возраст изображения.
|
||||||
|
- Прогноз 1000 мс не дал преимущества над более простой политикой.
|
||||||
|
- Прогноз 500 мс чрезмерно снижает частоту обновления.
|
||||||
|
- Следующая лабораторная проверяет долговременную устойчивость и изменение пропускной способности канала.
|
||||||
|
|||||||
BIN
data/processed/lab035/lab035_control_delay.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
19
data/processed/lab035/lab035_control_metrics.csv
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
channel_kbps,policy,control_p95_delay_ms,control_max_delay_ms,control_deadline_misses,control_max_receive_gap_ms,emergency_delay_ms,emergency_deadline_met,emergency_blocker_class,emergency_blocking_delay_ms,telemetry_deadline_misses
|
||||||
|
300.0,no_drop,17.46633333333314,17.78700000000022,0,65.7596666666671,1.7066666666671892,True,none,0.0,0
|
||||||
|
300.0,reactive_1500ms,17.46633333333314,17.78700000000022,0,65.7596666666671,1.7066666666671892,True,none,0.0,0
|
||||||
|
300.0,latest_only,17.46633333333314,17.78700000000022,0,65.7596666666671,1.7066666666671892,True,none,0.0,0
|
||||||
|
300.0,two_waiting,17.46633333333314,17.78700000000022,0,65.7596666666671,1.7066666666671892,True,none,0.0,0
|
||||||
|
300.0,predict_1000ms,17.46633333333314,17.78700000000022,0,65.7596666666671,1.7066666666671892,True,none,0.0,0
|
||||||
|
300.0,predict_500ms,17.46633333333314,17.78700000000022,0,65.7596666666671,1.7066666666671892,True,none,0.0,0
|
||||||
|
260.0,no_drop,18.907692307691043,20.492307692309808,0,67.81538461538617,1.969230769230279,True,none,0.0,0
|
||||||
|
260.0,reactive_1500ms,18.907692307691043,20.492307692309808,0,67.81538461538617,1.969230769230279,True,none,0.0,0
|
||||||
|
260.0,latest_only,18.907692307691043,20.492307692309808,0,67.81538461538617,1.969230769230279,True,none,0.0,0
|
||||||
|
260.0,two_waiting,18.907692307691043,20.492307692309808,0,67.81538461538617,1.969230769230279,True,none,0.0,0
|
||||||
|
260.0,predict_1000ms,18.907692307691043,20.492307692309808,0,67.81538461538617,1.969230769230279,True,none,0.0,0
|
||||||
|
260.0,predict_500ms,18.907692307691043,20.533000000078516,0,67.81538461538617,1.969230769230279,True,none,0.0,0
|
||||||
|
230.0,no_drop,22.499999999942013,24.556521739064863,0,69.00869565217427,22.330434782542596,True,video,20.10434782602033,0
|
||||||
|
230.0,reactive_1500ms,22.460869565154475,24.556521739064863,0,69.00869565217427,22.330434782542596,True,video,20.10434782602033,0
|
||||||
|
230.0,latest_only,22.043478260820315,23.339130434699484,0,69.00869565217427,6.2260869564561006,True,video,3.9999999999338343,0
|
||||||
|
230.0,two_waiting,22.53913043472089,23.33913043474567,0,69.00869565217427,6.991304347760519,True,video,4.765217391238252,0
|
||||||
|
230.0,predict_1000ms,22.317391304289558,23.35652173909253,0,69.00869565217427,2.365217391240293,True,video,0.13913043471802666,0
|
||||||
|
230.0,predict_500ms,22.211510869569295,23.304347826089256,0,69.00869565217427,5.739130434786688,True,video,3.5130434782644215,0
|
||||||
|
BIN
data/processed/lab035/lab035_frame_outcomes.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
data/processed/lab035/lab035_image_age.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
data/processed/lab035/lab035_policy_comparison.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
data/processed/lab035/lab035_prediction_accuracy.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
19
data/processed/lab035/lab035_prediction_metrics.csv
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
channel_kbps,policy,admitted_frames,prediction_rejected_frames,mean_absolute_error_ms,p95_absolute_error_ms,max_absolute_error_ms,published_after_deadline_frames,false_rejections
|
||||||
|
300.0,no_drop,0,0,0.0,0.0,0.0,0,0
|
||||||
|
300.0,reactive_1500ms,0,0,0.0,0.0,0.0,0,0
|
||||||
|
300.0,latest_only,0,0,0.0,0.0,0.0,0,0
|
||||||
|
300.0,two_waiting,0,0,0.0,0.0,0.0,0,0
|
||||||
|
300.0,predict_1000ms,63,0,0.0,0.0,0.0,0,0
|
||||||
|
300.0,predict_500ms,63,0,0.0,0.0,0.0,0,0
|
||||||
|
260.0,no_drop,0,0,0.0,0.0,0.0,0,0
|
||||||
|
260.0,reactive_1500ms,0,0,0.0,0.0,0.0,0,0
|
||||||
|
260.0,latest_only,0,0,0.0,0.0,0.0,0,0
|
||||||
|
260.0,two_waiting,0,0,0.0,0.0,0.0,0,0
|
||||||
|
260.0,predict_1000ms,63,0,0.0,0.0,0.0,0,0
|
||||||
|
260.0,predict_500ms,62,1,0.0,0.0,0.0,0,0
|
||||||
|
230.0,no_drop,0,0,0.0,0.0,0.0,0,0
|
||||||
|
230.0,reactive_1500ms,0,0,0.0,0.0,0.0,0,0
|
||||||
|
230.0,latest_only,0,0,0.0,0.0,0.0,0,0
|
||||||
|
230.0,two_waiting,0,0,0.0,0.0,0.0,0,0
|
||||||
|
230.0,predict_1000ms,56,7,0.0,0.0,0.0,0,0
|
||||||
|
230.0,predict_500ms,47,16,0.0,0.0,0.0,0,0
|
||||||
|
BIN
data/processed/lab035/lab035_publication_delay.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
data/processed/lab035/lab035_queue_size.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
99
data/processed/lab035/lab035_report.txt
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
Lab035. Упреждающий допуск видеокадров и обслуживание видео целыми кадрами
|
||||||
|
|
||||||
|
1. Исходное состояние
|
||||||
|
- Commit Lab034: b63e36abdb7031d642de8b8138b43cc29e94b759.
|
||||||
|
- Перед Lab035 рабочее дерево было чистым; main опережала origin/main на два commit.
|
||||||
|
|
||||||
|
2. Правило обслуживания
|
||||||
|
- После первого видеопакета кадр становится активным и не удаляется.
|
||||||
|
- При повторном выборе видео передаётся следующий пакет активного кадра; команды и телеметрия могут передаваться между пакетами.
|
||||||
|
- Новый видеокадр начинается только после полного завершения активного; видеопакеты разных кадров не чередуются; отдельный пакет не прерывается.
|
||||||
|
- Только неактивные кадры могут быть удалены до передачи первого пакета.
|
||||||
|
|
||||||
|
3. Теоретическая пропускная способность
|
||||||
|
speed | nonvideo kbps | remaining video kbps | remaining/aligned | minimum skip | maximum fps
|
||||||
|
300 | 17.973 | 282.027 | 1.143443 | 0.000000 | 3.000
|
||||||
|
260 | 17.973 | 242.027 | 0.981268 | 0.018732 | 2.944
|
||||||
|
230 | 17.973 | 212.027 | 0.859636 | 0.140364 | 2.579
|
||||||
|
|
||||||
|
4. Восемнадцать сочетаний
|
||||||
|
speed | policy | published/drop/partial | fps | age P95 ms | no-update max ms | queue max/waiting frames | waste bytes | prediction MAE/P95/max ms | control P95/max ms | emergency ms
|
||||||
|
300 | Без удаления | 63/0/0 | 2.986 | 590.000 | 406.506 | 21/1 | 0 | 0.000000/0.000000/0.000000 | 17.466/17.787 | 1.707
|
||||||
|
300 | Реактивная 1500 мс | 63/0/0 | 2.986 | 590.000 | 406.506 | 21/1 | 0 | 0.000000/0.000000/0.000000 | 17.466/17.787 | 1.707
|
||||||
|
300 | Самый свежий | 63/0/0 | 2.986 | 590.000 | 406.506 | 21/1 | 0 | 0.000000/0.000000/0.000000 | 17.466/17.787 | 1.707
|
||||||
|
300 | Два ожидающих | 63/0/0 | 2.986 | 590.000 | 406.506 | 21/1 | 0 | 0.000000/0.000000/0.000000 | 17.466/17.787 | 1.707
|
||||||
|
300 | Прогноз 1000 мс | 63/0/0 | 2.986 | 590.000 | 406.506 | 21/1 | 0 | 0.000000/0.000000/0.000000 | 17.466/17.787 | 1.707
|
||||||
|
300 | Прогноз 500 мс | 63/0/0 | 2.986 | 590.000 | 406.506 | 21/1 | 0 | 0.000000/0.000000/0.000000 | 17.466/17.787 | 1.707
|
||||||
|
260 | Без удаления | 63/0/0 | 2.937 | 773.833 | 417.764 | 37/1 | 0 | 0.000000/0.000000/0.000000 | 18.908/20.492 | 1.969
|
||||||
|
260 | Реактивная 1500 мс | 63/0/0 | 2.937 | 773.833 | 417.764 | 37/1 | 0 | 0.000000/0.000000/0.000000 | 18.908/20.492 | 1.969
|
||||||
|
260 | Самый свежий | 63/0/0 | 2.937 | 773.833 | 417.764 | 37/1 | 0 | 0.000000/0.000000/0.000000 | 18.908/20.492 | 1.969
|
||||||
|
260 | Два ожидающих | 63/0/0 | 2.937 | 773.833 | 417.764 | 37/1 | 0 | 0.000000/0.000000/0.000000 | 18.908/20.492 | 1.969
|
||||||
|
260 | Прогноз 1000 мс | 63/0/0 | 2.937 | 773.833 | 417.764 | 37/1 | 0 | 0.000000/0.000000/0.000000 | 18.908/20.492 | 1.969
|
||||||
|
260 | Прогноз 500 мс | 62/1/0 | 2.889 | 703.333 | 524.266 | 29/1 | 0 | 0.000000/0.000000/0.000000 | 18.908/20.533 | 1.969
|
||||||
|
230 | Без удаления | 63/0/0 | 2.600 | 2913.333 | 424.000 | 160/8 | 0 | 0.000000/0.000000/0.000000 | 22.500/24.557 | 22.330
|
||||||
|
230 | Реактивная 1500 мс | 27/0/36 | 1.300 | 11064.833 | 10626.980 | 87/4 | 321929 | 0.000000/0.000000/0.000000 | 22.461/24.557 | 22.330
|
||||||
|
230 | Самый свежий | 56/7/0 | 2.600 | 960.000 | 426.226 | 37/1 | 0 | 0.000000/0.000000/0.000000 | 22.043/23.339 | 6.226
|
||||||
|
230 | Два ожидающих | 57/6/0 | 2.600 | 1286.667 | 421.183 | 56/2 | 0 | 0.000000/0.000000/0.000000 | 22.539/23.339 | 6.991
|
||||||
|
230 | Прогноз 1000 мс | 56/7/0 | 2.600 | 1226.667 | 426.226 | 53/2 | 0 | 0.000000/0.000000/0.000000 | 22.317/23.357 | 2.365
|
||||||
|
230 | Прогноз 500 мс | 47/16/0 | 2.215 | 966.667 | 622.817 | 28/1 | 0 | 0.000000/0.000000/0.000000 | 22.212/23.304 | 5.739
|
||||||
|
|
||||||
|
5. Интерпретация
|
||||||
|
- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.
|
||||||
|
- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.
|
||||||
|
- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.
|
||||||
|
- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.
|
||||||
|
- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.
|
||||||
|
- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.
|
||||||
|
- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.
|
||||||
|
|
||||||
|
6. Допущения
|
||||||
|
- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.
|
||||||
|
- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.
|
||||||
|
- Политика автоматически не выбирается.
|
||||||
|
|
||||||
|
7. Функциональные проверки
|
||||||
|
- PASS 01_video_frames_do_not_interleave: PASS
|
||||||
|
- PASS 02_started_frame_never_dropped: PASS
|
||||||
|
- PASS 03_high_priority_between_frame_packets: PASS
|
||||||
|
- PASS 04_latest_drops_only_unstarted: PASS
|
||||||
|
- PASS 05_two_waiting_limit: PASS
|
||||||
|
- PASS 06_prediction_is_pure: PASS
|
||||||
|
- PASS 07_prediction_uses_actual_sizes: PASS
|
||||||
|
- PASS 08_prestart_drop_has_no_waste: PASS
|
||||||
|
- PASS 09_partial_only_reactive: PASS
|
||||||
|
- PASS 10_incomplete_not_published: PASS
|
||||||
|
- PASS 11_crc_layers_pass: PASS
|
||||||
|
- PASS 12_emergency_never_deleted: PASS
|
||||||
|
- PASS 13_priority_above_video: PASS
|
||||||
|
- PASS 14_300kbps_no_unnecessary_loss: PASS
|
||||||
|
- PASS 15_230kbps_bounded_queue: PASS
|
||||||
|
- PASS 16_frame_accounting: PASS
|
||||||
|
- PASS 17_byte_accounting: PASS
|
||||||
|
- PASS 18_reproducible: PASS
|
||||||
|
- PASS 19_predict_1000_never_known_late: PASS
|
||||||
|
- PASS 20_command_delay_bound: PASS
|
||||||
|
|
||||||
|
8. Созданные файлы
|
||||||
|
- protocol/video_frame_scheduler.py
|
||||||
|
- tests/lab035_video_frame_admission.py
|
||||||
|
- data/processed/lab035/lab035_summary.csv
|
||||||
|
- data/processed/lab035/lab035_video_metrics.csv
|
||||||
|
- data/processed/lab035/lab035_control_metrics.csv
|
||||||
|
- data/processed/lab035/lab035_prediction_metrics.csv
|
||||||
|
- data/processed/lab035/lab035_report.txt
|
||||||
|
- data/processed/lab035/lab035_update_rate.png
|
||||||
|
- data/processed/lab035/lab035_image_age.png
|
||||||
|
- data/processed/lab035/lab035_publication_delay.png
|
||||||
|
- data/processed/lab035/lab035_frame_outcomes.png
|
||||||
|
- data/processed/lab035/lab035_queue_size.png
|
||||||
|
- data/processed/lab035/lab035_prediction_accuracy.png
|
||||||
|
- data/processed/lab035/lab035_control_delay.png
|
||||||
|
- data/processed/lab035/lab035_policy_comparison.png
|
||||||
|
|
||||||
|
9. Итоговый Git status
|
||||||
|
- Lab035 не добавлена в индекс и не закоммичена.
|
||||||
|
|
||||||
|
## main...origin/main [ahead 2]
|
||||||
|
?? data/processed/lab035/
|
||||||
|
?? protocol/video_frame_scheduler.py
|
||||||
|
?? tests/lab035_video_frame_admission.py
|
||||||
19
data/processed/lab035/lab035_summary.csv
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
channel_kbps,policy,offered_load_kbps,offered_to_capacity_ratio,transmitted_packets,transmitted_bytes,dropped_before_start_packets,dropped_before_start_bytes,wasted_transmitted_bytes,mean_queue_packets,max_queue_packets,mean_queue_bytes,max_queue_bytes,mean_waiting_video_frames,max_waiting_video_frames,queue_at_source_end_packets,additional_drain_seconds,remaining_video_capacity_kbps,theoretical_minimum_skip_fraction,theoretical_maximum_update_fps
|
||||||
|
300.0,no_drop,264.62022471910115,0.8820674157303372,1738,686910,0,0,0,8.518393226324118,21,4685.69717834665,11415,0.0043967897271271275,1,13,0.1994403333333281,282.02658105939,0.0,3.0
|
||||||
|
300.0,reactive_1500ms,264.62022471910115,0.8820674157303372,1738,686910,0,0,0,8.518393226324118,21,4685.69717834665,11415,0.004397126805778977,1,13,0.1994403333333281,282.02658105939,0.0,3.0
|
||||||
|
300.0,latest_only,264.62022471910115,0.8820674157303372,1738,686910,0,0,0,8.518393226324118,21,4685.69717834665,11415,0.0043967897271271275,1,13,0.1994403333333281,282.02658105939,0.0,3.0
|
||||||
|
300.0,two_waiting,264.62022471910115,0.8820674157303372,1738,686910,0,0,0,8.518393226324118,21,4685.69717834665,11415,0.0043967897271271275,1,13,0.1994403333333281,282.02658105939,0.0,3.0
|
||||||
|
300.0,predict_1000ms,264.62022471910115,0.8820674157303372,1738,686910,0,0,0,8.518393226324118,21,4685.69717834665,11415,0.0043967897271271275,1,13,0.1994403333333281,282.02658105939,0.0,3.0
|
||||||
|
300.0,predict_500ms,264.62022471910115,0.8820674157303372,1738,686910,0,0,0,8.518393226324118,21,4685.69717834665,11415,0.0043967897271271275,1,13,0.1994403333333281,282.02658105939,0.0,3.0
|
||||||
|
260.0,no_drop,264.62022471910115,1.017770095073466,1738,686910,0,0,0,13.695290291396285,37,7655.6639836816,21131,0.22409427213244032,1,30,0.5392926410258561,242.02658105939005,0.0187321490116944,2.943803552964917
|
||||||
|
260.0,reactive_1500ms,264.62022471910115,1.017770095073466,1738,686910,0,0,0,13.695290291396285,37,7655.6639836816,21131,0.22409428818380472,1,30,0.5392926410258561,242.02658105939005,0.0187321490116944,2.943803552964917
|
||||||
|
260.0,latest_only,264.62022471910115,1.017770095073466,1738,686910,0,0,0,13.695290291396285,37,7655.6639836816,21131,0.22409427213244032,1,30,0.5392926410258561,242.02658105939005,0.0187321490116944,2.943803552964917
|
||||||
|
260.0,two_waiting,264.62022471910115,1.017770095073466,1738,686910,0,0,0,13.695290291396285,37,7655.6639836816,21131,0.22409427213244032,1,30,0.5392926410258561,242.02658105939005,0.0187321490116944,2.943803552964917
|
||||||
|
260.0,predict_1000ms,264.62022471910115,1.017770095073466,1738,686910,0,0,0,13.695290291396285,37,7655.6639836816,21131,0.22409427213244032,1,30,0.5392926410258561,242.02658105939005,0.0187321490116944,2.943803552964917
|
||||||
|
260.0,predict_500ms,264.62022471910115,1.017770095073466,1719,675994,19,10916,0,12.011204749970242,29,6682.897892161229,15804,0.13726051487841454,1,21,0.37186633333345753,242.02658105939005,0.0187321490116944,2.943803552964917
|
||||||
|
230.0,no_drop,264.62022471910115,1.1505227161700051,1738,686910,0,0,0,76.17493190801534,160,43586.68565075366,92568,3.7476585944586778,8,155,3.1258550724636294,212.02658105939005,0.14036356404385353,2.5789093078684395
|
||||||
|
230.0,reactive_1500ms,264.62022471910115,1.1505227161700051,1641,637811,0,0,321929,59.44779896084508,87,33911.67327808277,50035,2.785503524321179,4,81,1.4180637681158217,212.02658105939005,0.14036356404385353,2.5789093078684395
|
||||||
|
230.0,latest_only,264.62022471910115,1.1505227161700051,1614,615407,124,71503,0,19.887121174537647,37,11167.829372157905,21067,0.5720064205456188,1,33,0.6387942028984455,212.02658105939005,0.14036356404385353,2.5789093078684395
|
||||||
|
230.0,two_waiting,264.62022471910115,1.1505227161700051,1633,626345,105,60565,0,35.493155370922615,56,20128.047561843177,32209,1.456761532556227,2,52,1.0192463768114841,212.02658105939005,0.14036356404385353,2.5789093078684395
|
||||||
|
230.0,predict_1000ms,264.62022471910115,1.1505227161700051,1612,613851,126,73059,0,32.84338218367999,53,18617.01722113549,30172,1.3064353409168972,2,49,0.5846724637680119,212.02658105939005,0.14036356404385353,2.5789093078684395
|
||||||
|
230.0,predict_500ms,264.62022471910115,1.1505227161700051,1452,521373,286,165537,0,12.440944832856212,28,6904.035936733742,15168,0.22863162956241578,1,22,0.04693333333332461,212.02658105939005,0.14036356404385353,2.5789093078684395
|
||||||
|
BIN
data/processed/lab035/lab035_update_rate.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
19
data/processed/lab035/lab035_video_metrics.csv
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
channel_kbps,policy,created_frames,started_frames,published_frames,dropped_before_start_frames,partially_transmitted_cancelled_frames,published_fraction,actual_update_fps,mean_publication_delay_ms,p95_publication_delay_ms,max_publication_delay_ms,mean_display_age_ms,p95_display_age_ms,max_display_age_ms,display_age_over_500ms_fraction,display_age_over_1000ms_fraction,mean_no_update_duration_ms,p95_no_update_duration_ms,max_no_update_duration_ms,mean_missing_run_frames,p95_missing_run_frames,max_missing_run_frames,mean_publication_gap_ms,max_publication_gap_ms,transmitted_video_bytes,dropped_before_start_video_bytes,wasted_transmitted_video_bytes,delivered_useful_video_kbps
|
||||||
|
300.0,no_drop,63,63,63,0,0,1.0,2.985553772070626,272.2167195767169,293.13066666666225,301.84000000000003,433.2836060314408,589.9999999999999,623.3333333333348,0.30221366698748797,0.0,329.6296296296296,355.05666666666684,406.506333333331,0.0,0.0,0,333.07759016393436,406.506333333331,640254,0,0,157.76975922953451
|
||||||
|
300.0,reactive_1500ms,63,63,63,0,0,1.0,2.985553772070626,272.2167195767169,293.13066666666225,301.84000000000003,433.2836060314408,589.9999999999999,623.3333333333348,0.30221366698748797,0.0,329.6296296296296,355.05666666666684,406.506333333331,0.0,0.0,0,333.03312365591387,406.506333333331,640254,0,0,157.76975922953451
|
||||||
|
300.0,latest_only,63,63,63,0,0,1.0,2.985553772070626,272.2167195767169,293.13066666666225,301.84000000000003,433.2836060314408,589.9999999999999,623.3333333333348,0.30221366698748797,0.0,329.6296296296296,355.05666666666684,406.506333333331,0.0,0.0,0,333.07759016393436,406.506333333331,640254,0,0,157.76975922953451
|
||||||
|
300.0,two_waiting,63,63,63,0,0,1.0,2.985553772070626,272.2167195767169,293.13066666666225,301.84000000000003,433.2836060314408,589.9999999999999,623.3333333333348,0.30221366698748797,0.0,329.6296296296296,355.05666666666684,406.506333333331,0.0,0.0,0,333.07759016393436,406.506333333331,640254,0,0,157.76975922953451
|
||||||
|
300.0,predict_1000ms,63,63,63,0,0,1.0,2.985553772070626,272.2167195767169,293.13066666666225,301.84000000000003,433.2836060314408,589.9999999999999,623.3333333333348,0.30221366698748797,0.0,329.6296296296296,355.05666666666684,406.506333333331,0.0,0.0,0,333.07759016393436,406.506333333331,640254,0,0,157.76975922953451
|
||||||
|
300.0,predict_500ms,63,63,63,0,0,1.0,2.985553772070626,272.2167195767169,293.13066666666225,301.84000000000003,433.2836060314408,589.9999999999999,623.3333333333348,0.30221366698748797,0.0,329.6296296296296,355.05666666666684,406.506333333331,0.0,0.0,0,333.07759016393436,406.506333333331,640254,0,0,157.76975922953451
|
||||||
|
260.0,no_drop,63,63,63,0,0,1.0,2.937399678972713,393.259570207603,579.4598205129968,620.584948718161,546.5335258261151,773.833333333333,943.3333333333316,0.62223291626564,0.0,334.9462365591397,363.58000000001095,417.7637692307723,0.0,0.0,0,337.6377833333365,417.7637692307723,640254,0,0,157.76975922953451
|
||||||
|
260.0,reactive_1500ms,63,63,63,0,0,1.0,2.937399678972713,393.259570207603,579.4598205129968,620.584948718161,546.5335258261151,773.833333333333,943.3333333333316,0.62223291626564,0.0,334.9462365591397,363.58000000001095,417.7637692307723,0.0,0.0,0,337.64599379652947,417.7637692307723,640254,0,0,157.76975922953451
|
||||||
|
260.0,latest_only,63,63,63,0,0,1.0,2.937399678972713,393.259570207603,579.4598205129968,620.584948718161,546.5335258261151,773.833333333333,943.3333333333316,0.62223291626564,0.0,334.9462365591397,363.58000000001095,417.7637692307723,0.0,0.0,0,337.6377833333365,417.7637692307723,640254,0,0,157.76975922953451
|
||||||
|
260.0,two_waiting,63,63,63,0,0,1.0,2.937399678972713,393.259570207603,579.4598205129968,620.584948718161,546.5335258261151,773.833333333333,943.3333333333316,0.62223291626564,0.0,334.9462365591397,363.58000000001095,417.7637692307723,0.0,0.0,0,337.6377833333365,417.7637692307723,640254,0,0,157.76975922953451
|
||||||
|
260.0,predict_1000ms,63,63,63,0,0,1.0,2.937399678972713,393.259570207603,579.4598205129968,620.584948718161,546.5335258261151,773.833333333333,943.3333333333316,0.62223291626564,0.0,334.9462365591397,363.58000000001095,417.7637692307723,0.0,0.0,0,337.6377833333365,417.7637692307723,640254,0,0,157.76975922953451
|
||||||
|
260.0,predict_500ms,63,62,62,1,0,0.9841269841269841,2.8892455858747996,360.2011848635393,447.1807256411333,465.7234102564871,523.434392043632,703.3333333333336,983.3333333333343,0.5654475457170356,0.0,340.4371584699453,365.69230769231353,524.2659999999332,1.0,1.0,1,340.28908604954535,524.2659999999332,629338,10916,0,155.0561797752809
|
||||||
|
230.0,no_drop,63,63,63,0,0,1.0,2.6003210272873196,1777.4951000689596,3067.511884057848,3204.707246376671,1723.7872954764196,2913.333333333334,3206.666666666667,0.9735322425409048,0.8267564966313763,377.57575757575756,412.5426086956473,424.00000000000125,0.0,0.0,0,380.14569319113855,424.00000000000125,640254,0,0,157.76975922953451
|
||||||
|
230.0,reactive_1500ms,63,63,27,0,36,0.42857142857142855,1.3001605136436598,1010.3600644122084,1414.757101449207,1473.0202898550094,4028.4071222329167,11064.833333333332,12103.333333333334,0.9735322425409048,0.8267564966313763,741.6666666666666,416.659130434783,10626.97971014499,36.0,36.0,36,374.5458193979908,424.00000000000125,591155,0,321929,66.39987158908507
|
||||||
|
230.0,latest_only,63,56,56,7,0,0.8888888888888888,2.6003210272873196,536.8126293995401,685.881159420235,717.6463768114871,709.5107475136352,960.0000000000001,1076.6666666666679,0.9100096246390761,0.02165543792107796,377.5757575757575,416.6017391304294,426.22608695652355,1.0,1.0,1,381.4884331419178,426.22608695652355,568751,71503,0,140.1904333868379
|
||||||
|
230.0,two_waiting,63,57,57,6,0,0.9047619047619048,2.6003210272873196,838.9013984235485,1026.1286956521203,1098.0985507245257,998.5707410972092,1286.6666666666665,1440.0000000000014,0.9735322425409048,0.546679499518768,377.5757575757576,406.0417391304327,421.1826086956538,1.0,1.0,1,381.49433962263976,421.1826086956538,579689,60565,0,142.8577849117175
|
||||||
|
230.0,predict_1000ms,63,56,56,7,0,0.8888888888888888,2.6003210272873196,771.422153209065,952.4115942028234,971.5478260868871,943.7102983638115,1226.6666666666665,1359.9999999999995,0.9735322425409048,0.41193455245428295,377.57575757575756,405.373913043476,426.22608695652355,1.0,1.0,1,380.15028712058887,426.22608695652355,567195,73059,0,139.75743178170146
|
||||||
|
230.0,predict_500ms,63,47,47,16,0,0.746031746031746,2.215088282504013,412.55253931544405,466.07430289855006,482.2141594203,641.0153994225219,966.6666666666686,1056.6666666666683,0.7776708373435997,0.026467757459095284,441.84397163120565,614.827726086956,622.8173913043555,1.0,1.0,1,444.19246376811583,622.8173913043555,474717,165537,0,117.06375601926163
|
||||||
|
330
protocol/video_frame_scheduler.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"""Whole-frame video scheduling and predictive admission for Lab035."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from protocol.link_packet import TrafficClass, encode_link_packet
|
||||||
|
from protocol.priority_scheduler import transmission_duration_seconds
|
||||||
|
from protocol.video_age_policy import AgePolicyPacket
|
||||||
|
|
||||||
|
|
||||||
|
TIME_EPSILON_SECONDS = 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
class FramePolicy(str, Enum):
|
||||||
|
NO_DROP = "no_drop"
|
||||||
|
LATEST_ONLY = "latest_only"
|
||||||
|
TWO_WAITING = "two_waiting"
|
||||||
|
PREDICT_1000MS = "predict_1000ms"
|
||||||
|
PREDICT_500MS = "predict_500ms"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deadline_seconds(self) -> float | None:
|
||||||
|
if self is FramePolicy.PREDICT_1000MS:
|
||||||
|
return 1.0
|
||||||
|
if self is FramePolicy.PREDICT_500MS:
|
||||||
|
return 0.5
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VideoFrameGroup:
|
||||||
|
composite_frame_id: int
|
||||||
|
generation_time_us: int
|
||||||
|
packets: tuple[AgePolicyPacket, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generation_time_seconds(self) -> float:
|
||||||
|
return self.generation_time_us / 1_000_000.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wire_size_bytes(self) -> int:
|
||||||
|
return sum(packet.wire_size_bytes for packet in self.packets)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrameDrop:
|
||||||
|
frame: VideoFrameGroup
|
||||||
|
drop_time_seconds: float
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StateReplacement:
|
||||||
|
removed: AgePolicyPacket
|
||||||
|
replacement: AgePolicyPacket
|
||||||
|
time_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrameScheduledPacket:
|
||||||
|
item: AgePolicyPacket
|
||||||
|
start_seconds: float
|
||||||
|
end_seconds: float
|
||||||
|
blocked_by: AgePolicyPacket | None
|
||||||
|
blocking_delay_seconds: float
|
||||||
|
wire_packet: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrameAdmission:
|
||||||
|
composite_frame_id: int
|
||||||
|
predicted_completion_seconds: float
|
||||||
|
actual_completion_seconds: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prediction_error_seconds(self) -> float:
|
||||||
|
return self.actual_completion_seconds - self.predicted_completion_seconds
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrameScheduleResult:
|
||||||
|
policy: FramePolicy
|
||||||
|
channel_bitrate_bps: float
|
||||||
|
transmitted: tuple[FrameScheduledPacket, ...]
|
||||||
|
dropped_frames: tuple[FrameDrop, ...]
|
||||||
|
replacements: tuple[StateReplacement, ...]
|
||||||
|
started_frame_ids: tuple[int, ...]
|
||||||
|
completed_frame_ids: tuple[int, ...]
|
||||||
|
admissions: tuple[FrameAdmission, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_state(
|
||||||
|
ready: list[AgePolicyPacket],
|
||||||
|
item: AgePolicyPacket,
|
||||||
|
replacements: list[StateReplacement],
|
||||||
|
) -> None:
|
||||||
|
if item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY):
|
||||||
|
retained = []
|
||||||
|
for old in ready:
|
||||||
|
if (
|
||||||
|
old.packet.traffic_class == item.packet.traffic_class
|
||||||
|
and old.packet.stream_id == item.packet.stream_id
|
||||||
|
):
|
||||||
|
replacements.append(StateReplacement(old, item, item.available_time_seconds))
|
||||||
|
else:
|
||||||
|
retained.append(old)
|
||||||
|
ready[:] = retained
|
||||||
|
ready.append(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_high_priority(
|
||||||
|
cursor: float,
|
||||||
|
ready: list[AgePolicyPacket],
|
||||||
|
future: tuple[AgePolicyPacket, ...],
|
||||||
|
future_index: int,
|
||||||
|
channel_bitrate_bps: float,
|
||||||
|
) -> tuple[float, int, list[AgePolicyPacket]]:
|
||||||
|
"""Pure local helper used only by the completion predictor."""
|
||||||
|
|
||||||
|
copied_ready = list(ready)
|
||||||
|
index = future_index
|
||||||
|
while index < len(future) and future[index].available_time_seconds <= cursor + TIME_EPSILON_SECONDS:
|
||||||
|
item = future[index]
|
||||||
|
index += 1
|
||||||
|
copied_ready = [
|
||||||
|
old for old in copied_ready
|
||||||
|
if not (
|
||||||
|
old.packet.traffic_class == item.packet.traffic_class
|
||||||
|
and old.packet.stream_id == item.packet.stream_id
|
||||||
|
and item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
copied_ready.append(item)
|
||||||
|
while copied_ready:
|
||||||
|
selected = min(
|
||||||
|
copied_ready,
|
||||||
|
key=lambda item: (int(item.packet.traffic_class), item.arrival_order),
|
||||||
|
)
|
||||||
|
copied_ready.remove(selected)
|
||||||
|
cursor += transmission_duration_seconds(selected.wire_size_bytes, channel_bitrate_bps)
|
||||||
|
while index < len(future) and future[index].available_time_seconds <= cursor + TIME_EPSILON_SECONDS:
|
||||||
|
item = future[index]
|
||||||
|
index += 1
|
||||||
|
copied_ready = [
|
||||||
|
old for old in copied_ready
|
||||||
|
if not (
|
||||||
|
old.packet.traffic_class == item.packet.traffic_class
|
||||||
|
and old.packet.stream_id == item.packet.stream_id
|
||||||
|
and item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
copied_ready.append(item)
|
||||||
|
return cursor, index, copied_ready
|
||||||
|
|
||||||
|
|
||||||
|
def predict_frame_completion(
|
||||||
|
current_time_seconds: float,
|
||||||
|
frame: VideoFrameGroup,
|
||||||
|
channel_bitrate_bps: float,
|
||||||
|
ready_high_priority: Iterable[AgePolicyPacket],
|
||||||
|
future_high_priority: Iterable[AgePolicyPacket],
|
||||||
|
active_packet_remaining_seconds: float = 0.0,
|
||||||
|
) -> float:
|
||||||
|
"""Predict full-frame completion without mutating any caller collection.
|
||||||
|
|
||||||
|
Known periodic commands, telemetry, and the scheduled emergency event are
|
||||||
|
simulated exactly at packet boundaries. Unknown future discrete events are
|
||||||
|
outside the Lab035 traffic model and therefore cannot be included.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if channel_bitrate_bps <= 0.0:
|
||||||
|
raise ValueError("channel bitrate must be positive")
|
||||||
|
if active_packet_remaining_seconds < 0.0:
|
||||||
|
raise ValueError("active packet remainder must not be negative")
|
||||||
|
cursor = current_time_seconds + active_packet_remaining_seconds
|
||||||
|
ready = list(ready_high_priority)
|
||||||
|
future = tuple(sorted(future_high_priority, key=lambda item: (item.available_time_seconds, item.arrival_order)))
|
||||||
|
future_index = 0
|
||||||
|
for video_packet in frame.packets:
|
||||||
|
cursor, future_index, ready = _serve_high_priority(
|
||||||
|
cursor, ready, future, future_index, channel_bitrate_bps
|
||||||
|
)
|
||||||
|
cursor += transmission_duration_seconds(video_packet.wire_size_bytes, channel_bitrate_bps)
|
||||||
|
while future_index < len(future) and future[future_index].available_time_seconds <= cursor + TIME_EPSILON_SECONDS:
|
||||||
|
ready.append(future[future_index])
|
||||||
|
future_index += 1
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_video_frames(
|
||||||
|
frames: Iterable[VideoFrameGroup],
|
||||||
|
high_priority_packets: Iterable[AgePolicyPacket],
|
||||||
|
policy: FramePolicy,
|
||||||
|
channel_bitrate_bps: float,
|
||||||
|
) -> FrameScheduleResult:
|
||||||
|
"""Run strict-priority service while keeping video frames contiguous."""
|
||||||
|
|
||||||
|
policy = FramePolicy(policy)
|
||||||
|
if channel_bitrate_bps <= 0.0:
|
||||||
|
raise ValueError("channel bitrate must be positive")
|
||||||
|
frame_arrivals = tuple(sorted(frames, key=lambda frame: (frame.generation_time_seconds, frame.composite_frame_id)))
|
||||||
|
high_arrivals = tuple(sorted(high_priority_packets, key=lambda item: (item.available_time_seconds, item.arrival_order)))
|
||||||
|
ready_high: list[AgePolicyPacket] = []
|
||||||
|
pending_frames: list[VideoFrameGroup] = []
|
||||||
|
transmitted: list[FrameScheduledPacket] = []
|
||||||
|
dropped: list[FrameDrop] = []
|
||||||
|
replacements: list[StateReplacement] = []
|
||||||
|
started: list[int] = []
|
||||||
|
completed: list[int] = []
|
||||||
|
predicted_by_frame: dict[int, float] = {}
|
||||||
|
actual_by_frame: dict[int, float] = {}
|
||||||
|
active: VideoFrameGroup | None = None
|
||||||
|
active_index = 0
|
||||||
|
cursor = 0.0
|
||||||
|
frame_index = high_index = 0
|
||||||
|
blocker_by_order: dict[int, tuple[AgePolicyPacket, float]] = {}
|
||||||
|
|
||||||
|
def drop_frame(frame: VideoFrameGroup, reason: str) -> None:
|
||||||
|
dropped.append(FrameDrop(frame, cursor, reason))
|
||||||
|
|
||||||
|
def admit(now: float, active_packet: AgePolicyPacket | None = None, active_end: float = 0.0) -> None:
|
||||||
|
nonlocal frame_index, high_index
|
||||||
|
while high_index < len(high_arrivals) and high_arrivals[high_index].available_time_seconds <= now + TIME_EPSILON_SECONDS:
|
||||||
|
item = high_arrivals[high_index]
|
||||||
|
high_index += 1
|
||||||
|
if active_packet is not None and item.available_time_seconds > cursor + TIME_EPSILON_SECONDS:
|
||||||
|
blocker_by_order[item.arrival_order] = (
|
||||||
|
active_packet,
|
||||||
|
max(0.0, active_end - item.available_time_seconds),
|
||||||
|
)
|
||||||
|
_replace_state(ready_high, item, replacements)
|
||||||
|
while frame_index < len(frame_arrivals) and frame_arrivals[frame_index].generation_time_seconds <= now + TIME_EPSILON_SECONDS:
|
||||||
|
frame = frame_arrivals[frame_index]
|
||||||
|
frame_index += 1
|
||||||
|
if policy is FramePolicy.LATEST_ONLY:
|
||||||
|
for old in pending_frames:
|
||||||
|
drop_frame(old, "replaced_by_newest")
|
||||||
|
pending_frames[:] = [frame]
|
||||||
|
elif policy is FramePolicy.TWO_WAITING:
|
||||||
|
pending_frames.append(frame)
|
||||||
|
while len(pending_frames) > 2:
|
||||||
|
drop_frame(pending_frames.pop(0), "waiting_limit")
|
||||||
|
else:
|
||||||
|
pending_frames.append(frame)
|
||||||
|
|
||||||
|
while (
|
||||||
|
frame_index < len(frame_arrivals)
|
||||||
|
or high_index < len(high_arrivals)
|
||||||
|
or ready_high
|
||||||
|
or pending_frames
|
||||||
|
or active is not None
|
||||||
|
):
|
||||||
|
if not ready_high and not pending_frames and active is None:
|
||||||
|
next_times = []
|
||||||
|
if frame_index < len(frame_arrivals):
|
||||||
|
next_times.append(frame_arrivals[frame_index].generation_time_seconds)
|
||||||
|
if high_index < len(high_arrivals):
|
||||||
|
next_times.append(high_arrivals[high_index].available_time_seconds)
|
||||||
|
cursor = max(cursor, min(next_times))
|
||||||
|
admit(cursor)
|
||||||
|
|
||||||
|
selected: AgePolicyPacket | None = None
|
||||||
|
if ready_high:
|
||||||
|
selected = min(
|
||||||
|
ready_high,
|
||||||
|
key=lambda item: (int(item.packet.traffic_class), item.arrival_order),
|
||||||
|
)
|
||||||
|
ready_high.remove(selected)
|
||||||
|
else:
|
||||||
|
if active is None:
|
||||||
|
while pending_frames and active is None:
|
||||||
|
candidate = pending_frames.pop(0)
|
||||||
|
deadline = policy.deadline_seconds
|
||||||
|
if deadline is not None:
|
||||||
|
future_high = high_arrivals[high_index:]
|
||||||
|
predicted = predict_frame_completion(
|
||||||
|
cursor,
|
||||||
|
candidate,
|
||||||
|
channel_bitrate_bps,
|
||||||
|
tuple(ready_high),
|
||||||
|
future_high,
|
||||||
|
)
|
||||||
|
if predicted - candidate.generation_time_seconds > deadline + TIME_EPSILON_SECONDS:
|
||||||
|
drop_frame(candidate, "prediction_reject")
|
||||||
|
continue
|
||||||
|
predicted_by_frame[candidate.composite_frame_id] = predicted
|
||||||
|
active = candidate
|
||||||
|
active_index = 0
|
||||||
|
started.append(active.composite_frame_id)
|
||||||
|
if active is not None:
|
||||||
|
selected = active.packets[active_index]
|
||||||
|
|
||||||
|
if selected is None:
|
||||||
|
continue
|
||||||
|
start = max(cursor, selected.available_time_seconds)
|
||||||
|
wire_packet = encode_link_packet(selected.packet)
|
||||||
|
end = start + transmission_duration_seconds(len(wire_packet), channel_bitrate_bps)
|
||||||
|
cursor = start
|
||||||
|
admit(end, selected, end)
|
||||||
|
blocker, blocking_delay = blocker_by_order.get(selected.arrival_order, (None, 0.0))
|
||||||
|
transmitted.append(
|
||||||
|
FrameScheduledPacket(selected, start, end, blocker, blocking_delay, wire_packet)
|
||||||
|
)
|
||||||
|
cursor = end
|
||||||
|
if selected.packet.traffic_class is TrafficClass.VIDEO:
|
||||||
|
assert active is not None
|
||||||
|
active_index += 1
|
||||||
|
if active_index == len(active.packets):
|
||||||
|
completed.append(active.composite_frame_id)
|
||||||
|
actual_by_frame[active.composite_frame_id] = end
|
||||||
|
active = None
|
||||||
|
active_index = 0
|
||||||
|
|
||||||
|
admissions = tuple(
|
||||||
|
FrameAdmission(frame_id, predicted, actual_by_frame[frame_id])
|
||||||
|
for frame_id, predicted in sorted(predicted_by_frame.items())
|
||||||
|
)
|
||||||
|
return FrameScheduleResult(
|
||||||
|
policy=policy,
|
||||||
|
channel_bitrate_bps=channel_bitrate_bps,
|
||||||
|
transmitted=tuple(transmitted),
|
||||||
|
dropped_frames=tuple(dropped),
|
||||||
|
replacements=tuple(replacements),
|
||||||
|
started_frame_ids=tuple(started),
|
||||||
|
completed_frame_ids=tuple(completed),
|
||||||
|
admissions=admissions,
|
||||||
|
)
|
||||||
748
tests/lab035_video_frame_admission.py
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
"""Lab035: predictive admission and whole-frame video service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import matplotlib
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from protocol.link_packet import HEADER_SIZE, TrafficClass, decode_link_packet
|
||||||
|
from protocol.packet_erasure_fec import decode_fec_block, decode_outer_symbol
|
||||||
|
from protocol.video_frame_scheduler import (
|
||||||
|
FramePolicy,
|
||||||
|
FrameScheduleResult,
|
||||||
|
VideoFrameGroup,
|
||||||
|
predict_frame_completion,
|
||||||
|
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 (
|
||||||
|
PolicyDefinition as Lab034Policy,
|
||||||
|
build_aligned_workload,
|
||||||
|
build_lab033_workload,
|
||||||
|
percentile,
|
||||||
|
simulate_aligned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
OUTPUT_DIRECTORY = Path("data/processed/lab035")
|
||||||
|
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab035_summary.csv"
|
||||||
|
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab035_video_metrics.csv"
|
||||||
|
CONTROL_CSV_PATH = OUTPUT_DIRECTORY / "lab035_control_metrics.csv"
|
||||||
|
PREDICTION_CSV_PATH = OUTPUT_DIRECTORY / "lab035_prediction_metrics.csv"
|
||||||
|
REPORT_PATH = OUTPUT_DIRECTORY / "lab035_report.txt"
|
||||||
|
UPDATE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_update_rate.png"
|
||||||
|
AGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_image_age.png"
|
||||||
|
PUBLICATION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_publication_delay.png"
|
||||||
|
OUTCOME_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_frame_outcomes.png"
|
||||||
|
QUEUE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_queue_size.png"
|
||||||
|
PREDICTION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_prediction_accuracy.png"
|
||||||
|
CONTROL_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_control_delay.png"
|
||||||
|
COMPARISON_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_policy_comparison.png"
|
||||||
|
PLOT_PATHS = (
|
||||||
|
UPDATE_PLOT_PATH,
|
||||||
|
AGE_PLOT_PATH,
|
||||||
|
PUBLICATION_PLOT_PATH,
|
||||||
|
OUTCOME_PLOT_PATH,
|
||||||
|
QUEUE_PLOT_PATH,
|
||||||
|
PREDICTION_PLOT_PATH,
|
||||||
|
CONTROL_PLOT_PATH,
|
||||||
|
COMPARISON_PLOT_PATH,
|
||||||
|
)
|
||||||
|
LAB034_COMMIT = "b63e36abdb7031d642de8b8138b43cc29e94b759"
|
||||||
|
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
|
||||||
|
TIME_EPSILON_SECONDS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PolicyDefinition:
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
scheduler_policy: FramePolicy | None
|
||||||
|
reactive: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
POLICIES = (
|
||||||
|
PolicyDefinition("no_drop", "Без удаления", FramePolicy.NO_DROP),
|
||||||
|
PolicyDefinition("reactive_1500ms", "Реактивная 1500 мс", None, True),
|
||||||
|
PolicyDefinition("latest_only", "Самый свежий", FramePolicy.LATEST_ONLY),
|
||||||
|
PolicyDefinition("two_waiting", "Два ожидающих", FramePolicy.TWO_WAITING),
|
||||||
|
PolicyDefinition("predict_1000ms", "Прогноз 1000 мс", FramePolicy.PREDICT_1000MS),
|
||||||
|
PolicyDefinition("predict_500ms", "Прогноз 500 мс", FramePolicy.PREDICT_500MS),
|
||||||
|
)
|
||||||
|
POLICY_BY_NAME = {policy.name: policy for policy in POLICIES}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TheoreticalCapacity:
|
||||||
|
channel_kbps: float
|
||||||
|
nonvideo_load_kbps: float
|
||||||
|
remaining_video_kbps: float
|
||||||
|
video_capacity_ratio: float
|
||||||
|
minimum_skip_fraction: float
|
||||||
|
maximum_update_fps: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummaryMetrics:
|
||||||
|
channel_kbps: float
|
||||||
|
policy: str
|
||||||
|
offered_load_kbps: float
|
||||||
|
offered_to_capacity_ratio: float
|
||||||
|
transmitted_packets: int
|
||||||
|
transmitted_bytes: int
|
||||||
|
dropped_before_start_packets: int
|
||||||
|
dropped_before_start_bytes: int
|
||||||
|
wasted_transmitted_bytes: int
|
||||||
|
mean_queue_packets: float
|
||||||
|
max_queue_packets: int
|
||||||
|
mean_queue_bytes: float
|
||||||
|
max_queue_bytes: int
|
||||||
|
mean_waiting_video_frames: float
|
||||||
|
max_waiting_video_frames: int
|
||||||
|
queue_at_source_end_packets: int
|
||||||
|
additional_drain_seconds: float
|
||||||
|
remaining_video_capacity_kbps: float
|
||||||
|
theoretical_minimum_skip_fraction: float
|
||||||
|
theoretical_maximum_update_fps: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VideoMetrics:
|
||||||
|
channel_kbps: float
|
||||||
|
policy: str
|
||||||
|
created_frames: int
|
||||||
|
started_frames: int
|
||||||
|
published_frames: int
|
||||||
|
dropped_before_start_frames: int
|
||||||
|
partially_transmitted_cancelled_frames: int
|
||||||
|
published_fraction: float
|
||||||
|
actual_update_fps: float
|
||||||
|
mean_publication_delay_ms: float
|
||||||
|
p95_publication_delay_ms: float
|
||||||
|
max_publication_delay_ms: float
|
||||||
|
mean_display_age_ms: float
|
||||||
|
p95_display_age_ms: float
|
||||||
|
max_display_age_ms: float
|
||||||
|
display_age_over_500ms_fraction: float
|
||||||
|
display_age_over_1000ms_fraction: float
|
||||||
|
mean_no_update_duration_ms: float
|
||||||
|
p95_no_update_duration_ms: float
|
||||||
|
max_no_update_duration_ms: float
|
||||||
|
mean_missing_run_frames: float
|
||||||
|
p95_missing_run_frames: float
|
||||||
|
max_missing_run_frames: int
|
||||||
|
mean_publication_gap_ms: float
|
||||||
|
max_publication_gap_ms: float
|
||||||
|
transmitted_video_bytes: int
|
||||||
|
dropped_before_start_video_bytes: int
|
||||||
|
wasted_transmitted_video_bytes: int
|
||||||
|
delivered_useful_video_kbps: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ControlMetrics:
|
||||||
|
channel_kbps: float
|
||||||
|
policy: str
|
||||||
|
control_p95_delay_ms: float
|
||||||
|
control_max_delay_ms: float
|
||||||
|
control_deadline_misses: int
|
||||||
|
control_max_receive_gap_ms: float
|
||||||
|
emergency_delay_ms: float
|
||||||
|
emergency_deadline_met: bool
|
||||||
|
emergency_blocker_class: str
|
||||||
|
emergency_blocking_delay_ms: float
|
||||||
|
telemetry_deadline_misses: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PredictionMetrics:
|
||||||
|
channel_kbps: float
|
||||||
|
policy: str
|
||||||
|
admitted_frames: int
|
||||||
|
prediction_rejected_frames: int
|
||||||
|
mean_absolute_error_ms: float
|
||||||
|
p95_absolute_error_ms: float
|
||||||
|
max_absolute_error_ms: float
|
||||||
|
published_after_deadline_frames: int
|
||||||
|
false_rejections: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScenarioResult:
|
||||||
|
summary: SummaryMetrics
|
||||||
|
video: VideoMetrics
|
||||||
|
control: ControlMetrics
|
||||||
|
prediction: PredictionMetrics
|
||||||
|
publication_times: dict[int, float]
|
||||||
|
schedule: FrameScheduleResult | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FunctionalTestResult:
|
||||||
|
name: str
|
||||||
|
passed: bool
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_frame_groups(aligned) -> tuple[VideoFrameGroup, ...]:
|
||||||
|
grouped: dict[int, list] = {}
|
||||||
|
for item in aligned.packets:
|
||||||
|
if item.composite_frame_id is not None:
|
||||||
|
grouped.setdefault(item.composite_frame_id, []).append(item)
|
||||||
|
return tuple(
|
||||||
|
VideoFrameGroup(
|
||||||
|
composite_frame_id=frame_id,
|
||||||
|
generation_time_us=packets[0].packet.generation_time_us,
|
||||||
|
packets=tuple(sorted(packets, key=lambda item: item.packet.sequence_number)),
|
||||||
|
)
|
||||||
|
for frame_id, packets in sorted(grouped.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def high_priority_packets(aligned) -> tuple:
|
||||||
|
return tuple(
|
||||||
|
item for item in aligned.packets
|
||||||
|
if item.packet.traffic_class is not TrafficClass.VIDEO
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def theoretical_capacity(aligned, rate: float) -> TheoreticalCapacity:
|
||||||
|
duration = aligned.lab033.metadata.duration_seconds
|
||||||
|
nonvideo_bytes = sum(
|
||||||
|
item.wire_size_bytes for item in aligned.packets
|
||||||
|
if item.packet.traffic_class is not TrafficClass.VIDEO
|
||||||
|
)
|
||||||
|
nonvideo_kbps = nonvideo_bytes * 8.0 / duration / 1000.0
|
||||||
|
video_kbps = aligned.layout_metrics.after_link_kbps
|
||||||
|
remaining = max(0.0, rate - nonvideo_kbps)
|
||||||
|
ratio = remaining / video_kbps
|
||||||
|
skip = max(0.0, 1.0 - ratio)
|
||||||
|
return TheoreticalCapacity(
|
||||||
|
rate,
|
||||||
|
nonvideo_kbps,
|
||||||
|
remaining,
|
||||||
|
ratio,
|
||||||
|
skip,
|
||||||
|
COMPOSITE_FPS * min(1.0, ratio),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def receive_whole_frames(schedule: FrameScheduleResult) -> dict[int, float]:
|
||||||
|
dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
|
||||||
|
receiver = CompositeReassembler()
|
||||||
|
publication_times: dict[int, float] = {}
|
||||||
|
symbols_by_block: dict[int, list[bytes]] = {}
|
||||||
|
decoded_blocks: set[int] = set()
|
||||||
|
for sent in schedule.transmitted:
|
||||||
|
link = decode_link_packet(sent.wire_packet)
|
||||||
|
if link.traffic_class is not TrafficClass.VIDEO:
|
||||||
|
continue
|
||||||
|
frame_id = sent.item.composite_frame_id
|
||||||
|
assert frame_id is not None and frame_id not in dropped
|
||||||
|
outer = decode_outer_symbol(link.payload)
|
||||||
|
symbols = symbols_by_block.setdefault(outer.block_id, [])
|
||||||
|
symbols.append(link.payload)
|
||||||
|
if not outer.is_parity:
|
||||||
|
completed = receiver.ingest(outer.data)
|
||||||
|
if completed is not None:
|
||||||
|
publication_times[completed.composite_frame_id] = sent.end_seconds
|
||||||
|
if outer.block_id not in decoded_blocks and len(symbols) >= outer.source_count:
|
||||||
|
decoded = decode_fec_block(tuple(symbols))
|
||||||
|
for inner in decoded.source_packets:
|
||||||
|
decode_inner_packet(inner)
|
||||||
|
decoded_blocks.add(outer.block_id)
|
||||||
|
if set(publication_times) != set(schedule.completed_frame_ids):
|
||||||
|
raise AssertionError("published frames differ from completed whole frames")
|
||||||
|
return publication_times
|
||||||
|
|
||||||
|
|
||||||
|
def display_and_gap_metrics(publications: dict[int, float], source_end: float):
|
||||||
|
events = sorted((time, frame) for frame, time in publications.items() if time <= source_end + TIME_EPSILON_SECONDS)
|
||||||
|
samples = np.arange(0.0, source_end + 0.005, 0.01)
|
||||||
|
ages = []
|
||||||
|
index = 0
|
||||||
|
last_frame = None
|
||||||
|
for time in samples:
|
||||||
|
while index < len(events) and events[index][0] <= time:
|
||||||
|
last_frame = events[index][1]; index += 1
|
||||||
|
generation = 0.0 if last_frame is None else last_frame / COMPOSITE_FPS
|
||||||
|
ages.append(max(0.0, time - generation))
|
||||||
|
update_times = [0.0] + [time for time, _ in events] + [source_end]
|
||||||
|
no_update = tuple(max(0.0, right - left) for left, right in zip(update_times, update_times[1:]))
|
||||||
|
publication_gaps = tuple(right[0] - left[0] for left, right in zip(events, events[1:]))
|
||||||
|
return ages, no_update, publication_gaps
|
||||||
|
|
||||||
|
|
||||||
|
def missing_runs(frame_count: int, published: set[int]) -> tuple[int, ...]:
|
||||||
|
runs = []
|
||||||
|
current = 0
|
||||||
|
for frame_id in range(frame_count):
|
||||||
|
if frame_id not in published:
|
||||||
|
current += 1
|
||||||
|
elif current:
|
||||||
|
runs.append(current); current = 0
|
||||||
|
if current:
|
||||||
|
runs.append(current)
|
||||||
|
return tuple(runs)
|
||||||
|
|
||||||
|
|
||||||
|
def queue_metrics(schedule: FrameScheduleResult, frames, source_end: float):
|
||||||
|
intervals = []
|
||||||
|
first_start: dict[int, float] = {}
|
||||||
|
for sent in schedule.transmitted:
|
||||||
|
intervals.append((sent.item.available_time_seconds, sent.end_seconds, sent.item.wire_size_bytes))
|
||||||
|
if sent.item.composite_frame_id is not None:
|
||||||
|
first_start.setdefault(sent.item.composite_frame_id, sent.start_seconds)
|
||||||
|
drop_time = {item.frame.composite_frame_id: item.drop_time_seconds for item in schedule.dropped_frames}
|
||||||
|
for item in schedule.dropped_frames:
|
||||||
|
for packet in item.frame.packets:
|
||||||
|
intervals.append((packet.available_time_seconds, item.drop_time_seconds, packet.wire_size_bytes))
|
||||||
|
for item in schedule.replacements:
|
||||||
|
intervals.append((item.removed.available_time_seconds, item.time_seconds, item.removed.wire_size_bytes))
|
||||||
|
packet_area = byte_area = 0.0
|
||||||
|
events: dict[float, list[int]] = {}
|
||||||
|
remaining = 0
|
||||||
|
for start, end, size in intervals:
|
||||||
|
if start <= source_end + TIME_EPSILON_SECONDS < end - TIME_EPSILON_SECONDS:
|
||||||
|
remaining += 1
|
||||||
|
left, right = max(0.0, start), min(source_end, end)
|
||||||
|
if right <= left + TIME_EPSILON_SECONDS:
|
||||||
|
continue
|
||||||
|
packet_area += right - left; byte_area += (right - left) * size
|
||||||
|
events.setdefault(left, [0, 0])[0] += 1; events[left][1] += size
|
||||||
|
events.setdefault(right, [0, 0])[0] -= 1; events[right][1] -= size
|
||||||
|
count = size_now = max_count = max_size = 0
|
||||||
|
for time in sorted(events):
|
||||||
|
count += events[time][0]; size_now += events[time][1]
|
||||||
|
max_count = max(max_count, count); max_size = max(max_size, size_now)
|
||||||
|
frame_intervals = []
|
||||||
|
for frame in frames:
|
||||||
|
end = first_start.get(frame.composite_frame_id, drop_time.get(frame.composite_frame_id, source_end))
|
||||||
|
frame_intervals.append((frame.generation_time_seconds, end))
|
||||||
|
frame_area = 0.0; frame_events: dict[float, int] = {}
|
||||||
|
for start, end in frame_intervals:
|
||||||
|
left, right = max(0.0, start), min(source_end, end)
|
||||||
|
if right <= left + TIME_EPSILON_SECONDS: continue
|
||||||
|
frame_area += right - left
|
||||||
|
frame_events[left] = frame_events.get(left, 0) + 1
|
||||||
|
frame_events[right] = frame_events.get(right, 0) - 1
|
||||||
|
waiting = max_waiting = 0
|
||||||
|
for time in sorted(frame_events):
|
||||||
|
waiting += frame_events[time]; max_waiting = max(max_waiting, waiting)
|
||||||
|
finish = max([source_end] + [item.end_seconds for item in schedule.transmitted] + [item.drop_time_seconds for item in schedule.dropped_frames])
|
||||||
|
return (
|
||||||
|
packet_area / source_end, max_count, byte_area / source_end, max_size,
|
||||||
|
frame_area / source_end, max_waiting, remaining, max(0.0, finish - source_end),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def control_metrics(schedule: FrameScheduleResult, rate: float, policy: str) -> ControlMetrics:
|
||||||
|
by_class = {
|
||||||
|
traffic: [item for item in schedule.transmitted if item.item.packet.traffic_class is traffic]
|
||||||
|
for traffic in (TrafficClass.CONTROL, TrafficClass.EMERGENCY, TrafficClass.TELEMETRY)
|
||||||
|
}
|
||||||
|
control = by_class[TrafficClass.CONTROL]
|
||||||
|
control_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in control]
|
||||||
|
gaps = [right.end_seconds - left.end_seconds for left, right in zip(control, control[1:])]
|
||||||
|
telemetry_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in by_class[TrafficClass.TELEMETRY]]
|
||||||
|
emergency = by_class[TrafficClass.EMERGENCY]
|
||||||
|
if len(emergency) != 1: raise AssertionError("exactly one emergency command is required")
|
||||||
|
urgent = emergency[0]
|
||||||
|
urgent_delay = urgent.end_seconds - urgent.item.packet.generation_time_us / 1_000_000.0
|
||||||
|
return ControlMetrics(
|
||||||
|
rate, policy,
|
||||||
|
percentile(control_delays, 95) * 1000.0,
|
||||||
|
max(control_delays) * 1000.0,
|
||||||
|
sum(delay > 0.1 + TIME_EPSILON_SECONDS for delay in control_delays),
|
||||||
|
max(gaps, default=0.0) * 1000.0,
|
||||||
|
urgent_delay * 1000.0,
|
||||||
|
urgent_delay <= 0.05 + TIME_EPSILON_SECONDS,
|
||||||
|
urgent.blocked_by.packet.traffic_class.name.lower() if urgent.blocked_by else "none",
|
||||||
|
urgent.blocking_delay_seconds * 1000.0,
|
||||||
|
sum(delay > 0.5 + TIME_EPSILON_SECONDS for delay in telemetry_delays),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def video_metrics(aligned, schedule, publications, rate, policy):
|
||||||
|
source_end = aligned.lab033.metadata.duration_seconds
|
||||||
|
published = set(publications)
|
||||||
|
dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
|
||||||
|
delays = [publications[frame] - frame / COMPOSITE_FPS for frame in sorted(published)]
|
||||||
|
ages, no_update, publication_gaps = display_and_gap_metrics(publications, source_end)
|
||||||
|
runs = missing_runs(len(aligned.lab033.composites), published)
|
||||||
|
transmitted_video_bytes = sum(
|
||||||
|
len(item.wire_packet) for item in schedule.transmitted
|
||||||
|
if item.item.packet.traffic_class is TrafficClass.VIDEO
|
||||||
|
)
|
||||||
|
dropped_bytes = sum(item.frame.wire_size_bytes for item in schedule.dropped_frames)
|
||||||
|
useful = sum(
|
||||||
|
len(aligned.lab033.composites[frame].base_jpeg) + len(aligned.lab033.composites[frame].roi_jpeg)
|
||||||
|
for frame in published
|
||||||
|
)
|
||||||
|
return VideoMetrics(
|
||||||
|
rate, policy, len(aligned.lab033.composites), len(schedule.started_frame_ids),
|
||||||
|
len(published), len(dropped), 0, len(published) / len(aligned.lab033.composites),
|
||||||
|
sum(time <= source_end + TIME_EPSILON_SECONDS for time in publications.values()) / source_end,
|
||||||
|
float(np.mean(delays)) * 1000.0 if delays else 0.0,
|
||||||
|
percentile(delays, 95) * 1000.0, max(delays, default=0.0) * 1000.0,
|
||||||
|
float(np.mean(ages)) * 1000.0, percentile(ages, 95) * 1000.0,
|
||||||
|
max(ages, default=0.0) * 1000.0,
|
||||||
|
sum(age > 0.5 for age in ages) / len(ages),
|
||||||
|
sum(age > 1.0 for age in ages) / len(ages),
|
||||||
|
float(np.mean(no_update)) * 1000.0, percentile(no_update, 95) * 1000.0,
|
||||||
|
max(no_update, default=0.0) * 1000.0,
|
||||||
|
float(np.mean(runs)) if runs else 0.0, percentile(runs, 95), max(runs, default=0),
|
||||||
|
float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
|
||||||
|
max(publication_gaps, default=0.0) * 1000.0,
|
||||||
|
transmitted_video_bytes, dropped_bytes, 0,
|
||||||
|
useful * 8.0 / source_end / 1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prediction_metrics(schedule, rate, policy):
|
||||||
|
errors = [abs(item.prediction_error_seconds) for item in schedule.admissions]
|
||||||
|
deadline = schedule.policy.deadline_seconds
|
||||||
|
rejected = [item for item in schedule.dropped_frames if item.reason == "prediction_reject"]
|
||||||
|
late = sum(
|
||||||
|
item.actual_completion_seconds - item.composite_frame_id / COMPOSITE_FPS
|
||||||
|
> deadline + TIME_EPSILON_SECONDS
|
||||||
|
for item in schedule.admissions
|
||||||
|
) if deadline is not None else 0
|
||||||
|
return PredictionMetrics(
|
||||||
|
rate, policy, len(schedule.admissions), len(rejected),
|
||||||
|
float(np.mean(errors)) * 1000.0 if errors else 0.0,
|
||||||
|
percentile(errors, 95) * 1000.0, max(errors, default=0.0) * 1000.0,
|
||||||
|
late, 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def whole_frame_result(aligned, frames, high, rate, policy_def):
|
||||||
|
schedule = schedule_video_frames(frames, high, policy_def.scheduler_policy, rate * 1000.0)
|
||||||
|
publications = receive_whole_frames(schedule)
|
||||||
|
video = video_metrics(aligned, schedule, publications, rate, policy_def.name)
|
||||||
|
control = control_metrics(schedule, rate, policy_def.name)
|
||||||
|
prediction = prediction_metrics(schedule, rate, policy_def.name)
|
||||||
|
source_end = aligned.lab033.metadata.duration_seconds
|
||||||
|
qp, qmax, qb, qbmax, fq, fqmax, remaining, drain = queue_metrics(schedule, frames, source_end)
|
||||||
|
capacity = theoretical_capacity(aligned, rate)
|
||||||
|
offered_bytes = sum(item.wire_size_bytes for item in aligned.packets)
|
||||||
|
summary = SummaryMetrics(
|
||||||
|
rate, policy_def.name,
|
||||||
|
offered_bytes * 8.0 / source_end / 1000.0,
|
||||||
|
offered_bytes * 8.0 / source_end / (rate * 1000.0),
|
||||||
|
len(schedule.transmitted), sum(len(item.wire_packet) for item in schedule.transmitted),
|
||||||
|
sum(len(item.frame.packets) for item in schedule.dropped_frames),
|
||||||
|
sum(item.frame.wire_size_bytes for item in schedule.dropped_frames), 0,
|
||||||
|
qp, qmax, qb, qbmax, fq, fqmax, remaining, drain,
|
||||||
|
capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
|
||||||
|
capacity.maximum_update_fps,
|
||||||
|
)
|
||||||
|
return ScenarioResult(summary, video, control, prediction, publications, schedule)
|
||||||
|
|
||||||
|
|
||||||
|
def reactive_result(aligned, rate):
|
||||||
|
old_policy = Lab034Policy("aligned_1500ms", "По кадрам, 1500 мс", "aligned", 1500)
|
||||||
|
old = simulate_aligned(aligned, rate, old_policy)
|
||||||
|
capacity = theoretical_capacity(aligned, rate)
|
||||||
|
s, v, c = old.summary, old.video, old.control
|
||||||
|
source_end = aligned.lab033.metadata.duration_seconds
|
||||||
|
dropped_frames = set(old.dropped_frames)
|
||||||
|
partial_frames = {
|
||||||
|
frame_id for frame_id in dropped_frames
|
||||||
|
if old.transmitted_video_by_frame.get(frame_id, 0) > 0
|
||||||
|
}
|
||||||
|
before_start_frames = dropped_frames - partial_frames
|
||||||
|
dropped_before_packets = [
|
||||||
|
item for item in old.schedule.dropped_video
|
||||||
|
if item.item.composite_frame_id in before_start_frames
|
||||||
|
]
|
||||||
|
first_start = {}
|
||||||
|
for item in old.schedule.transmitted:
|
||||||
|
if item.item.composite_frame_id is not None:
|
||||||
|
first_start.setdefault(item.item.composite_frame_id, item.start_seconds)
|
||||||
|
frame_drop_time = {}
|
||||||
|
for item in old.schedule.dropped_video:
|
||||||
|
assert item.item.composite_frame_id is not None
|
||||||
|
frame_drop_time.setdefault(item.item.composite_frame_id, item.drop_time_seconds)
|
||||||
|
frame_events = {}
|
||||||
|
frame_area = 0.0
|
||||||
|
for frame_id in range(len(aligned.lab033.composites)):
|
||||||
|
start = frame_id / COMPOSITE_FPS
|
||||||
|
end = first_start.get(frame_id, frame_drop_time.get(frame_id, start))
|
||||||
|
left, right = max(0.0, start), min(source_end, end)
|
||||||
|
if right <= left + TIME_EPSILON_SECONDS:
|
||||||
|
continue
|
||||||
|
frame_area += right - left
|
||||||
|
frame_events[left] = frame_events.get(left, 0) + 1
|
||||||
|
frame_events[right] = frame_events.get(right, 0) - 1
|
||||||
|
waiting = max_waiting = 0
|
||||||
|
for time in sorted(frame_events):
|
||||||
|
waiting += frame_events[time]
|
||||||
|
max_waiting = max(max_waiting, waiting)
|
||||||
|
publication_events = sorted(old.publication_times.values())
|
||||||
|
publication_gaps = [
|
||||||
|
right - left for left, right in zip(publication_events, publication_events[1:])
|
||||||
|
]
|
||||||
|
summary = SummaryMetrics(
|
||||||
|
rate, "reactive_1500ms", s.offered_load_kbps, s.offered_to_capacity_ratio,
|
||||||
|
s.transmitted_packets, s.transmitted_bytes,
|
||||||
|
len(dropped_before_packets),
|
||||||
|
sum(item.item.wire_size_bytes for item in dropped_before_packets),
|
||||||
|
s.wasted_transmitted_bytes, s.mean_queue_packets, s.max_queue_packets,
|
||||||
|
s.mean_queue_bytes, s.max_queue_bytes,
|
||||||
|
frame_area / source_end, max_waiting,
|
||||||
|
s.queue_at_source_end_packets, s.additional_drain_seconds,
|
||||||
|
capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
|
||||||
|
capacity.maximum_update_fps,
|
||||||
|
)
|
||||||
|
video = VideoMetrics(
|
||||||
|
rate, "reactive_1500ms", v.created_frames,
|
||||||
|
v.published_frames + v.partially_transmitted_cancelled_frames,
|
||||||
|
v.published_frames,
|
||||||
|
v.intentionally_dropped_frames - v.partially_transmitted_cancelled_frames,
|
||||||
|
v.partially_transmitted_cancelled_frames,
|
||||||
|
v.published_fraction, v.actual_update_fps,
|
||||||
|
v.mean_publication_delay_ms, v.p95_publication_delay_ms, v.max_publication_delay_ms,
|
||||||
|
v.mean_display_age_ms, v.p95_display_age_ms, v.max_display_age_ms,
|
||||||
|
v.display_age_over_500ms_fraction, v.display_age_over_1000ms_fraction,
|
||||||
|
v.mean_no_update_duration_ms, v.p95_no_update_duration_ms, v.max_no_update_duration_ms,
|
||||||
|
v.mean_missing_run_frames, v.p95_missing_run_frames, v.max_missing_run_frames,
|
||||||
|
float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
|
||||||
|
max(publication_gaps, default=0.0) * 1000.0,
|
||||||
|
sum(len(item.wire_packet) for item in old.schedule.transmitted if item.item.packet.traffic_class is TrafficClass.VIDEO),
|
||||||
|
0, v.wasted_transmitted_video_bytes, v.delivered_useful_video_kbps,
|
||||||
|
)
|
||||||
|
control = ControlMetrics(
|
||||||
|
rate, "reactive_1500ms", c.control_p95_age_ms, c.control_max_age_ms,
|
||||||
|
c.control_deadline_misses, c.control_max_receive_gap_ms,
|
||||||
|
c.emergency_total_delay_ms, c.emergency_deadline_met,
|
||||||
|
c.emergency_blocker_class, c.emergency_blocking_delay_ms,
|
||||||
|
c.telemetry_deadline_misses,
|
||||||
|
)
|
||||||
|
prediction = PredictionMetrics(rate, "reactive_1500ms", 0, 0, 0.0, 0.0, 0.0, 0, 0)
|
||||||
|
return ScenarioResult(summary, video, control, prediction, old.publication_times, None)
|
||||||
|
|
||||||
|
|
||||||
|
def run_experiment(aligned, frames, high):
|
||||||
|
results = []
|
||||||
|
for rate in CHANNEL_RATES_KBPS:
|
||||||
|
for policy in POLICIES:
|
||||||
|
results.append(
|
||||||
|
reactive_result(aligned, rate)
|
||||||
|
if policy.reactive
|
||||||
|
else whole_frame_result(aligned, frames, high, rate, policy)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def run_functional_tests(aligned, frames, high, results):
|
||||||
|
lookup = {(r.summary.channel_kbps, r.summary.policy): r for r in results}
|
||||||
|
checks = []
|
||||||
|
def check(name):
|
||||||
|
def decorator(function): checks.append((name, function)); return function
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
whole = [result for result in results if result.schedule is not None]
|
||||||
|
|
||||||
|
@check("01_video_frames_do_not_interleave")
|
||||||
|
def _():
|
||||||
|
for result in whole:
|
||||||
|
sequence = [item.item.composite_frame_id for item in result.schedule.transmitted if item.item.composite_frame_id is not None]
|
||||||
|
compressed = [frame for index, frame in enumerate(sequence) if index == 0 or frame != sequence[index - 1]]
|
||||||
|
assert len(compressed) == len(set(compressed))
|
||||||
|
|
||||||
|
@check("02_started_frame_never_dropped")
|
||||||
|
def _():
|
||||||
|
for result in whole:
|
||||||
|
assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})
|
||||||
|
|
||||||
|
@check("03_high_priority_between_frame_packets")
|
||||||
|
def _():
|
||||||
|
assert any(
|
||||||
|
any(item.item.packet.traffic_class is not TrafficClass.VIDEO for item in result.schedule.transmitted[left + 1:right])
|
||||||
|
for result in whole
|
||||||
|
for left, right in zip(
|
||||||
|
[i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][:-1],
|
||||||
|
[i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][1:],
|
||||||
|
)
|
||||||
|
if result.schedule.transmitted[left].item.composite_frame_id == result.schedule.transmitted[right].item.composite_frame_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@check("04_latest_drops_only_unstarted")
|
||||||
|
def _():
|
||||||
|
for rate in CHANNEL_RATES_KBPS:
|
||||||
|
result = lookup[(rate, "latest_only")]
|
||||||
|
assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})
|
||||||
|
|
||||||
|
@check("05_two_waiting_limit")
|
||||||
|
def _(): assert all(lookup[(rate, "two_waiting")].summary.max_waiting_video_frames <= 2 for rate in CHANNEL_RATES_KBPS)
|
||||||
|
|
||||||
|
@check("06_prediction_is_pure")
|
||||||
|
def _():
|
||||||
|
ready = list(high[:2]); future = list(high[2:20]); ready_before=list(ready); future_before=list(future)
|
||||||
|
predict_frame_completion(0.0, frames[0], 230_000.0, ready, future)
|
||||||
|
assert ready == ready_before and future == future_before
|
||||||
|
|
||||||
|
@check("07_prediction_uses_actual_sizes")
|
||||||
|
def _():
|
||||||
|
small = VideoFrameGroup(999, 0, (frames[0].packets[0],))
|
||||||
|
full = predict_frame_completion(0.0, frames[0], 300_000.0, (), ())
|
||||||
|
one = predict_frame_completion(0.0, small, 300_000.0, (), ())
|
||||||
|
assert full > one and abs(one - small.wire_size_bytes * 8.0 / 300_000.0) < 1e-12
|
||||||
|
|
||||||
|
@check("08_prestart_drop_has_no_waste")
|
||||||
|
def _(): assert all(result.video.wasted_transmitted_video_bytes == 0 for result in whole)
|
||||||
|
|
||||||
|
@check("09_partial_only_reactive")
|
||||||
|
def _():
|
||||||
|
assert all(result.video.partially_transmitted_cancelled_frames == 0 for result in whole)
|
||||||
|
assert lookup[(230.0, "reactive_1500ms")].video.partially_transmitted_cancelled_frames > 0
|
||||||
|
|
||||||
|
@check("10_incomplete_not_published")
|
||||||
|
def _():
|
||||||
|
for result in whole:
|
||||||
|
dropped = {item.frame.composite_frame_id for item in result.schedule.dropped_frames}
|
||||||
|
assert not (dropped & set(result.publication_times))
|
||||||
|
|
||||||
|
@check("11_crc_layers_pass")
|
||||||
|
def _(): assert all(result.video.published_frames == len(result.publication_times) for result in results)
|
||||||
|
|
||||||
|
@check("12_emergency_never_deleted")
|
||||||
|
def _(): assert all(result.control.emergency_deadline_met for result in results)
|
||||||
|
|
||||||
|
@check("13_priority_above_video")
|
||||||
|
def _(): assert all(result.control.control_deadline_misses == 0 and result.control.telemetry_deadline_misses == 0 for result in results)
|
||||||
|
|
||||||
|
@check("14_300kbps_no_unnecessary_loss")
|
||||||
|
def _(): assert all(lookup[(300.0, policy.name)].video.published_frames == 63 for policy in POLICIES)
|
||||||
|
|
||||||
|
@check("15_230kbps_bounded_queue")
|
||||||
|
def _():
|
||||||
|
baseline = lookup[(230.0, "no_drop")].summary.max_queue_packets
|
||||||
|
assert all(lookup[(230.0, name)].summary.max_queue_packets < baseline for name in ("latest_only", "two_waiting", "predict_1000ms", "predict_500ms"))
|
||||||
|
|
||||||
|
@check("16_frame_accounting")
|
||||||
|
def _():
|
||||||
|
for result in results:
|
||||||
|
assert result.video.published_frames + result.video.dropped_before_start_frames + result.video.partially_transmitted_cancelled_frames == 63
|
||||||
|
|
||||||
|
@check("17_byte_accounting")
|
||||||
|
def _():
|
||||||
|
for result in whole:
|
||||||
|
assert result.summary.transmitted_bytes == sum(len(item.wire_packet) for item in result.schedule.transmitted)
|
||||||
|
assert result.summary.dropped_before_start_bytes == sum(item.frame.wire_size_bytes for item in result.schedule.dropped_frames)
|
||||||
|
|
||||||
|
@check("18_reproducible")
|
||||||
|
def _():
|
||||||
|
original = lookup[(230.0, "predict_1000ms")].schedule
|
||||||
|
repeated = schedule_video_frames(frames, high, FramePolicy.PREDICT_1000MS, 230_000.0)
|
||||||
|
assert [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in original.transmitted] == [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in repeated.transmitted]
|
||||||
|
|
||||||
|
@check("19_predict_1000_never_known_late")
|
||||||
|
def _(): assert all(lookup[(rate, "predict_1000ms")].prediction.published_after_deadline_frames == 0 for rate in CHANNEL_RATES_KBPS)
|
||||||
|
|
||||||
|
@check("20_command_delay_bound")
|
||||||
|
def _():
|
||||||
|
lab033 = {}
|
||||||
|
with Path("data/processed/lab033/lab033_summary.csv").open(encoding="utf-8") as file:
|
||||||
|
for row in csv.DictReader(file):
|
||||||
|
if row["scheduler"] == "latest_state": lab033[float(row["channel_kbps"])] = float(row["control_max_age_ms"])
|
||||||
|
max_video_bytes = max(packet.wire_size_bytes for frame in frames for packet in frame.packets)
|
||||||
|
for result in results:
|
||||||
|
bound = lab033[result.summary.channel_kbps] + max_video_bytes * 8.0 / (result.summary.channel_kbps * 1000.0) * 1000.0
|
||||||
|
assert result.control.control_max_delay_ms <= bound + 1e-9
|
||||||
|
|
||||||
|
output=[]
|
||||||
|
for name,function in checks:
|
||||||
|
try: function(); output.append(FunctionalTestResult(name,True,"PASS"))
|
||||||
|
except Exception as error: output.append(FunctionalTestResult(name,False,f"{type(error).__name__}: {error}"))
|
||||||
|
if not all(item.passed for item in output): raise AssertionError("functional checks failed: "+", ".join(item.name for item in output if not item.passed))
|
||||||
|
return tuple(output)
|
||||||
|
|
||||||
|
|
||||||
|
def save_csv(results):
|
||||||
|
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path, cls, rows in (
|
||||||
|
(SUMMARY_CSV_PATH, SummaryMetrics, (r.summary for r in results)),
|
||||||
|
(VIDEO_CSV_PATH, VideoMetrics, (r.video for r in results)),
|
||||||
|
(CONTROL_CSV_PATH, ControlMetrics, (r.control for r in results)),
|
||||||
|
(PREDICTION_CSV_PATH, PredictionMetrics, (r.prediction for r in results)),
|
||||||
|
):
|
||||||
|
with path.open("w",encoding="utf-8",newline="") as file:
|
||||||
|
writer=csv.DictWriter(file,fieldnames=list(cls.__dataclass_fields__)); writer.writeheader(); writer.writerows(asdict(row) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def grouped_plot(results,value,ylabel,title,path):
|
||||||
|
x=np.arange(len(CHANNEL_RATES_KBPS)); width=.13
|
||||||
|
fig,axis=plt.subplots(figsize=(12,5.5))
|
||||||
|
for index,policy in enumerate(POLICIES):
|
||||||
|
rows=[r for r in results if r.summary.policy==policy.name]
|
||||||
|
axis.bar(x+(index-2.5)*width,[value(r) for r in rows],width,label=policy.label)
|
||||||
|
axis.set_xticks(x,[f"{rate:.0f}" for rate in CHANNEL_RATES_KBPS]); axis.set_xlabel("Скорость, кбит/с"); axis.set_ylabel(ylabel); axis.set_title(title); axis.grid(axis="y",alpha=.3); axis.legend(fontsize=8); fig.tight_layout(); fig.savefig(path,dpi=150); plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def save_plots(results):
|
||||||
|
grouped_plot(results,lambda r:r.video.actual_update_fps,"Обновлений/с","Фактическая частота обновления",UPDATE_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.video.p95_display_age_ms,"P95 возраста, мс","Возраст отображаемого изображения",AGE_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.video.p95_publication_delay_ms,"P95 задержки, мс","Задержка публикации",PUBLICATION_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.video.published_frames,"Кадров","Опубликованные кадры",OUTCOME_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.summary.max_queue_packets,"Пакетов","Максимальный размер очереди",QUEUE_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.prediction.p95_absolute_error_ms,"P95 ошибки, мс","Точность прогноза",PREDICTION_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.control.control_p95_delay_ms,"P95, мс","Задержка команд",CONTROL_PLOT_PATH)
|
||||||
|
grouped_plot(results,lambda r:r.video.dropped_before_start_frames,"Кадров","Сравнение политик упреждающего удаления",COMPARISON_PLOT_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(aligned,results,tests):
|
||||||
|
git_status=subprocess.run(("git","status","--short","--branch"),check=True,capture_output=True,text=True,encoding="utf-8").stdout.rstrip()
|
||||||
|
capacities={rate:theoretical_capacity(aligned,rate) for rate in CHANNEL_RATES_KBPS}
|
||||||
|
lines=[
|
||||||
|
"Lab035. Упреждающий допуск видеокадров и обслуживание видео целыми кадрами","",
|
||||||
|
"1. Исходное состояние",f"- Commit Lab034: {LAB034_COMMIT}.","- Перед Lab035 рабочее дерево было чистым; main опережала origin/main на два commit.","",
|
||||||
|
"2. Правило обслуживания","- После первого видеопакета кадр становится активным и не удаляется.","- При повторном выборе видео передаётся следующий пакет активного кадра; команды и телеметрия могут передаваться между пакетами.","- Новый видеокадр начинается только после полного завершения активного; видеопакеты разных кадров не чередуются; отдельный пакет не прерывается.","- Только неактивные кадры могут быть удалены до передачи первого пакета.","",
|
||||||
|
"3. Теоретическая пропускная способность","speed | nonvideo kbps | remaining video kbps | remaining/aligned | minimum skip | maximum fps",
|
||||||
|
]
|
||||||
|
for rate in CHANNEL_RATES_KBPS:
|
||||||
|
c=capacities[rate]; lines.append(f"{rate:.0f} | {c.nonvideo_load_kbps:.3f} | {c.remaining_video_kbps:.3f} | {c.video_capacity_ratio:.6f} | {c.minimum_skip_fraction:.6f} | {c.maximum_update_fps:.3f}")
|
||||||
|
lines.extend(["","4. Восемнадцать сочетаний","speed | policy | published/drop/partial | fps | age P95 ms | no-update max ms | queue max/waiting frames | waste bytes | prediction MAE/P95/max ms | control P95/max ms | emergency ms"])
|
||||||
|
for r in results:
|
||||||
|
s,v,c,p=r.summary,r.video,r.control,r.prediction
|
||||||
|
lines.append(f"{s.channel_kbps:.0f} | {POLICY_BY_NAME[s.policy].label} | {v.published_frames}/{v.dropped_before_start_frames}/{v.partially_transmitted_cancelled_frames} | {v.actual_update_fps:.3f} | {v.p95_display_age_ms:.3f} | {v.max_no_update_duration_ms:.3f} | {s.max_queue_packets}/{s.max_waiting_video_frames} | {v.wasted_transmitted_video_bytes} | {p.mean_absolute_error_ms:.6f}/{p.p95_absolute_error_ms:.6f}/{p.max_absolute_error_ms:.6f} | {c.control_p95_delay_ms:.3f}/{c.control_max_delay_ms:.3f} | {c.emergency_delay_ms:.3f}")
|
||||||
|
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(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
|
||||||
|
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outputs():
|
||||||
|
for path in (SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH):
|
||||||
|
with path.open(encoding="utf-8",newline="") as file: rows=list(csv.DictReader(file))
|
||||||
|
if len(rows)!=18: raise AssertionError(f"{path} must contain 18 rows")
|
||||||
|
if "Lab035" not in REPORT_PATH.read_text(encoding="utf-8"): raise AssertionError("invalid report")
|
||||||
|
for path in PLOT_PATHS:
|
||||||
|
image=cv2.imread(str(path),cv2.IMREAD_UNCHANGED)
|
||||||
|
if image is None or image.size==0: raise AssertionError(f"OpenCV could not read {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
lab033=build_lab033_workload(); aligned=build_aligned_workload(lab033)
|
||||||
|
frames=build_frame_groups(aligned); high=high_priority_packets(aligned)
|
||||||
|
results=run_experiment(aligned,frames,high)
|
||||||
|
tests=run_functional_tests(aligned,frames,high,results)
|
||||||
|
save_csv(results); save_plots(results); write_report(aligned,results,tests); validate_outputs()
|
||||||
|
print(f"Lab035 complete: {len(results)} scenarios, {len(tests)} checks")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=="__main__": main()
|
||||||