diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index aa6d4f6..a3aa8d2 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -117,3 +117,25 @@ git version 2.51.1.windows.1 - Режим 8+4 оставлен контрольным. - Подтверждено, что без перемежения продолжительные серии потерь не исправляются. - Следующая лабораторная посвящена перемежению пакетов. + +--- + +# Запись 006 + +## Дата + +29 июля 2026 года + +## Тема + +Завершение Lab031: перемежение блоков исправления ошибок. + +## Выполнено + +- Исследовано перемежение блоков исправления ошибок. +- Проверены глубины D=1, 2, 4 и 8. +- Подтверждено, что глубокое перемежение повышает задержку сильнее, чем полезность для операторского видеопотока. +- D=1 оставлен основным режимом. +- D=2 сохранён как резервный вариант. +- D=4 и D=8 исключены из основной линии разработки. +- Следующая лабораторная подбирает размер блока и количество проверочных пакетов. diff --git a/data/processed/lab031/lab031_block_recovery.png b/data/processed/lab031/lab031_block_recovery.png new file mode 100644 index 0000000..f60e0f4 Binary files /dev/null and b/data/processed/lab031/lab031_block_recovery.png differ diff --git a/data/processed/lab031/lab031_composite_success.png b/data/processed/lab031/lab031_composite_success.png new file mode 100644 index 0000000..5df9d43 Binary files /dev/null and b/data/processed/lab031/lab031_composite_success.png differ diff --git a/data/processed/lab031/lab031_depth_tradeoff.png b/data/processed/lab031/lab031_depth_tradeoff.png new file mode 100644 index 0000000..53e5e7c Binary files /dev/null and b/data/processed/lab031/lab031_depth_tradeoff.png differ diff --git a/data/processed/lab031/lab031_mode_comparison.png b/data/processed/lab031/lab031_mode_comparison.png new file mode 100644 index 0000000..482c9e7 Binary files /dev/null and b/data/processed/lab031/lab031_mode_comparison.png differ diff --git a/data/processed/lab031/lab031_no_image_duration.png b/data/processed/lab031/lab031_no_image_duration.png new file mode 100644 index 0000000..b6c345e Binary files /dev/null and b/data/processed/lab031/lab031_no_image_duration.png differ diff --git a/data/processed/lab031/lab031_publication_delay.png b/data/processed/lab031/lab031_publication_delay.png new file mode 100644 index 0000000..fe6f8a7 Binary files /dev/null and b/data/processed/lab031/lab031_publication_delay.png differ diff --git a/data/processed/lab031/lab031_report.txt b/data/processed/lab031/lab031_report.txt new file mode 100644 index 0000000..0f4534d --- /dev/null +++ b/data/processed/lab031/lab031_report.txt @@ -0,0 +1,93 @@ +Lab031. Перемежение пакетов между FEC-блоками + +Исходные данные и неизменные слои +- Видео: data\raw\lab026_rover_source.mp4 +- 63 реальных синхронных пар BASE/ROI, 3 fps; длительность источника 20.766667 с. +- BASE 240x135 grayscale JPEG Q23; ROI 320x180 grayscale JPEG Q33. +- Внутренний транспорт Lab028: payload 512 байт, packet CRC32, object CRC32, атомарная публикация BASE+ROI. +- Внешний формат и GF(256) Lab030 используются без изменений; k=8, parity r=2 или r=4. + +Точное правило перестановки +- Последовательные готовые FEC-блоки делятся на группы не более D блоков. +- Для symbol_index=0,1,2,... передаётся символ с этим индексом блока 0, затем блока 1, ... блока D-1. +- Если блок короче, отсутствующий symbol_index пропускается. Внутри блока сохраняется порядок systematic, затем parity. +- Перестановка не изменяет байты, block_id или symbol_index и не добавляет пакеты. + +Формирование и буферизация групп +- Полная группа D>1 выпускается после готовности D-го последовательного блока; все её пакеты получают это время доступности перед FIFO-передатчиком. +- Неполная последняя группа выпускается в момент окончания видеопотока; синтетические блоки и символы не добавляются. +- D=1 сохраняет порядок и времена доступности Lab030 и имеет нулевую добавочную задержку ожидания группы. +- Размер группы формируется в порядке возрастания block_id; полные группы имеют D блоков, последняя — остаток. +- Средний буфер — среднее заполнение после каждого прихода готового блока (1..размер группы); максимум включает момент непосредственно перед выпуском группы. + +Семь фиксированных режимов и статические метрики +mode | k+r | D | groups | final group | source/parity packets | outer kbit/s | overhead | wait mean/max s | buffer mean/max | queue mean/max | symbol gap/span ms +----:|----:|--:|-------:|------------:|----------------------:|-------------:|---------:|----------------:|----------------:|---------------:|-------------------: +Без FEC | none | 1 | 0 | 0 | 862/0 | 168.396 | 6.310% | 0.000000/0.000000 | 0.000/0 | 13.717/15 | 0.000/0.000 +8+2, D=1 | 8+2 | 1 | 108 | 1 | 862/216 | 226.951 | 30.483% | 0.000000/0.000000 | 1.000/1 | 17.210/21 | 19.547/175.558 +8+2, D=2 | 8+2 | 2 | 54 | 2 | 862/216 | 226.951 | 30.483% | 0.098765/0.333333 | 1.500/2 | 20.635/38 | 29.178/262.058 +8+2, D=4 | 8+2 | 4 | 27 | 4 | 862/216 | 226.951 | 30.483% | 0.290123/0.666667 | 2.500/4 | 39.929/40 | 58.283/523.467 +8+2, D=8 | 8+2 | 8 | 14 | 4 | 862/216 | 226.951 | 30.483% | 0.651852/1.666667 | 4.426/8 | 79.506/80 | 114.400/1027.482 +8+4, D=1 | 8+4 | 1 | 108 | 1 | 862/431 | 274.659 | 42.558% | 0.000000/0.000000 | 1.000/1 | 20.859/26 | 16.379/179.710 +8+4, D=4 | 8+4 | 4 | 27 | 4 | 862/431 | 274.659 | 42.558% | 0.290123/0.666667 | 2.500/4 | 51.026/57 | 58.817/645.351 + +Временная модель и статистика +- Непрерывные чередующиеся экспоненциальные Good/Bad интервалы Lab029B, средняя доля Bad около 2%. +- Средние Bad: 10, 50, 200, 1000 мс; 200 повторов на условие; seeds 310310...310313. +- Пакет теряется при любом пересечении интервала его передачи с Bad; скорость FIFO 300 кбит/с. +- Учитываются фактическая длина каждого внутреннего и внешнего пакета, ожидание группы и накопление очереди. +- Приёмник группирует внешние символы по block_id, декодирует после любых k принятых символов и передаёт восстановленные внутренние пакеты в CompositeReassembler. +- Соседние composite_frame_id не смешиваются; неполный BASE+ROI кадр не публикуется. + +Результаты при средней Bad 200 мс +mode | composite | no-image mean/p95/max s | publication mean/p95/max s | affected blocks | all blocks | unrecoverable | delivered kbit/s | incomplete run mean/p95/max +----:|----------:|------------------------:|-------------------------:|----------------:|-----------:|--------------:|----------------:|------------------------: +Без FEC | 0.960079 | 0.780292/1.322480/2.001440 | 0.185041/0.203013/0.209680 | 0.000000 | 0.000000 | 0.000000 | 150.735 | 1.393/3.000/5 +8+2, D=1 | 0.963095 | 0.827615/1.346867/2.032160 | 0.246248/0.275067/0.464720 | 0.152235 | 0.971898 | 0.028102 | 150.452 | 1.520/3.000/5 +8+2, D=2 | 0.957143 | 1.026629/1.727381/2.670480 | 0.509783/0.670480/0.933093 | 0.225694 | 0.969028 | 0.030972 | 147.820 | 2.134/4.000/7 +8+2, D=4 | 0.958333 | 1.387432/2.317012/3.306267 | 0.923445/1.134053/1.536720 | 0.408333 | 0.967130 | 0.032870 | 147.993 | 3.017/5.000/10 +8+2, D=8 | 0.964921 | 2.088808/3.261775/4.919493 | 1.724311/2.252480/2.735360 | 0.644555 | 0.970231 | 0.029769 | 145.481 | 3.946/6.000/10 +8+4, D=1 | 0.964048 | 0.875715/1.383067/2.062880 | 0.295702/0.336507/0.481920 | 0.247453 | 0.976065 | 0.023935 | 150.049 | 1.647/3.000/5 +8+4, D=4 | 0.974841 | 1.338860/1.827840/2.774213 | 0.966677/1.152507/1.665707 | 0.669316 | 0.980741 | 0.019259 | 148.676 | 2.805/4.000/7 + +Когда 8+2 с перемежением превосходит 8+4 без него +- Критерий ниже: строго более высокая доля атомарно восстановленных составных кадров; задержки приведены рядом как цена перемежения. +- Bad 50 мс, 8+2, D=4: composite 0.972063 > 0.958175; P95 no-image 1.660629 с против 1.017307 с; P95 публикации 1.150264 с против 0.338693 с. +- Bad 50 мс, 8+2, D=8: composite 0.989603 > 0.958175; P95 no-image 2.456107 с против 1.017307 с; P95 публикации 2.271840 с против 0.338693 с. +- Bad 200 мс, 8+2, D=8: composite 0.964921 > 0.964048; P95 no-image 3.261775 с против 1.383067 с; P95 публикации 2.252480 с против 0.336507 с. + +Функциональные проверки +- PASS d1_matches_lab030: D=1 wire order is byte-identical to Lab030 +- PASS permutation_preserves_packets: all 1078 packets preserved +- PASS each_packet_once: every protected-mode packet appears exactly once +- PASS identifiers_preserved: block_id and symbol_index survived permutation +- PASS inverse_distribution: inverse grouping passed for D=[2, 4, 8] +- PASS decoded_inner_byte_exact: decoded inner packets are byte-exact +- PASS inner_outer_crc: outer and recovered Lab028 CRC32 checks passed +- PASS zero_bad_100_percent: all seven modes restored 100% of frames without Bad +- PASS fixed_seed_reproducibility: identical seed produced identical result fields +- PASS atomic_incomplete_composite: missing final ROI fragment prevented publication +- PASS depth_increases_symbol_distance: D=1/2/4/8 strictly increase intrablock symbol spacing +- PASS group_wait_in_publication_delay: D=4 group wait 0.290123 s is reflected in publication delay +- PASS final_partial_group: final 4-block group emitted 38 packets at end of stream + +Допущения модели +- Видеопрофиль конечный и детерминированный; JPEG кодируются в памяти один раз перед Monte Carlo. +- Устройство знает конец потока и только тогда выпускает неполную последнюю группу D>1. +- Время готовности FEC-блока равно максимальному времени генерации его исходных пакетов; вычислительная задержка GF(256) считается нулевой. +- D=1 определён как точный режим Lab030; поэтому он не ждёт завершения блока перед отправкой уже готовых systematic symbols. +- Передатчик и канал общие FIFO; параллельные радиоканалы, ARQ, повторные передачи, команды, телеметрия, модуляция и реальный SDR не моделируются. +- Потеря является стиранием полного внешнего пакета; отдельные битовые ошибки не добавляются. +- Publication delay считается от frame_id/3 до конца пакета, завершившего атомарную сборку; no-image включает время до следующего опубликованного кадра или конца расписания. +- Intrablock symbol gap измеряется между временами конца последовательных symbol_index; span — от первого до последнего символа блока. + +Артефакты +- CSV: data\processed\lab031\lab031_results.csv +- Отчёт: data\processed\lab031\lab031_report.txt +- Доля восстановленных кадров: data\processed\lab031\lab031_composite_success.png +- Отсутствие нового изображения: data\processed\lab031\lab031_no_image_duration.png +- Задержка публикации: data\processed\lab031\lab031_publication_delay.png +- Доля восстановленных блоков: data\processed\lab031\lab031_block_recovery.png +- Устойчивость/задержка от D: data\processed\lab031\lab031_depth_tradeoff.png +- Итоговое сравнение: data\processed\lab031\lab031_mode_comparison.png +- JPEG, внешние пакеты и бинарные дампы на диск не сохранялись. diff --git a/data/processed/lab031/lab031_results.csv b/data/processed/lab031/lab031_results.csv new file mode 100644 index 0000000..03886a3 --- /dev/null +++ b/data/processed/lab031/lab031_results.csv @@ -0,0 +1,29 @@ +mode,label,source_block_size,nominal_parity_count,interleaving_depth,mean_bad_duration_ms,mean_good_duration_ms,target_bad_time_fraction,actual_bad_time_fraction,monte_carlo_repetitions,seed,source_jpeg_bitrate_kbps,inner_packet_stream_bitrate_kbps,outer_fec_stream_bitrate_kbps,service_and_parity_percent,control_stream_bitrate_kbps,schedule_duration_seconds,interleaving_group_count,final_group_block_count,mean_group_wait_delay_seconds,p95_group_wait_delay_seconds,max_group_wait_delay_seconds,mean_queue_length_packets,max_queue_length_packets,mean_buffered_blocks,max_buffered_blocks,mean_intrablock_symbol_gap_seconds,mean_intrablock_symbol_span_seconds,max_intrablock_symbol_span_seconds,transmitted_source_packets,transmitted_parity_packets,lost_source_packets,lost_parity_packets,recovered_source_packets,fec_recovered_blocks,fec_unrecoverable_blocks,fec_affected_block_recovery_rate,fec_all_block_success_rate,fec_unrecoverable_block_rate,base_objects_completed,roi_objects_completed,atomic_composite_frames_completed,composite_success_rate,base_only_frames,roi_only_frames,incomplete_frames,effective_delivered_video_bitrate_kbps,mean_publication_delay_seconds,p95_publication_delay_seconds,max_publication_delay_seconds,mean_no_new_image_duration_seconds,p95_no_new_image_duration_seconds,max_no_new_image_duration_seconds,mean_consecutive_incomplete_frames,p95_consecutive_incomplete_frames,max_consecutive_incomplete_frames +none,Без FEC,8,0,1,10.0,490.0,0.02,0.019635234668464615,200,310310,157.76975922953451,168.39601926163724,168.39601926163724,6.3102798265039075,300.0,20.869679999999992,0,0,0.0,0.0,0.0,13.7169373549884,15,0.0,0,0.0,0.0,0.0,172400,0,7830,0,0,0,0,0.0,0.0,0.0,10803,9768,8554,0.6788888888888889,2249,1214,4046,106.50128607625994,0.184895562310024,0.20301333333332394,0.20967999999999998,0.8071357862085417,1.3423520000000007,2.6819999999999897,1.4481030780243378,3.0,7 +8+2_D1,"8+2, D=1",8,2,1,10.0,490.0,0.02,0.01989276611622791,200,310310,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,20.974640000000022,108,1,0.0,0.0,0.0,17.209647495361782,21,1.0,1,0.0195466666666667,0.1755580246913583,0.2513600000000018,172400,43200,8234,2146,5581,4099,951,0.8116831683168317,0.9559722222222222,0.04402777777777778,12107,11879,11591,0.9199206349206349,516,288,1009,143.66791706556094,0.26030441549478167,0.3947733333333314,0.466560000000003,0.7235130475086914,1.0172773333333234,1.8285600000000155,1.1691772885283893,2.0,4 +8+2_D2,"8+2, D=2",8,2,2,10.0,490.0,0.02,0.020419285815139677,200,310310,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,54,2,0.09876543209876544,0.3333333333333339,0.3333333333333357,20.634508348794064,38,1.5,2,0.029177621993127328,0.2620582716049399,0.27303999999999107,172400,43200,8513,2185,7407,6267,424,0.9366312957704379,0.9803703703703703,0.01962962962962963,12377,12258,12143,0.9637301587301588,234,115,457,148.79314028873705,0.5255043553213112,0.67048,0.9482399999999895,0.7850378666666681,1.1956960000000163,1.693359999999986,1.406153846153846,2.0,4 +8+2_D4,"8+2, D=4",8,2,4,10.0,490.0,0.02,0.020419285815139677,200,310310,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,39.929499072356215,40,2.5,4,0.05828288659793857,0.5234666666666705,0.5352533333333276,172400,43200,8403,2263,7823,6862,242,0.9659346846846847,0.9887962962962963,0.011203703703703704,12453,12398,12316,0.9774603174603175,137,82,284,150.92200183006048,0.955103273790194,1.1987733333333424,1.5428266666666461,0.7946769333333339,1.3154093333333325,1.825786666666668,1.42,2.0,3 +8+2_D8,"8+2, D=8",8,2,8,10.0,490.0,0.02,0.019783831729573317,200,310310,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.729893333333457,14,4,0.6518518518518518,1.333333333333334,1.666666666666666,79.50649350649351,80,4.425925925925926,8,0.11440010996563674,1.0274824691358113,1.0638933333334109,172400,43200,8326,2151,7778,6865,228,0.9678556323135485,0.9894444444444445,0.010555555555555556,12465,12402,12335,0.9789682539682539,130,67,265,147.5914653975897,1.7776782315903348,2.299730666666638,2.73535999999996,0.6972641975308679,1.7551626666666769,2.463786666666663,1.402116402116402,2.0,3 +8+4_D1,"8+4, D=1",8,4,1,10.0,490.0,0.02,0.020272932566179635,200,310310,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.051440000000028,108,1,0.0,0.0,0.0,20.85924207269915,26,1.0,1,0.016378644163150546,0.17971012345679074,0.2513600000000018,172400,86200,8349,4418,7753,4836,157,0.9685559783697176,0.9927314814814815,0.007268518518518519,12505,12460,12416,0.9853968253968254,89,44,184,153.336186028129,0.3085311168384899,0.4101333333333308,0.4960266666666602,0.7172980645161281,1.0115039999999988,1.0841333333333387,1.1870967741935483,2.0,2 +8+4_D4,"8+4, D=4",8,4,4,10.0,490.0,0.02,0.02028471209220386,200,310310,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.48064000000025,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,51.02552204176334,57,2.5,4,0.05881681012658274,0.6453511111111162,0.6581333333333261,172400,86200,8379,4498,8345,7029,9,0.9987212276214834,0.9995833333333334,0.0004166666666666667,12593,12593,12589,0.9991269841269841,4,4,11,152.39649470406664,0.9972173421240931,1.2425066666666575,1.6595999999999744,0.6450566666666556,1.2836946666666527,1.5891466666666396,1.375,2.6499999999999995,3 +none,Без FEC,8,0,1,50.0,2450.0,0.02,0.01904752893886233,200,310311,157.76975922953451,168.39601926163724,168.39601926163724,6.3102798265039075,300.0,20.869679999999992,0,0,0.0,0.0,0.0,13.7169373549884,15,0.0,0,0.0,0.0,0.0,172400,0,4146,0,0,0,0,0.0,0.0,0.0,12068,11771,11478,0.910952380952381,590,293,1122,143.00240540343697,0.1850171179647958,0.20301333333332394,0.20967999999999998,0.695712680514342,1.0000266666666668,1.3480800000000013,1.1097922848664687,2.0,3 +8+2_D1,"8+2, D=1",8,2,1,50.0,2450.0,0.02,0.019583724988700386,200,310311,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,20.974640000000022,108,1,0.0,0.0,0.0,17.209647495361782,21,1.0,1,0.0195466666666667,0.1755580246913583,0.2513600000000018,172400,43200,4350,1111,964,668,893,0.4279308135810378,0.9586574074074075,0.04134259259259259,12205,11976,11788,0.9355555555555556,417,188,812,146.1207896774389,0.24840877502545095,0.27522666666668627,0.45397333333333023,0.7019893258949336,1.0052693333333333,1.358746666666665,1.1324965132496514,2.0,3 +8+2_D2,"8+2, D=2",8,2,2,50.0,2450.0,0.02,0.020611137780154415,200,310311,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,54,2,0.09876543209876544,0.3333333333333339,0.3333333333333357,20.634508348794064,38,1.5,2,0.029177621993127328,0.2620582716049399,0.27303999999999107,172400,43200,4525,1128,1855,1386,762,0.6452513966480447,0.9647222222222223,0.035277777777777776,12202,12084,11923,0.9462698412698413,279,161,677,146.11615606798344,0.5131737180239886,0.67048,0.9482399999999895,0.8504971698113227,1.2140000000000006,2.1241066666666635,1.596698113207547,2.0,4 +8+2_D4,"8+2, D=4",8,2,4,50.0,2450.0,0.02,0.020611137780154415,200,310311,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,39.929499072356215,40,2.5,4,0.05828288659793857,0.5234666666666705,0.5352533333333276,172400,43200,4598,1155,3293,2645,445,0.8559870550161812,0.9793981481481482,0.02060185185185185,12377,12308,12248,0.9720634920634921,129,60,352,150.10497334094154,0.9336452906597018,1.1502639999999997,1.5428266666666461,1.0794554732510333,1.6606293333333362,3.3212533333333107,2.1728395061728394,3.0,8 +8+2_D8,"8+2, D=8",8,2,8,50.0,2450.0,0.02,0.019261484934125217,200,310311,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.729893333333457,14,4,0.6518518518518518,1.333333333333334,1.666666666666666,79.50649350649351,80,4.425925925925926,8,0.11440010996563674,1.0274824691358113,1.0638933333334109,172400,43200,4342,1090,3932,3450,161,0.9554140127388535,0.9925462962962963,0.007453703703703704,12508,12491,12469,0.9896031746031746,39,22,131,149.20466429655653,1.7412040548560521,2.271839999999967,2.73535999999996,1.0850114553990724,2.4561066666666633,3.22338666666667,1.8450704225352113,5.0,6 +8+4_D1,"8+4, D=1",8,4,1,50.0,2450.0,0.02,0.019588623960636035,200,310311,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.051440000000028,108,1,0.0,0.0,0.0,20.85924207269915,26,1.0,1,0.016378644163150546,0.17971012345679074,0.2513600000000018,172400,86200,4310,2179,2061,996,474,0.6775510204081633,0.9780555555555556,0.021944444444444444,12327,12214,12073,0.9581746031746031,254,141,527,149.08571005118867,0.29811151495071847,0.3386933333333566,0.49543999999999855,0.738907046714174,1.017306666666654,1.3934133333333296,1.2517814726840855,2.0,3 +8+4_D4,"8+4, D=4",8,4,4,50.0,2450.0,0.02,0.018896970593780017,200,310311,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.48064000000025,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,51.02552204176334,57,2.5,4,0.05881681012658274,0.6453511111111162,0.6581333333333261,172400,86200,4201,2094,3982,2847,49,0.9830801104972375,0.9977314814814815,0.0022685185185185187,12572,12563,12555,0.9964285714285714,17,8,45,151.98650878185947,0.9761674020974541,1.2002133333333127,1.6510666666666776,1.098252631578969,1.6198666666666914,1.6359466666666824,2.3684210526315788,4.0,4 +none,Без FEC,8,0,1,200.0,9800.0,0.02,0.021768052926612245,200,310312,157.76975922953451,168.39601926163724,168.39601926163724,6.3102798265039075,300.0,20.869679999999992,0,0,0.0,0.0,0.0,13.7169373549884,15,0.0,0,0.0,0.0,0.0,172400,0,3950,0,0,0,0,0.0,0.0,0.0,12258,12177,12097,0.9600793650793651,161,80,503,150.73476737544615,0.18504139318287638,0.20301333333332394,0.20967999999999998,0.7802921514312094,1.3224799999999997,2.001439999999989,1.3933518005540166,3.0,5 +8+2_D1,"8+2, D=1",8,2,1,200.0,9800.0,0.02,0.019093901593380775,200,310312,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,20.974640000000022,108,1,0.0,0.0,0.0,17.209647495361782,21,1.0,1,0.0195466666666667,0.1755580246913583,0.2513600000000018,172400,43200,3550,907,161,109,607,0.15223463687150837,0.9718981481481481,0.028101851851851854,12292,12214,12135,0.9630952380952381,157,79,465,150.45183707563024,0.24624820546628343,0.27506666666666346,0.4647199999999989,0.8276151633986941,1.3468666666666853,2.0321599999999878,1.5196078431372548,3.0,5 +8+2_D2,"8+2, D=2",8,2,2,200.0,9800.0,0.02,0.019876605364394936,200,310312,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,54,2,0.09876543209876544,0.3333333333333339,0.3333333333333357,20.634508348794064,38,1.5,2,0.029177621993127328,0.2620582716049399,0.27303999999999107,172400,43200,3716,939,289,195,669,0.22569444444444445,0.9690277777777778,0.030972222222222224,12233,12146,12060,0.9571428571428572,173,86,540,147.81991309725186,0.5097829695964634,0.67048,0.9330933333333231,1.0266287220026378,1.7273813333333339,2.67048,2.1343873517786562,4.0,7 +8+2_D4,"8+2, D=4",8,2,4,200.0,9800.0,0.02,0.019876605364394936,200,310312,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,39.929499072356215,40,2.5,4,0.05828288659793857,0.5234666666666705,0.5352533333333276,172400,43200,3501,985,669,490,710,0.4083333333333333,0.9671296296296297,0.03287037037037037,12225,12144,12075,0.9583333333333334,150,69,525,147.99297450232626,0.9234452472049709,1.1340533333333287,1.5367199999999794,1.3874324904214599,2.3170120000000005,3.306266666666649,3.0172413793103448,5.0,10 +8+2_D8,"8+2, D=8",8,2,8,200.0,9800.0,0.02,0.020349130050647177,200,310312,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.729893333333457,14,4,0.6518518518518518,1.333333333333334,1.666666666666666,79.50649350649351,80,4.425925925925926,8,0.11440010996563674,1.0274824691358113,1.0638933333334109,172400,43200,3769,942,1577,1166,643,0.6445550027639579,0.9702314814814815,0.029768518518518517,12263,12201,12158,0.964920634920635,105,43,442,145.48062208619442,1.7243108340187623,2.252479999999979,2.73535999999996,2.0888080952381114,3.261774666666647,4.919493333333319,3.9464285714285716,6.0,10 +8+4_D1,"8+4, D=1",8,4,1,200.0,9800.0,0.02,0.019604503383780177,200,310312,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.051440000000028,108,1,0.0,0.0,0.0,20.85924207269915,26,1.0,1,0.016378644163150546,0.17971012345679074,0.2513600000000018,172400,86200,3604,1812,413,170,517,0.24745269286754004,0.9760648148148148,0.023935185185185184,12296,12229,12147,0.964047619047619,149,82,453,150.0487852612456,0.295702434071515,0.33650666666666273,0.48192000000000323,0.8757147151515156,1.3830666666666662,2.0628799999999847,1.6472727272727272,3.0,5 +8+4_D4,"8+4, D=4",8,4,4,200.0,9800.0,0.02,0.02040848762305882,200,310312,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.48064000000025,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,51.02552204176334,57,2.5,4,0.05881681012658274,0.6453511111111162,0.6581333333333261,172400,86200,3708,1890,1660,842,416,0.6693163751987281,0.9807407407407407,0.01925925925925926,12361,12328,12283,0.9748412698412698,78,45,317,148.67567260565622,0.9666773524383458,1.1525066666667954,1.6657066666666411,1.3388604129793618,1.827840000000003,2.7742133333332575,2.8053097345132745,4.0,7 +none,Без FEC,8,0,1,1000.0,49000.0,0.02,0.02189344857431169,200,310313,157.76975922953451,168.39601926163724,168.39601926163724,6.3102798265039075,300.0,20.869679999999992,0,0,0.0,0.0,0.0,13.7169373549884,15,0.0,0,0.0,0.0,0.0,172400,0,3802,0,0,0,0,0.0,0.0,0.0,12305,12289,12269,0.9737301587301588,36,20,331,152.87799908767172,0.18504089113483205,0.20301333333332394,0.20967999999999998,1.468867087719298,3.339362666666659,3.6820266666666566,3.4842105263157896,9.0,10 +8+2_D1,"8+2, D=1",8,2,1,1000.0,49000.0,0.02,0.02188397349572346,200,310313,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,20.974640000000022,108,1,0.0,0.0,0.0,17.209647495361782,21,1.0,1,0.0195466666666667,0.1755580246913583,0.2513600000000018,172400,43200,3809,957,36,26,522,0.04744525547445255,0.9758333333333333,0.024166666666666666,12303,12284,12260,0.973015873015873,43,24,340,152.00213400563712,0.24599688961392194,0.27506666666666346,0.43664000000000414,1.5143336170212773,3.4397426666666786,4.036026666666666,3.617021276595745,9.349999999999994,11 +8+2_D2,"8+2, D=2",8,2,2,1000.0,49000.0,0.02,0.022829618385024127,200,310313,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,54,2,0.09876543209876544,0.3333333333333339,0.3333333333333357,20.634508348794064,38,1.5,2,0.029177621993127328,0.2620582716049399,0.27303999999999107,172400,43200,3980,1016,69,46,557,0.07628524046434494,0.974212962962963,0.025787037037037035,12276,12253,12230,0.9706349206349206,46,23,370,149.90435921478297,0.5096241548105763,0.67048,0.9282666666666639,1.7479460606060633,3.630441333333325,4.003600000000002,4.204545454545454,10.0,11 +8+2_D4,"8+2, D=4",8,2,4,1000.0,49000.0,0.02,0.022829618385024127,200,310313,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.216053333333374,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,39.929499072356215,40,2.5,4,0.05828288659793857,0.5234666666666705,0.5352533333333276,172400,43200,4007,1050,98,77,615,0.11127167630057803,0.9715277777777778,0.02847222222222222,12259,12218,12198,0.9680952380952381,61,20,402,149.49624183951244,0.9220254096299959,1.1219999999999963,1.4781066666666742,2.195163636363639,4.007194666666669,5.646800000000001,5.220779220779221,10.0,15 +8+2_D8,"8+2, D=8",8,2,8,1000.0,49000.0,0.02,0.022771430179993328,200,310313,157.76975922953451,168.39601926163724,226.95139646869984,30.4830189704105,300.0,21.729893333333457,14,4,0.6518518518518518,1.333333333333334,1.666666666666666,79.50649350649351,80,4.425925925925926,8,0.11440010996563674,1.0274824691358113,1.0638933333334109,172400,43200,4017,1006,164,121,696,0.1481028151774786,0.9677777777777777,0.03222222222222222,12214,12171,12157,0.9648412698412698,57,14,443,145.4897597288397,1.7153148156069298,2.1772533333333026,2.73535999999996,3.137030303030318,4.9471199999999556,7.46471999999997,6.712121212121212,11.75,19 +8+4_D1,"8+4, D=1",8,4,1,1000.0,49000.0,0.02,0.02223910448390382,200,310313,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.051440000000028,108,1,0.0,0.0,0.0,20.85924207269915,26,1.0,1,0.016378644163150546,0.17971012345679074,0.2513600000000018,172400,86200,3857,1938,68,29,507,0.054104477611940295,0.9765277777777778,0.02347222222222222,12299,12278,12246,0.9719047619047619,53,32,354,151.28060028197575,0.2955365735750469,0.33650666666666273,0.34253333333332137,1.566540709219859,3.4289906666666816,4.075946666666653,3.765957446808511,9.349999999999994,11 +8+4_D4,"8+4, D=4",8,4,4,1000.0,49000.0,0.02,0.023171676201598494,200,310313,157.76975922953451,168.39601926163724,274.6586195826645,42.55787075997801,300.0,21.48064000000025,27,4,0.29012345679012347,0.666666666666667,0.6666666666666679,51.02552204176334,57,2.5,4,0.05881681012658274,0.6453511111111162,0.6581333333333261,172400,86200,4013,2044,279,134,568,0.1908831908831909,0.9737037037037037,0.026296296296296297,12272,12248,12223,0.9700793650793651,49,25,377,147.95045399019597,0.9617925004772583,1.1378933333333352,1.6425333333333096,2.2056548356807713,4.033480000000006,5.626773333333361,5.309859154929577,10.0,16 diff --git a/protocol/fec_interleaver.py b/protocol/fec_interleaver.py new file mode 100644 index 0000000..957c11b --- /dev/null +++ b/protocol/fec_interleaver.py @@ -0,0 +1,178 @@ +""" +Packet interleaving for consecutive packet-erasure FEC blocks. + +The interleaver is deliberately independent from GF(256). It accepts fully +encoded outer symbols from ``packet_erasure_fec`` and only changes their wire +order. A group contains up to ``depth`` consecutive blocks. Symbols are +emitted by symbol index across the blocks: + + block0[0], block1[0], ..., block0[1], block1[1], ... + +If a block is shorter than the others, its missing symbol index is skipped. +No packet is added, removed, or modified. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from protocol.packet_erasure_fec import ( + OuterSymbol, + decode_outer_symbol, +) + + +class InterleaverError(ValueError): + """An interleaving group or outer-symbol sequence is inconsistent.""" + + +def _decode_block( + wire_block: Sequence[bytes], +) -> tuple[int, tuple[bytes, ...], tuple[OuterSymbol, ...]]: + if not wire_block: + raise InterleaverError("FEC block is empty") + + packets = tuple(wire_block) + symbols = tuple(decode_outer_symbol(packet) for packet in packets) + first = symbols[0] + expected_count = first.source_count + first.parity_count + if len(symbols) != expected_count: + raise InterleaverError( + f"block {first.block_id} has {len(symbols)} symbols, " + f"expected {expected_count}" + ) + metadata = ( + first.block_id, + first.source_count, + first.parity_count, + first.symbol_size, + ) + for expected_index, symbol in enumerate(symbols): + current = ( + symbol.block_id, + symbol.source_count, + symbol.parity_count, + symbol.symbol_size, + ) + if current != metadata: + raise InterleaverError("outer symbols have inconsistent metadata") + if symbol.symbol_index != expected_index: + raise InterleaverError( + f"block {first.block_id} is not in symbol-index order" + ) + return first.block_id, packets, symbols + + +def interleave_group( + wire_blocks: Sequence[Sequence[bytes]], +) -> tuple[bytes, ...]: + """ + Interleave one non-empty group of complete consecutive FEC blocks. + + Source symbols remain before parity symbols inside every individual block + because each block is traversed by increasing ``symbol_index``. + """ + + if not wire_blocks: + raise InterleaverError("interleaving group is empty") + + decoded = tuple(_decode_block(block) for block in wire_blocks) + block_ids = tuple(item[0] for item in decoded) + if len(set(block_ids)) != len(block_ids): + raise InterleaverError("interleaving group repeats a block_id") + if any( + right != left + 1 + for left, right in zip(block_ids, block_ids[1:]) + ): + raise InterleaverError("FEC blocks are not consecutive") + + packets_by_block = tuple(item[1] for item in decoded) + maximum_symbols = max(len(block) for block in packets_by_block) + return tuple( + block[symbol_index] + for symbol_index in range(maximum_symbols) + for block in packets_by_block + if symbol_index < len(block) + ) + + +def interleave_blocks( + wire_blocks: Sequence[Sequence[bytes]], + depth: int, +) -> tuple[bytes, ...]: + """ + Interleave a stream in groups of ``depth`` blocks. + + The final group may contain fewer than ``depth`` blocks and is emitted + without synthetic padding. + """ + + if not isinstance(depth, int) or isinstance(depth, bool) or depth < 1: + raise ValueError("interleaving depth must be a positive integer") + blocks = tuple(tuple(block) for block in wire_blocks) + if not blocks: + return () + + block_ids = tuple(_decode_block(block)[0] for block in blocks) + if any( + right != left + 1 + for left, right in zip(block_ids, block_ids[1:]) + ): + raise InterleaverError("FEC block stream is not consecutive") + + result = [] + for start in range(0, len(blocks), depth): + group = blocks[start:start + depth] + result.extend(interleave_group(group)) + return tuple(result) + + +def deinterleave_symbols( + wire_symbols: Sequence[bytes], +) -> tuple[tuple[bytes, ...], ...]: + """ + Group received symbols by ``block_id`` and restore symbol-index order. + + Lost symbols are simply absent, so the returned blocks may be incomplete. + Blocks are returned in first-seen order. A conflicting duplicate symbol + is rejected; a byte-identical duplicate is retained only once. + """ + + block_order = [] + grouped: dict[int, dict[int, bytes]] = {} + metadata: dict[int, tuple[int, int, int]] = {} + for wire_packet in wire_symbols: + symbol = decode_outer_symbol(wire_packet) + if symbol.block_id not in grouped: + block_order.append(symbol.block_id) + grouped[symbol.block_id] = {} + metadata[symbol.block_id] = ( + symbol.source_count, + symbol.parity_count, + symbol.symbol_size, + ) + current_metadata = ( + symbol.source_count, + symbol.parity_count, + symbol.symbol_size, + ) + if current_metadata != metadata[symbol.block_id]: + raise InterleaverError( + f"block {symbol.block_id} metadata changed" + ) + existing = grouped[symbol.block_id].get(symbol.symbol_index) + if existing is not None and existing != wire_packet: + raise InterleaverError( + f"conflicting duplicate block={symbol.block_id}, " + f"symbol={symbol.symbol_index}" + ) + grouped[symbol.block_id][symbol.symbol_index] = wire_packet + + return tuple( + tuple( + symbols[index] + for index in sorted(symbols) + ) + for block_id in block_order + for symbols in (grouped[block_id],) + ) diff --git a/tests/lab031_fec_interleaving.py b/tests/lab031_fec_interleaving.py new file mode 100644 index 0000000..67c2b6a --- /dev/null +++ b/tests/lab031_fec_interleaving.py @@ -0,0 +1,1576 @@ +""" +Lab031. Packet interleaving between packet-erasure FEC blocks. + +The Lab028 512-byte-payload transport, Lab030 outer packet format and +GF(256) implementation, and Lab029B continuous-time channel are reused +unchanged. Complete consecutive FEC blocks are buffered in groups and sent +by symbol index across the group. Seven fixed modes compare no FEC, 8+2 at +depths 1/2/4/8, and 8+4 at depths 1/4. +""" + +from __future__ import annotations + +import csv +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import Callable + +import cv2 +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from protocol.fec_interleaver import ( + deinterleave_symbols, + interleave_blocks, + interleave_group, +) +from protocol.packet_erasure_fec import ( + decode_fec_block, + decode_outer_symbol, + encode_fec_block, +) +from protocol.video_packet import ( + CompositeReassembler, + decode_packet as decode_inner_packet, +) +from tests.lab028_video_packetization import ( + COMPOSITE_FPS, + SOURCE_VIDEO_PATH, + EncodedComposite, + VideoMetadata, + load_video_profile, +) +from tests.lab029_packet_channel_simulation import ( + PreparedProfile, + prepare_profiles, +) +from tests.lab029b_time_based_burst_simulation import ( + BAD_TIME_FRACTION, + CONTROL_STREAM_BITRATE_BPS, + CONTROL_STREAM_BITRATE_KBPS, + MEAN_BAD_DURATIONS_SECONDS, + generate_bad_intervals, + percentile, +) +from tests.lab030_packet_erasure_fec import ( + FECBlockPlan, + FECMode, + FunctionalTestResult, + ModeSchedule, + RepetitionResult, + SourcePacket, + TransmissionUnit, + build_mode_units, + overlap_loss_flags, + prepare_source_packets, + schedule_units, + simulate_baseline, + simulate_fec, +) + + +OUTPUT_DIRECTORY = Path("data/processed/lab031") +CSV_PATH = OUTPUT_DIRECTORY / "lab031_results.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab031_report.txt" +COMPOSITE_SUCCESS_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab031_composite_success.png" +) +NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab031_no_image_duration.png" +PUBLICATION_DELAY_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab031_publication_delay.png" +) +BLOCK_RECOVERY_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab031_block_recovery.png" +) +DEPTH_TRADEOFF_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab031_depth_tradeoff.png" +) +MODE_COMPARISON_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab031_mode_comparison.png" +) + +INNER_PAYLOAD_SIZE = 512 +SOURCE_BLOCK_SIZE = 8 +MONTE_CARLO_REPETITIONS = 200 +MASTER_SEED = 310_310 +SEED_BASE = MASTER_SEED + + +@dataclass(frozen=True) +class InterleavingMode: + name: str + parity_count: int + depth: int + label: str + + +MODES = ( + InterleavingMode("none", 0, 1, "Без FEC"), + InterleavingMode("8+2_D1", 2, 1, "8+2, D=1"), + InterleavingMode("8+2_D2", 2, 2, "8+2, D=2"), + InterleavingMode("8+2_D4", 2, 4, "8+2, D=4"), + InterleavingMode("8+2_D8", 2, 8, "8+2, D=8"), + InterleavingMode("8+4_D1", 4, 1, "8+4, D=1"), + InterleavingMode("8+4_D4", 4, 4, "8+4, D=4"), +) + + +@dataclass(frozen=True) +class InterleavingGroupPlan: + group_index: int + block_ids: tuple[int, ...] + ready_seconds: tuple[float, ...] + release_seconds: float + unit_count: int + + +@dataclass(frozen=True) +class InterleavedSchedule: + mode: InterleavingMode + fec_schedule: ModeSchedule + groups: tuple[InterleavingGroupPlan, ...] + group_wait_delays_seconds: tuple[float, ...] + mean_group_wait_delay_seconds: float + p95_group_wait_delay_seconds: float + max_group_wait_delay_seconds: float + mean_buffered_blocks: float + max_buffered_blocks: int + mean_intrablock_symbol_gap_seconds: float + mean_intrablock_symbol_span_seconds: float + max_intrablock_symbol_span_seconds: float + + +@dataclass(frozen=True) +class SimulationResult: + mode: str + label: str + source_block_size: int + nominal_parity_count: int + interleaving_depth: int + mean_bad_duration_ms: float + mean_good_duration_ms: float + target_bad_time_fraction: float + actual_bad_time_fraction: float + monte_carlo_repetitions: int + seed: int + source_jpeg_bitrate_kbps: float + inner_packet_stream_bitrate_kbps: float + outer_fec_stream_bitrate_kbps: float + service_and_parity_percent: float + control_stream_bitrate_kbps: float + schedule_duration_seconds: float + interleaving_group_count: int + final_group_block_count: int + mean_group_wait_delay_seconds: float + p95_group_wait_delay_seconds: float + max_group_wait_delay_seconds: float + mean_queue_length_packets: float + max_queue_length_packets: int + mean_buffered_blocks: float + max_buffered_blocks: int + mean_intrablock_symbol_gap_seconds: float + mean_intrablock_symbol_span_seconds: float + max_intrablock_symbol_span_seconds: float + transmitted_source_packets: int + transmitted_parity_packets: int + lost_source_packets: int + lost_parity_packets: int + recovered_source_packets: int + fec_recovered_blocks: int + fec_unrecoverable_blocks: int + fec_affected_block_recovery_rate: float + fec_all_block_success_rate: float + fec_unrecoverable_block_rate: float + base_objects_completed: int + roi_objects_completed: int + atomic_composite_frames_completed: int + composite_success_rate: float + base_only_frames: int + roi_only_frames: int + incomplete_frames: int + effective_delivered_video_bitrate_kbps: float + mean_publication_delay_seconds: float + p95_publication_delay_seconds: float + max_publication_delay_seconds: float + mean_no_new_image_duration_seconds: float + p95_no_new_image_duration_seconds: float + max_no_new_image_duration_seconds: float + mean_consecutive_incomplete_frames: float + p95_consecutive_incomplete_frames: float + max_consecutive_incomplete_frames: int + + +CSV_FIELDS = [ + field.name + for field in SimulationResult.__dataclass_fields__.values() +] + + +def _block_units( + units: tuple[TransmissionUnit, ...], +) -> tuple[tuple[TransmissionUnit, ...], ...]: + grouped: dict[int, list[TransmissionUnit]] = {} + order = [] + for unit in units: + if unit.block_id not in grouped: + order.append(unit.block_id) + grouped[unit.block_id] = [] + grouped[unit.block_id].append(unit) + return tuple(tuple(grouped[block_id]) for block_id in order) + + +def _symbol_timing( + schedule: ModeSchedule, +) -> tuple[float, float, float]: + endings: dict[int, list[tuple[int, float]]] = {} + for scheduled in schedule.units: + unit = scheduled.unit + endings.setdefault(unit.block_id, []).append( + (unit.symbol_index, scheduled.end_seconds) + ) + gaps = [] + spans = [] + for values in endings.values(): + ordered = [ + end for _, end in sorted(values) + ] + gaps.extend( + right - left + for left, right in zip(ordered, ordered[1:]) + ) + if len(ordered) > 1: + spans.append(ordered[-1] - ordered[0]) + return ( + float(np.mean(gaps)) if gaps else 0.0, + float(np.mean(spans)) if spans else 0.0, + max(spans) if spans else 0.0, + ) + + +def _make_mode_schedule( + metadata: VideoMetadata, + composites: list[EncodedComposite], + source_packets: tuple[SourcePacket, ...], + mode: InterleavingMode, +) -> InterleavedSchedule: + lab030_mode = FECMode(mode.name, mode.parity_count, mode.label) + sequential_units, blocks = build_mode_units( + source_packets, lab030_mode + ) + + groups = [] + waits = [] + buffer_samples = [] + if mode.parity_count == 0: + reordered_units = sequential_units + else: + plans_by_id = {plan.block_id: plan for plan in blocks} + units_by_block = _block_units(sequential_units) + reordered = [] + sequence_index = 0 + for group_index, start in enumerate( + range(0, len(units_by_block), mode.depth) + ): + group_units = units_by_block[start:start + mode.depth] + block_ids = tuple( + units[0].block_id for units in group_units + ) + ready_times = tuple( + max( + source_packets[index].generation_time_seconds + for index in plans_by_id[ + block_id + ].source_global_indices + ) + for block_id in block_ids + ) + complete_group = len(group_units) == mode.depth + release_time = max(ready_times) + if mode.depth > 1 and not complete_group: + release_time = max( + release_time, metadata.duration_seconds + ) + waits.extend( + release_time - ready for ready in ready_times + ) + buffer_samples.extend( + range(1, len(group_units) + 1) + ) + + wire_blocks = tuple( + tuple(unit.wire_packet for unit in units) + for units in group_units + ) + interleaved = interleave_group(wire_blocks) + lookup = { + (unit.block_id, unit.symbol_index): unit + for units in group_units + for unit in units + } + for wire_packet in interleaved: + parsed = decode_outer_symbol(wire_packet) + original = lookup[ + (parsed.block_id, parsed.symbol_index) + ] + generation_time = ( + original.generation_time_seconds + if mode.depth == 1 + else release_time + ) + reordered.append( + replace( + original, + sequence_index=sequence_index, + generation_time_seconds=generation_time, + ) + ) + sequence_index += 1 + groups.append( + InterleavingGroupPlan( + group_index=group_index, + block_ids=block_ids, + ready_seconds=ready_times, + release_seconds=release_time, + unit_count=len(interleaved), + ) + ) + reordered_units = tuple(reordered) + + scheduled, duration, mean_queue, max_queue = schedule_units( + reordered_units + ) + total_jpeg_bytes = sum( + len(composite.base_jpeg) + len(composite.roi_jpeg) + for composite in composites + ) + total_inner_bytes = sum( + len(packet.inner_packet) for packet in source_packets + ) + fec_schedule = ModeSchedule( + mode=lab030_mode, + source_packets=source_packets, + blocks=blocks, + units=scheduled, + source_duration_seconds=metadata.duration_seconds, + duration_seconds=duration, + total_jpeg_bytes=total_jpeg_bytes, + total_inner_bytes=total_inner_bytes, + total_transmitted_bytes=sum( + len(unit.unit.wire_packet) for unit in scheduled + ), + mean_queue_length_packets=mean_queue, + max_queue_length_packets=max_queue, + ) + if mode.parity_count: + symbol_gap, symbol_span, max_symbol_span = _symbol_timing( + fec_schedule + ) + else: + symbol_gap, symbol_span, max_symbol_span = 0.0, 0.0, 0.0 + return InterleavedSchedule( + mode=mode, + fec_schedule=fec_schedule, + groups=tuple(groups), + group_wait_delays_seconds=tuple(waits), + mean_group_wait_delay_seconds=( + float(np.mean(waits)) if waits else 0.0 + ), + p95_group_wait_delay_seconds=percentile(waits, 95), + max_group_wait_delay_seconds=max(waits) if waits else 0.0, + mean_buffered_blocks=( + float(np.mean(buffer_samples)) + if buffer_samples + else 0.0 + ), + max_buffered_blocks=( + max(buffer_samples) if buffer_samples else 0 + ), + mean_intrablock_symbol_gap_seconds=symbol_gap, + mean_intrablock_symbol_span_seconds=symbol_span, + max_intrablock_symbol_span_seconds=max_symbol_span, + ) + + +def build_schedules( + metadata: VideoMetadata, + composites: list[EncodedComposite], + profile: PreparedProfile, +) -> dict[str, InterleavedSchedule]: + source_packets = prepare_source_packets(profile) + return { + mode.name: _make_mode_schedule( + metadata, composites, source_packets, mode + ) + for mode in MODES + } + + +def _aggregate_condition( + schedule: InterleavedSchedule, + frame_count: int, + mean_bad_duration_seconds: float, + seed: int, + repetitions: int, +) -> SimulationResult: + fec_schedule = schedule.fec_schedule + rng = np.random.default_rng(seed) + repetition_results: list[RepetitionResult] = [] + bad_time_seconds = 0.0 + for _ in range(repetitions): + intervals = generate_bad_intervals( + fec_schedule.duration_seconds, + mean_bad_duration_seconds, + rng, + ) + bad_time_seconds += sum( + interval.duration_seconds for interval in intervals + ) + loss_flags = overlap_loss_flags(fec_schedule, intervals) + repetition_results.append( + simulate_baseline( + fec_schedule, loss_flags, frame_count + ) + if schedule.mode.parity_count == 0 + else simulate_fec( + fec_schedule, loss_flags, frame_count + ) + ) + + def total(field: str) -> int: + return sum( + int(getattr(result, field)) + for result in repetition_results + ) + + def flattened(field: str) -> list[float]: + return [ + float(value) + for result in repetition_results + for value in getattr(result, field) + ] + + publication_delays = flattened("publication_delays") + no_image = flattened("no_new_image_durations") + incomplete_runs = flattened("incomplete_frame_runs") + recovered_blocks = total("fec_recovered_blocks") + failed_blocks = total("fec_unrecoverable_blocks") + affected_blocks = recovered_blocks + failed_blocks + total_frames = frame_count * repetitions + source_packets_per_pass = len(fec_schedule.source_packets) + parity_packets_per_pass = sum( + int(unit.unit.is_parity) for unit in fec_schedule.units + ) + total_fec_blocks = len(fec_schedule.blocks) * repetitions + delivered_bytes = total("delivered_jpeg_bytes") + source_jpeg_rate = ( + fec_schedule.total_jpeg_bytes + * 8.0 + / fec_schedule.source_duration_seconds + / 1000.0 + ) + inner_rate = ( + fec_schedule.total_inner_bytes + * 8.0 + / fec_schedule.source_duration_seconds + / 1000.0 + ) + outer_rate = ( + fec_schedule.total_transmitted_bytes + * 8.0 + / fec_schedule.source_duration_seconds + / 1000.0 + ) + return SimulationResult( + mode=schedule.mode.name, + label=schedule.mode.label, + source_block_size=SOURCE_BLOCK_SIZE, + nominal_parity_count=schedule.mode.parity_count, + interleaving_depth=schedule.mode.depth, + mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0, + mean_good_duration_ms=( + mean_bad_duration_seconds + * (1.0 - BAD_TIME_FRACTION) + / BAD_TIME_FRACTION + * 1000.0 + ), + target_bad_time_fraction=BAD_TIME_FRACTION, + actual_bad_time_fraction=( + bad_time_seconds + / (fec_schedule.duration_seconds * repetitions) + ), + monte_carlo_repetitions=repetitions, + seed=seed, + source_jpeg_bitrate_kbps=source_jpeg_rate, + inner_packet_stream_bitrate_kbps=inner_rate, + outer_fec_stream_bitrate_kbps=outer_rate, + service_and_parity_percent=( + ( + fec_schedule.total_transmitted_bytes + - fec_schedule.total_jpeg_bytes + ) + / fec_schedule.total_transmitted_bytes + * 100.0 + ), + control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS, + schedule_duration_seconds=fec_schedule.duration_seconds, + interleaving_group_count=len(schedule.groups), + final_group_block_count=( + len(schedule.groups[-1].block_ids) + if schedule.groups + else 0 + ), + mean_group_wait_delay_seconds=( + schedule.mean_group_wait_delay_seconds + ), + p95_group_wait_delay_seconds=( + schedule.p95_group_wait_delay_seconds + ), + max_group_wait_delay_seconds=( + schedule.max_group_wait_delay_seconds + ), + mean_queue_length_packets=( + fec_schedule.mean_queue_length_packets + ), + max_queue_length_packets=( + fec_schedule.max_queue_length_packets + ), + mean_buffered_blocks=schedule.mean_buffered_blocks, + max_buffered_blocks=schedule.max_buffered_blocks, + mean_intrablock_symbol_gap_seconds=( + schedule.mean_intrablock_symbol_gap_seconds + ), + mean_intrablock_symbol_span_seconds=( + schedule.mean_intrablock_symbol_span_seconds + ), + max_intrablock_symbol_span_seconds=( + schedule.max_intrablock_symbol_span_seconds + ), + transmitted_source_packets=( + source_packets_per_pass * repetitions + ), + transmitted_parity_packets=( + parity_packets_per_pass * repetitions + ), + lost_source_packets=total("lost_source_packets"), + lost_parity_packets=total("lost_parity_packets"), + recovered_source_packets=total("recovered_source_packets"), + fec_recovered_blocks=recovered_blocks, + fec_unrecoverable_blocks=failed_blocks, + fec_affected_block_recovery_rate=( + recovered_blocks / affected_blocks + if affected_blocks + else 0.0 + ), + fec_all_block_success_rate=( + (total_fec_blocks - failed_blocks) / total_fec_blocks + if total_fec_blocks + else 0.0 + ), + fec_unrecoverable_block_rate=( + failed_blocks / total_fec_blocks + if total_fec_blocks + else 0.0 + ), + base_objects_completed=total("base_objects_completed"), + roi_objects_completed=total("roi_objects_completed"), + atomic_composite_frames_completed=total( + "atomic_composite_frames_completed" + ), + composite_success_rate=( + total("atomic_composite_frames_completed") / total_frames + ), + base_only_frames=total("base_only_frames"), + roi_only_frames=total("roi_only_frames"), + incomplete_frames=total("incomplete_frames"), + effective_delivered_video_bitrate_kbps=( + delivered_bytes + * 8.0 + / (fec_schedule.duration_seconds * repetitions) + / 1000.0 + ), + mean_publication_delay_seconds=( + float(np.mean(publication_delays)) + if publication_delays + else 0.0 + ), + p95_publication_delay_seconds=percentile( + publication_delays, 95 + ), + max_publication_delay_seconds=( + max(publication_delays) if publication_delays else 0.0 + ), + mean_no_new_image_duration_seconds=( + float(np.mean(no_image)) if no_image else 0.0 + ), + p95_no_new_image_duration_seconds=percentile(no_image, 95), + max_no_new_image_duration_seconds=( + max(no_image) if no_image else 0.0 + ), + mean_consecutive_incomplete_frames=( + float(np.mean(incomplete_runs)) + if incomplete_runs + else 0.0 + ), + p95_consecutive_incomplete_frames=percentile( + incomplete_runs, 95 + ), + max_consecutive_incomplete_frames=( + int(max(incomplete_runs)) if incomplete_runs else 0 + ), + ) + + +def run_monte_carlo( + schedules: dict[str, InterleavedSchedule], + frame_count: int, +) -> list[SimulationResult]: + results = [] + for duration_index, duration in enumerate( + MEAN_BAD_DURATIONS_SECONDS + ): + seed = SEED_BASE + duration_index + for mode in MODES: + results.append( + _aggregate_condition( + schedules[mode.name], + frame_count, + duration, + seed, + MONTE_CARLO_REPETITIONS, + ) + ) + return results + + +def result_lookup( + results: list[SimulationResult], + mean_bad_duration_ms: float, +) -> dict[str, SimulationResult]: + return { + result.mode: result + for result in results + if result.mean_bad_duration_ms == mean_bad_duration_ms + } + + +def run_functional_tests( + composites: list[EncodedComposite], + schedules: dict[str, InterleavedSchedule], +) -> list[FunctionalTestResult]: + tests: list[tuple[str, Callable[[], str]]] = [] + protected_modes = tuple( + mode for mode in MODES if mode.parity_count + ) + sequential_82, _ = build_mode_units( + schedules["8+2_D1"].fec_schedule.source_packets, + FECMode("lab030_8+2", 2, "8+2"), + ) + + def d1_matches_lab030_order() -> str: + actual = tuple( + unit.unit.wire_packet + for unit in schedules["8+2_D1"].fec_schedule.units + ) + expected = tuple( + unit.wire_packet for unit in sequential_82 + ) + if actual != expected: + raise AssertionError("D=1 changed Lab030 wire order") + return "D=1 wire order is byte-identical to Lab030" + + def permutation_preserves_multiset() -> str: + schedule = schedules["8+2_D8"] + original, _ = build_mode_units( + schedule.fec_schedule.source_packets, + FECMode("reference", 2, "8+2"), + ) + before = sorted(unit.wire_packet for unit in original) + after = sorted( + unit.unit.wire_packet + for unit in schedule.fec_schedule.units + ) + if before != after: + raise AssertionError("interleaving added or removed packets") + return f"all {len(before)} packets preserved" + + def every_packet_once() -> str: + for mode in protected_modes: + packets = [ + unit.unit.wire_packet + for unit in schedules[mode.name].fec_schedule.units + ] + if len(packets) != len(set(packets)): + raise AssertionError( + f"{mode.name} contains a duplicate packet" + ) + return "every protected-mode packet appears exactly once" + + def identifiers_are_preserved() -> str: + schedule = schedules["8+2_D4"].fec_schedule + for unit in schedule.units: + parsed = decode_outer_symbol(unit.unit.wire_packet) + if ( + parsed.block_id != unit.unit.block_id + or parsed.symbol_index != unit.unit.symbol_index + ): + raise AssertionError("outer identifiers changed") + return "block_id and symbol_index survived permutation" + + def inverse_distribution_works() -> str: + checked = [] + source_packets = schedules[ + "8+2_D1" + ].fec_schedule.source_packets[:64] + for depth in (2, 4, 8): + wire_blocks = tuple( + encode_fec_block( + tuple( + packet.inner_packet + for packet in source_packets[ + start:start + SOURCE_BLOCK_SIZE + ] + ), + start // SOURCE_BLOCK_SIZE, + 2, + ) + for start in range( + 0, len(source_packets), SOURCE_BLOCK_SIZE + ) + ) + interleaved = interleave_blocks(wire_blocks, depth) + restored = deinterleave_symbols(interleaved) + if restored != wire_blocks: + raise AssertionError( + f"inverse distribution failed for D={depth}" + ) + checked.append(depth) + return f"inverse grouping passed for D={checked}" + + def decoded_packets_are_exact() -> str: + source_packets = schedules[ + "8+2_D1" + ].fec_schedule.source_packets[:32] + wire_blocks = tuple( + encode_fec_block( + tuple( + packet.inner_packet + for packet in source_packets[ + start:start + SOURCE_BLOCK_SIZE + ] + ), + start // SOURCE_BLOCK_SIZE, + 2, + ) + for start in range(0, 32, SOURCE_BLOCK_SIZE) + ) + interleaved = interleave_blocks(wire_blocks, 4) + received = tuple( + packet + for packet in interleaved + if decode_outer_symbol(packet).symbol_index not in {1, 7} + ) + restored_blocks = deinterleave_symbols(received) + restored_packets = [] + for block in restored_blocks: + decoded = decode_fec_block(block) + restored_packets.extend(decoded.source_packets) + expected = [ + packet.inner_packet for packet in source_packets + ] + if restored_packets != expected: + raise AssertionError("decoded inner bytes differ") + return "decoded inner packets are byte-exact" + + def both_crc_layers_pass() -> str: + schedule = schedules["8+4_D4"].fec_schedule + first_block = [ + unit.unit.wire_packet + for unit in schedule.units + if unit.unit.block_id == 0 + and unit.unit.symbol_index not in {0, 2, 8, 10} + ] + for outer in first_block: + decode_outer_symbol(outer) + decoded = decode_fec_block(tuple(first_block)) + for inner in decoded.source_packets: + decode_inner_packet(inner) + return "outer and recovered Lab028 CRC32 checks passed" + + def zero_bad_restores_every_frame() -> str: + for mode in MODES: + schedule = schedules[mode.name].fec_schedule + flags = np.zeros(len(schedule.units), dtype=np.bool_) + result = ( + simulate_baseline(schedule, flags, len(composites)) + if mode.parity_count == 0 + else simulate_fec(schedule, flags, len(composites)) + ) + if ( + result.atomic_composite_frames_completed + != len(composites) + ): + raise AssertionError( + f"zero-Bad failed for {mode.name}" + ) + return "all seven modes restored 100% of frames without Bad" + + def fixed_seed_is_reproducible() -> str: + first = _aggregate_condition( + schedules["8+2_D4"], + len(composites), + 0.05, + 319_319, + 3, + ) + second = _aggregate_condition( + schedules["8+2_D4"], + len(composites), + 0.05, + 319_319, + 3, + ) + if first != second: + raise AssertionError("same seed changed the result") + return "identical seed produced identical result fields" + + def incomplete_composite_is_not_published() -> str: + receiver = CompositeReassembler() + first_frame = [ + packet.inner_packet + for packet in schedules[ + "none" + ].fec_schedule.source_packets + if packet.composite_frame_id == 0 + ] + for packet in first_frame[:-1]: + if receiver.ingest(packet) is not None: + raise AssertionError( + "incomplete composite was published" + ) + return "missing final ROI fragment prevented publication" + + def depth_increases_symbol_distance() -> str: + gaps = [ + schedules[f"8+2_D{depth}"].mean_intrablock_symbol_gap_seconds + for depth in (1, 2, 4, 8) + ] + if any( + right <= left + for left, right in zip(gaps, gaps[1:]) + ): + raise AssertionError( + f"symbol gaps are not strictly increasing: {gaps}" + ) + return "D=1/2/4/8 strictly increase intrablock symbol spacing" + + def group_wait_is_in_total_delay() -> str: + d1 = schedules["8+2_D1"] + d4 = schedules["8+2_D4"] + flags1 = np.zeros( + len(d1.fec_schedule.units), dtype=np.bool_ + ) + flags4 = np.zeros( + len(d4.fec_schedule.units), dtype=np.bool_ + ) + result1 = simulate_fec( + d1.fec_schedule, flags1, len(composites) + ) + result4 = simulate_fec( + d4.fec_schedule, flags4, len(composites) + ) + mean1 = float(np.mean(result1.publication_delays)) + mean4 = float(np.mean(result4.publication_delays)) + if d4.mean_group_wait_delay_seconds <= 0.0: + raise AssertionError("D=4 has no group wait") + if mean4 <= mean1: + raise AssertionError( + "group wait did not increase publication delay" + ) + return ( + f"D=4 group wait {d4.mean_group_wait_delay_seconds:.6f} s " + f"is reflected in publication delay" + ) + + def final_partial_group_is_transmitted() -> str: + schedule = schedules["8+2_D8"] + if not schedule.groups: + raise AssertionError("no interleaving groups") + final = schedule.groups[-1] + if len(final.block_ids) >= schedule.mode.depth: + raise AssertionError("test stream has no partial final group") + packets = [ + unit.unit.wire_packet + for unit in schedule.fec_schedule.units + if unit.unit.block_id in final.block_ids + ] + if len(packets) != final.unit_count: + raise AssertionError("partial final group lost packets") + if ( + final.release_seconds + < schedule.fec_schedule.source_duration_seconds + ): + raise AssertionError("partial group was released before stream end") + return ( + f"final {len(final.block_ids)}-block group emitted " + f"{len(packets)} packets at end of stream" + ) + + tests.extend( + [ + ("d1_matches_lab030", d1_matches_lab030_order), + ("permutation_preserves_packets", permutation_preserves_multiset), + ("each_packet_once", every_packet_once), + ("identifiers_preserved", identifiers_are_preserved), + ("inverse_distribution", inverse_distribution_works), + ("decoded_inner_byte_exact", decoded_packets_are_exact), + ("inner_outer_crc", both_crc_layers_pass), + ("zero_bad_100_percent", zero_bad_restores_every_frame), + ("fixed_seed_reproducibility", fixed_seed_is_reproducible), + ( + "atomic_incomplete_composite", + incomplete_composite_is_not_published, + ), + ( + "depth_increases_symbol_distance", + depth_increases_symbol_distance, + ), + ( + "group_wait_in_publication_delay", + group_wait_is_in_total_delay, + ), + ( + "final_partial_group", + final_partial_group_is_transmitted, + ), + ] + ) + results = [] + for name, test in tests: + try: + detail = test() + except Exception as error: + results.append(FunctionalTestResult(name, False, str(error))) + else: + results.append(FunctionalTestResult(name, True, detail)) + failed = [result for result in results if not result.passed] + if failed: + raise RuntimeError( + "Lab031 functional checks failed: " + + "; ".join( + f"{result.name}: {result.detail}" + for result in failed + ) + ) + return results + + +def validate_results(results: list[SimulationResult]) -> None: + expected = len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS) + if len(results) != expected: + raise RuntimeError( + f"expected {expected} result rows, got {len(results)}" + ) + keys = { + (result.mode, result.mean_bad_duration_ms) + for result in results + } + if len(keys) != expected: + raise RuntimeError("Lab031 result rows are not unique") + if any( + not 0.0 <= result.composite_success_rate <= 1.0 + for result in results + ): + raise RuntimeError("composite success is outside 0...1") + if any( + not 0.0 <= result.fec_unrecoverable_block_rate <= 1.0 + for result in results + ): + raise RuntimeError("unrecoverable block rate is outside 0...1") + + +def save_csv(results: list[SimulationResult]) -> None: + with CSV_PATH.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=CSV_FIELDS) + writer.writeheader() + for result in results: + writer.writerow(asdict(result)) + + +def _rows_for_mode( + results: list[SimulationResult], + mode_name: str, +) -> list[SimulationResult]: + return sorted( + ( + result + for result in results + if result.mode == mode_name + ), + key=lambda result: result.mean_bad_duration_ms, + ) + + +def _save_metric_plot( + results: list[SimulationResult], + field: str, + ylabel: str, + title: str, + path: Path, +) -> None: + figure, axis = plt.subplots(figsize=(10.8, 6.4)) + for mode in MODES: + rows = _rows_for_mode(results, mode.name) + axis.plot( + [row.mean_bad_duration_ms for row in rows], + [getattr(row, field) for row in rows], + marker="o", + linewidth=1.8, + label=mode.label, + ) + axis.set_xscale("log") + axis.set_xlabel("Средняя длительность Bad, мс") + axis.set_ylabel(ylabel) + axis.set_title(title) + axis.grid(True, which="both", alpha=0.28) + axis.legend(ncol=2, fontsize=8) + figure.tight_layout() + figure.savefig(path, dpi=160) + plt.close(figure) + + +def save_plots(results: list[SimulationResult]) -> None: + _save_metric_plot( + results, + "composite_success_rate", + "Доля восстановленных составных кадров", + "Lab031. Атомарное восстановление BASE + ROI", + COMPOSITE_SUCCESS_PLOT_PATH, + ) + _save_metric_plot( + results, + "p95_no_new_image_duration_seconds", + "P95 отсутствия нового изображения, с", + "Lab031. Длительность удержания последнего изображения", + NO_IMAGE_PLOT_PATH, + ) + _save_metric_plot( + results, + "p95_publication_delay_seconds", + "P95 задержки публикации, с", + "Lab031. Задержка публикации составного кадра", + PUBLICATION_DELAY_PLOT_PATH, + ) + + figure, axis = plt.subplots(figsize=(10.8, 6.4)) + for mode in MODES: + if not mode.parity_count: + continue + rows = _rows_for_mode(results, mode.name) + axis.plot( + [row.mean_bad_duration_ms for row in rows], + [row.fec_all_block_success_rate for row in rows], + marker="o", + linewidth=1.8, + label=mode.label, + ) + axis.set_xscale("log") + axis.set_xlabel("Средняя длительность Bad, мс") + axis.set_ylabel("Доля восстановимых FEC-блоков") + axis.set_title("Lab031. Восстановление FEC-блоков") + axis.grid(True, which="both", alpha=0.28) + axis.legend(ncol=2, fontsize=8) + figure.tight_layout() + figure.savefig(BLOCK_RECOVERY_PLOT_PATH, dpi=160) + plt.close(figure) + + representative = result_lookup(results, 200.0) + depth_rows = [ + representative[f"8+2_D{depth}"] + for depth in (1, 2, 4, 8) + ] + figure, success_axis = plt.subplots(figsize=(10.2, 6.2)) + delay_axis = success_axis.twinx() + depths = [row.interleaving_depth for row in depth_rows] + success_axis.plot( + depths, + [row.composite_success_rate for row in depth_rows], + color="#1565c0", + marker="o", + linewidth=2.2, + label="Составные кадры", + ) + delay_axis.plot( + depths, + [row.p95_publication_delay_seconds for row in depth_rows], + color="#c62828", + marker="s", + linewidth=2.0, + label="P95 публикации", + ) + delay_axis.plot( + depths, + [row.mean_group_wait_delay_seconds for row in depth_rows], + color="#ef6c00", + marker="^", + linestyle="--", + linewidth=1.8, + label="Среднее ожидание группы", + ) + success_axis.set_xticks(depths) + success_axis.set_xlabel("Глубина перемежения D") + success_axis.set_ylabel( + "Доля восстановленных составных кадров", + color="#1565c0", + ) + delay_axis.set_ylabel("Задержка, с", color="#c62828") + success_axis.set_title( + "Lab031. Устойчивость и задержка 8+2 при Bad 200 мс" + ) + success_axis.grid(True, alpha=0.28) + lines = success_axis.lines + delay_axis.lines + success_axis.legend( + lines, + [line.get_label() for line in lines], + loc="best", + ) + figure.tight_layout() + figure.savefig(DEPTH_TRADEOFF_PLOT_PATH, dpi=160) + plt.close(figure) + + rows = [representative[mode.name] for mode in MODES] + positions = np.arange(len(rows)) + figure, success_axis = plt.subplots(figsize=(12.0, 6.5)) + delay_axis = success_axis.twinx() + success_axis.bar( + positions - 0.2, + [row.composite_success_rate for row in rows], + width=0.4, + color="#2e7d32", + label="Составные кадры", + ) + delay_axis.bar( + positions + 0.2, + [row.p95_publication_delay_seconds for row in rows], + width=0.4, + color="#6a1b9a", + alpha=0.72, + label="P95 публикации", + ) + success_axis.set_xticks( + positions, + [mode.label for mode in MODES], + rotation=24, + ha="right", + ) + success_axis.set_ylabel("Доля восстановленных кадров") + delay_axis.set_ylabel("P95 задержки публикации, с") + success_axis.set_title( + "Lab031. Итоговое сравнение режимов при Bad 200 мс" + ) + success_axis.grid(True, axis="y", alpha=0.25) + lines = [ + success_axis.patches[0], + delay_axis.patches[0], + ] + success_axis.legend( + lines, + ["Составные кадры", "P95 публикации"], + loc="upper left", + ) + figure.tight_layout() + figure.savefig(MODE_COMPARISON_PLOT_PATH, dpi=160) + plt.close(figure) + + +def _mode_configuration_table( + schedules: dict[str, InterleavedSchedule], +) -> list[str]: + lines = [ + ( + "mode | k+r | D | groups | final group | source/parity " + "packets | outer kbit/s | overhead | wait mean/max s | " + "buffer mean/max | queue mean/max | symbol gap/span ms" + ), + ( + "----:|----:|--:|-------:|------------:|----------------------:" + "|-------------:|---------:|----------------:|----------------:" + "|---------------:|-------------------:" + ), + ] + for mode in MODES: + schedule = schedules[mode.name] + fec = schedule.fec_schedule + source_count = len(fec.source_packets) + parity_count = sum( + int(unit.unit.is_parity) for unit in fec.units + ) + outer_rate = ( + fec.total_transmitted_bytes + * 8.0 + / fec.source_duration_seconds + / 1000.0 + ) + overhead = ( + ( + fec.total_transmitted_bytes - fec.total_jpeg_bytes + ) + / fec.total_transmitted_bytes + * 100.0 + ) + block_code = ( + "none" + if not mode.parity_count + else f"8+{mode.parity_count}" + ) + lines.append( + f"{mode.label} | {block_code} | {mode.depth} | " + f"{len(schedule.groups)} | " + f"{len(schedule.groups[-1].block_ids) if schedule.groups else 0} " + f"| {source_count}/{parity_count} | {outer_rate:.3f} | " + f"{overhead:.3f}% | " + f"{schedule.mean_group_wait_delay_seconds:.6f}/" + f"{schedule.max_group_wait_delay_seconds:.6f} | " + f"{schedule.mean_buffered_blocks:.3f}/" + f"{schedule.max_buffered_blocks} | " + f"{fec.mean_queue_length_packets:.3f}/" + f"{fec.max_queue_length_packets} | " + f"{schedule.mean_intrablock_symbol_gap_seconds * 1000:.3f}/" + f"{schedule.mean_intrablock_symbol_span_seconds * 1000:.3f}" + ) + return lines + + +def _representative_table( + results: list[SimulationResult], +) -> list[str]: + rows = result_lookup(results, 200.0) + lines = [ + ( + "mode | composite | no-image mean/p95/max s | publication " + "mean/p95/max s | affected blocks | all blocks | " + "unrecoverable | delivered kbit/s | incomplete run " + "mean/p95/max" + ), + ( + "----:|----------:|------------------------:|-------------------------:" + "|----------------:|-----------:|--------------:|----------------:" + "|------------------------:" + ), + ] + for mode in MODES: + row = rows[mode.name] + lines.append( + f"{mode.label} | {row.composite_success_rate:.6f} | " + f"{row.mean_no_new_image_duration_seconds:.6f}/" + f"{row.p95_no_new_image_duration_seconds:.6f}/" + f"{row.max_no_new_image_duration_seconds:.6f} | " + f"{row.mean_publication_delay_seconds:.6f}/" + f"{row.p95_publication_delay_seconds:.6f}/" + f"{row.max_publication_delay_seconds:.6f} | " + f"{row.fec_affected_block_recovery_rate:.6f} | " + f"{row.fec_all_block_success_rate:.6f} | " + f"{row.fec_unrecoverable_block_rate:.6f} | " + f"{row.effective_delivered_video_bitrate_kbps:.3f} | " + f"{row.mean_consecutive_incomplete_frames:.3f}/" + f"{row.p95_consecutive_incomplete_frames:.3f}/" + f"{row.max_consecutive_incomplete_frames}" + ) + return lines + + +def _outperformance_lines( + results: list[SimulationResult], +) -> list[str]: + lines = [] + for duration in ( + value * 1000.0 for value in MEAN_BAD_DURATIONS_SECONDS + ): + rows = result_lookup(results, duration) + control = rows["8+4_D1"] + for name in ("8+2_D2", "8+2_D4", "8+2_D8"): + candidate = rows[name] + if ( + candidate.composite_success_rate + > control.composite_success_rate + ): + lines.append( + f"- Bad {duration:.0f} мс, {candidate.label}: " + f"composite {candidate.composite_success_rate:.6f} " + f"> {control.composite_success_rate:.6f}; " + f"P95 no-image " + f"{candidate.p95_no_new_image_duration_seconds:.6f} " + f"с против " + f"{control.p95_no_new_image_duration_seconds:.6f} с; " + f"P95 публикации " + f"{candidate.p95_publication_delay_seconds:.6f} " + f"с против " + f"{control.p95_publication_delay_seconds:.6f} с." + ) + if not lines: + lines.append( + "- В исследованных условиях 8+2 с перемежением не превзошёл " + "8+4, D=1 по доле составных кадров." + ) + return lines + + +def write_report( + metadata: VideoMetadata, + composites: list[EncodedComposite], + schedules: dict[str, InterleavedSchedule], + results: list[SimulationResult], + tests: list[FunctionalTestResult], +) -> None: + lines = [ + "Lab031. Перемежение пакетов между FEC-блоками", + "", + "Исходные данные и неизменные слои", + f"- Видео: {SOURCE_VIDEO_PATH}", + ( + f"- {len(composites)} реальных синхронных пар BASE/ROI, " + f"{COMPOSITE_FPS:.0f} fps; длительность источника " + f"{metadata.duration_seconds:.6f} с." + ), + ( + "- BASE 240x135 grayscale JPEG Q23; ROI 320x180 " + "grayscale JPEG Q33." + ), + ( + "- Внутренний транспорт Lab028: payload 512 байт, " + "packet CRC32, object CRC32, атомарная публикация BASE+ROI." + ), + ( + "- Внешний формат и GF(256) Lab030 используются без " + "изменений; k=8, parity r=2 или r=4." + ), + "", + "Точное правило перестановки", + ( + "- Последовательные готовые FEC-блоки делятся на группы " + "не более D блоков." + ), + ( + "- Для symbol_index=0,1,2,... передаётся символ с этим " + "индексом блока 0, затем блока 1, ... блока D-1." + ), + ( + "- Если блок короче, отсутствующий symbol_index пропускается. " + "Внутри блока сохраняется порядок systematic, затем parity." + ), + ( + "- Перестановка не изменяет байты, block_id или symbol_index " + "и не добавляет пакеты." + ), + "", + "Формирование и буферизация групп", + ( + "- Полная группа D>1 выпускается после готовности D-го " + "последовательного блока; все её пакеты получают это время " + "доступности перед FIFO-передатчиком." + ), + ( + "- Неполная последняя группа выпускается в момент окончания " + "видеопотока; синтетические блоки и символы не добавляются." + ), + ( + "- D=1 сохраняет порядок и времена доступности Lab030 и " + "имеет нулевую добавочную задержку ожидания группы." + ), + ( + "- Размер группы формируется в порядке возрастания block_id; " + "полные группы имеют D блоков, последняя — остаток." + ), + ( + "- Средний буфер — среднее заполнение после каждого прихода " + "готового блока (1..размер группы); максимум включает момент " + "непосредственно перед выпуском группы." + ), + "", + "Семь фиксированных режимов и статические метрики", + *_mode_configuration_table(schedules), + "", + "Временная модель и статистика", + ( + "- Непрерывные чередующиеся экспоненциальные Good/Bad " + "интервалы Lab029B, средняя доля Bad около 2%." + ), + ( + "- Средние Bad: 10, 50, 200, 1000 мс; 200 повторов на " + f"условие; seeds {SEED_BASE}...{SEED_BASE + 3}." + ), + ( + "- Пакет теряется при любом пересечении интервала его " + "передачи с Bad; скорость FIFO 300 кбит/с." + ), + ( + "- Учитываются фактическая длина каждого внутреннего и " + "внешнего пакета, ожидание группы и накопление очереди." + ), + ( + "- Приёмник группирует внешние символы по block_id, " + "декодирует после любых k принятых символов и передаёт " + "восстановленные внутренние пакеты в CompositeReassembler." + ), + ( + "- Соседние composite_frame_id не смешиваются; неполный " + "BASE+ROI кадр не публикуется." + ), + "", + "Результаты при средней Bad 200 мс", + *_representative_table(results), + "", + "Когда 8+2 с перемежением превосходит 8+4 без него", + ( + "- Критерий ниже: строго более высокая доля атомарно " + "восстановленных составных кадров; задержки приведены рядом " + "как цена перемежения." + ), + *_outperformance_lines(results), + "", + "Функциональные проверки", + *[ + f"- {'PASS' if test.passed else 'FAIL'} " + f"{test.name}: {test.detail}" + for test in tests + ], + "", + "Допущения модели", + ( + "- Видеопрофиль конечный и детерминированный; JPEG " + "кодируются в памяти один раз перед Monte Carlo." + ), + ( + "- Устройство знает конец потока и только тогда выпускает " + "неполную последнюю группу D>1." + ), + ( + "- Время готовности FEC-блока равно максимальному времени " + "генерации его исходных пакетов; вычислительная задержка " + "GF(256) считается нулевой." + ), + ( + "- D=1 определён как точный режим Lab030; поэтому он не " + "ждёт завершения блока перед отправкой уже готовых " + "systematic symbols." + ), + ( + "- Передатчик и канал общие FIFO; параллельные радиоканалы, " + "ARQ, повторные передачи, команды, телеметрия, модуляция и " + "реальный SDR не моделируются." + ), + ( + "- Потеря является стиранием полного внешнего пакета; " + "отдельные битовые ошибки не добавляются." + ), + ( + "- Publication delay считается от frame_id/3 до конца " + "пакета, завершившего атомарную сборку; no-image включает " + "время до следующего опубликованного кадра или конца " + "расписания." + ), + ( + "- Intrablock symbol gap измеряется между временами конца " + "последовательных symbol_index; span — от первого до " + "последнего символа блока." + ), + "", + "Артефакты", + f"- CSV: {CSV_PATH}", + f"- Отчёт: {REPORT_PATH}", + f"- Доля восстановленных кадров: {COMPOSITE_SUCCESS_PLOT_PATH}", + f"- Отсутствие нового изображения: {NO_IMAGE_PLOT_PATH}", + f"- Задержка публикации: {PUBLICATION_DELAY_PLOT_PATH}", + f"- Доля восстановленных блоков: {BLOCK_RECOVERY_PLOT_PATH}", + f"- Устойчивость/задержка от D: {DEPTH_TRADEOFF_PLOT_PATH}", + f"- Итоговое сравнение: {MODE_COMPARISON_PLOT_PATH}", + "- JPEG, внешние пакеты и бинарные дампы на диск не сохранялись.", + "", + ] + REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") + + +def validate_outputs() -> None: + expected = ( + CSV_PATH, + REPORT_PATH, + COMPOSITE_SUCCESS_PLOT_PATH, + NO_IMAGE_PLOT_PATH, + PUBLICATION_DELAY_PLOT_PATH, + BLOCK_RECOVERY_PLOT_PATH, + DEPTH_TRADEOFF_PLOT_PATH, + MODE_COMPARISON_PLOT_PATH, + ) + for path in expected: + if not path.exists() or path.stat().st_size <= 0: + raise RuntimeError(f"missing or empty output: {path}") + with CSV_PATH.open(newline="", encoding="utf-8") as file: + rows = list(csv.DictReader(file)) + if len(rows) != 28: + raise RuntimeError( + f"Lab031 CSV must contain 28 rows, got {len(rows)}" + ) + if len(rows[0]) != len(CSV_FIELDS): + raise RuntimeError("Lab031 CSV column count changed") + for path in expected: + if path.suffix == ".png": + image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) + if image is None: + raise RuntimeError(f"OpenCV cannot read {path}") + + +def main() -> None: + print("Lab031: loading real 512-byte-payload Lab028 stream...") + metadata, composites = load_video_profile(SOURCE_VIDEO_PATH) + profile = prepare_profiles(composites)[INNER_PAYLOAD_SIZE] + schedules = build_schedules(metadata, composites, profile) + for mode in MODES: + schedule = schedules[mode.name] + fec = schedule.fec_schedule + print( + f" {mode.label}: units={len(fec.units)}, " + f"groups={len(schedule.groups)}, " + f"wait={schedule.mean_group_wait_delay_seconds:.6f} s, " + f"gap={schedule.mean_intrablock_symbol_gap_seconds:.6f} s, " + f"timeline={fec.duration_seconds:.6f} s" + ) + + print("Running Lab031 functional checks...") + tests = run_functional_tests(composites, schedules) + for test in tests: + print(f" PASS {test.name}: {test.detail}") + + print( + f"Running {len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS)} " + f"conditions, {MONTE_CARLO_REPETITIONS} repetitions each..." + ) + results = run_monte_carlo(schedules, len(composites)) + validate_results(results) + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + save_csv(results) + save_plots(results) + write_report( + metadata, composites, schedules, results, tests + ) + validate_outputs() + + representative = result_lookup(results, 200.0) + print("Representative Bad=200 ms results:") + for mode in MODES: + result = representative[mode.name] + print( + f" {mode.label}: composite=" + f"{result.composite_success_rate:.6f}, " + f"block={result.fec_all_block_success_rate:.6f}, " + f"delay_p95={result.p95_publication_delay_seconds:.6f} s" + ) + print(f"CSV: {CSV_PATH}") + print(f"Report: {REPORT_PATH}") + print("Lab031 completed successfully.") + + +if __name__ == "__main__": + main()