diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 1244515..197d12a 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -213,3 +213,29 @@ git version 2.51.1.windows.1 - Подтверждено, что короткая очередь сама по себе не означает свежее изображение. - Текущая реактивная политика не принята. - Следующий этап посвящён упреждающему допуску кадров и обслуживанию видео целыми кадрами. + +--- + +# Запись 010 + +## Дата + +3 августа 2026 года + +## Тема + +Завершение Lab035: упреждающий допуск и обслуживание видео целыми составными кадрами. + +## Выполнено + +- Lab035 исследует упреждающий допуск и обслуживание видео целыми составными кадрами. +- Начатый кадр всегда передаётся полностью. +- Команды и телеметрия могут передаваться между его пакетами. +- Реактивное удаление Lab034 окончательно не принято. +- Основная политика — хранение только самого свежего не начатого кадра. +- При 230 кбит/с опубликовано 56 из 63 кадров с частотой около 2,6 кадра/с. +- Бесполезно переданных видеобайтов нет. +- Очередь из двух кадров увеличивает возраст изображения. +- Прогноз 1000 мс не дал преимущества над более простой политикой. +- Прогноз 500 мс чрезмерно снижает частоту обновления. +- Следующая лабораторная проверяет долговременную устойчивость и изменение пропускной способности канала. diff --git a/data/processed/lab035/lab035_control_delay.png b/data/processed/lab035/lab035_control_delay.png new file mode 100644 index 0000000..bfeb585 Binary files /dev/null and b/data/processed/lab035/lab035_control_delay.png differ diff --git a/data/processed/lab035/lab035_control_metrics.csv b/data/processed/lab035/lab035_control_metrics.csv new file mode 100644 index 0000000..4f28bbc --- /dev/null +++ b/data/processed/lab035/lab035_control_metrics.csv @@ -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 diff --git a/data/processed/lab035/lab035_frame_outcomes.png b/data/processed/lab035/lab035_frame_outcomes.png new file mode 100644 index 0000000..8cc29e3 Binary files /dev/null and b/data/processed/lab035/lab035_frame_outcomes.png differ diff --git a/data/processed/lab035/lab035_image_age.png b/data/processed/lab035/lab035_image_age.png new file mode 100644 index 0000000..6ad73c4 Binary files /dev/null and b/data/processed/lab035/lab035_image_age.png differ diff --git a/data/processed/lab035/lab035_policy_comparison.png b/data/processed/lab035/lab035_policy_comparison.png new file mode 100644 index 0000000..84b8e11 Binary files /dev/null and b/data/processed/lab035/lab035_policy_comparison.png differ diff --git a/data/processed/lab035/lab035_prediction_accuracy.png b/data/processed/lab035/lab035_prediction_accuracy.png new file mode 100644 index 0000000..020e6ab Binary files /dev/null and b/data/processed/lab035/lab035_prediction_accuracy.png differ diff --git a/data/processed/lab035/lab035_prediction_metrics.csv b/data/processed/lab035/lab035_prediction_metrics.csv new file mode 100644 index 0000000..c8f3f1c --- /dev/null +++ b/data/processed/lab035/lab035_prediction_metrics.csv @@ -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 diff --git a/data/processed/lab035/lab035_publication_delay.png b/data/processed/lab035/lab035_publication_delay.png new file mode 100644 index 0000000..cb6d202 Binary files /dev/null and b/data/processed/lab035/lab035_publication_delay.png differ diff --git a/data/processed/lab035/lab035_queue_size.png b/data/processed/lab035/lab035_queue_size.png new file mode 100644 index 0000000..0a46a03 Binary files /dev/null and b/data/processed/lab035/lab035_queue_size.png differ diff --git a/data/processed/lab035/lab035_report.txt b/data/processed/lab035/lab035_report.txt new file mode 100644 index 0000000..b837eae --- /dev/null +++ b/data/processed/lab035/lab035_report.txt @@ -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 diff --git a/data/processed/lab035/lab035_summary.csv b/data/processed/lab035/lab035_summary.csv new file mode 100644 index 0000000..82aaa70 --- /dev/null +++ b/data/processed/lab035/lab035_summary.csv @@ -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 diff --git a/data/processed/lab035/lab035_update_rate.png b/data/processed/lab035/lab035_update_rate.png new file mode 100644 index 0000000..2f94ecd Binary files /dev/null and b/data/processed/lab035/lab035_update_rate.png differ diff --git a/data/processed/lab035/lab035_video_metrics.csv b/data/processed/lab035/lab035_video_metrics.csv new file mode 100644 index 0000000..38a5aa6 --- /dev/null +++ b/data/processed/lab035/lab035_video_metrics.csv @@ -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 diff --git a/protocol/video_frame_scheduler.py b/protocol/video_frame_scheduler.py new file mode 100644 index 0000000..cbd0288 --- /dev/null +++ b/protocol/video_frame_scheduler.py @@ -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, + ) diff --git a/tests/lab035_video_frame_admission.py b/tests/lab035_video_frame_admission.py new file mode 100644 index 0000000..98ed805 --- /dev/null +++ b/tests/lab035_video_frame_admission.py @@ -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()