diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index cfa2fbb..abe6fb2 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -54,3 +54,24 @@ git version 2.51.1.windows.1 - Исследованы размеры payload 64, 128, 256, 512 и 1024 байта. - Размер payload 512 байт принят как рабочий кандидат. - Окончательный выбор размера пакета перенесён в Lab029. + +--- + +# Запись 003 + +## Дата + +27 июля 2026 года + +## Тема + +Завершение Lab029: моделирование потерь и ошибок видеопакетов. + +## Выполнено + +- Исследованы независимые потери пакетов и независимые битовые ошибки. +- Проверены короткие, средние и длинные серии потерь по модели Gilbert–Elliott. +- Выполнено сравнение payload 256, 512 и 1024 байта. +- Подтверждены packet CRC32, изоляция соседних кадров и атомарная сборка BASE + ROI. +- Зафиксировано ограничение пакетной временной шкалы Lab029. +- Сравнение помех одинаковой физической длительности перенесено в Lab029B. diff --git a/data/processed/lab029/lab029_ber_success.png b/data/processed/lab029/lab029_ber_success.png new file mode 100644 index 0000000..d7bbb55 Binary files /dev/null and b/data/processed/lab029/lab029_ber_success.png differ diff --git a/data/processed/lab029/lab029_burst_loss_results.png b/data/processed/lab029/lab029_burst_loss_results.png new file mode 100644 index 0000000..4bbb1d0 Binary files /dev/null and b/data/processed/lab029/lab029_burst_loss_results.png differ diff --git a/data/processed/lab029/lab029_independent_loss_success.png b/data/processed/lab029/lab029_independent_loss_success.png new file mode 100644 index 0000000..7f59262 Binary files /dev/null and b/data/processed/lab029/lab029_independent_loss_success.png differ diff --git a/data/processed/lab029/lab029_payload_comparison.png b/data/processed/lab029/lab029_payload_comparison.png new file mode 100644 index 0000000..feae729 Binary files /dev/null and b/data/processed/lab029/lab029_payload_comparison.png differ diff --git a/data/processed/lab029/lab029_report.txt b/data/processed/lab029/lab029_report.txt new file mode 100644 index 0000000..10efe10 --- /dev/null +++ b/data/processed/lab029/lab029_report.txt @@ -0,0 +1,95 @@ +Lab029. Прохождение видеопакетов через канал с потерями и ошибками + +Исходные данные и неизменный транспорт Lab028 +- Видео: data\raw\lab026_rover_source.mp4 +- Источник: 1280x720, 30.000000 fps, 623 кадров, 20.766667 с. +- Реальных синхронных JPEG-пар: 63; BASE 240x135 grayscale Q23, ROI 320x180 grayscale Q33, 3 composite fps. +- Использован protocol/video_packet.py без изменения: 32-byte header, packet CRC32, object CRC32, отдельные BASE/ROI и атомарная выдача CompositeReassembler. +- Payload: 256, 512, 1024 байт. +- Пакетов в одном проходе: 256 B=1660, 512 B=862, 1024 B=478. + +Методика Monte Carlo +- Повторов на каждую строку: 200. +- Master seed: 29029; independent seeds 29029...29034; BER seeds 30029...30034; burst seeds 31029...31031. +- Для одинакового параметра один seed повторно используется для payload 256/512/1024, чтобы сравнение было коррелированным и воспроизводимым. +- packet_delivery_rate = (received - CRC rejected) / transmitted. +- effective delivered JPEG bitrate учитывает BASE+ROI bytes только атомарно выданных составных кадров и полное симулированное время. +- Потерянный фрагмент — физически потерянный пакет или пакет, отклонённый проверкой транспорта. +- FEC, ARQ, повторные передачи и интерливинг не применяются. + +Модели канала +- Independent loss: каждый пакет теряется независимо с заданной вероятностью. +- BER: число независимых битовых ошибок выбирается binomial по полной длине пакета в битах, включая 32-byte header; выбранные биты физически инвертируются перед reassembler. +- Gilbert-Elliott: Good доставляет все пакеты, Bad теряет все. Начальное состояние выбирается из стационарного распределения. +- В Lab029 длительность состояния Bad и серии потерь задаётся количеством пакетов, а не физическим временем. +- Серия из одинакового количества пакетов имеет разную физическую длительность для payload 256, 512 и 1024 байта, поскольку различается время передачи пакета. +- Доля успешно восстановленных кадров не показывает длительность непрерывного отсутствия нового изображения. +- Поэтому burst-loss результаты Lab029 не являются окончательным сравнением размеров payload; для временного сравнения предназначена Lab029B. + - short: короткие серии потерь; P(G->B)=0.010204082, P(B->G)=0.500000000, stationary loss=2.000%, expected mean burst=2.0. + - medium: средние серии потерь; P(G->B)=0.004081633, P(B->G)=0.200000000, stationary loss=2.000%, expected mean burst=5.0. + - long: длинные серии потерь; P(G->B)=0.001020408, P(B->G)=0.050000000, stationary loss=2.000%, expected mean burst=20.0. + +Функциональные проверки +- PASS zero_impairment_100_percent: all payloads delivered 100% packets and composite frames +- PASS packet_crc_rejects_bit_error: payload bit flip was rejected by packet CRC32 +- PASS adjacent_frame_isolation: two interleaved neighboring frames remained independent +- PASS atomic_incomplete_frame: missing ROI fragment prevented atomic publication +- PASS fixed_seed_reproducibility: identical seed produced byte-for-byte equal result fields + +1. Independent packet loss — atomic composite success +parameter | payload 256 | payload 512 | payload 1024 +---------:|------------:|------------:|-------------: +0.0% | 100.000% | 100.000% | 100.000% +0.1% | 97.397% | 98.651% | 99.214% +0.5% | 87.294% | 93.357% | 96.254% +1.0% | 76.405% | 87.008% | 92.206% +2.0% | 58.937% | 76.516% | 86.286% +5.0% | 25.698% | 48.738% | 67.198% + +2. BER — atomic composite success +parameter | payload 256 | payload 512 | payload 1024 +---------:|------------:|------------:|-------------: +0 | 100.000% | 100.000% | 100.000% +1e-07 | 99.413% | 99.524% | 99.357% +3e-07 | 98.222% | 98.476% | 98.492% +1e-06 | 94.111% | 94.714% | 94.754% +3e-06 | 83.905% | 84.373% | 85.397% +1e-05 | 55.357% | 56.722% | 57.770% + +3. Burst-loss +scenario | payload | expected loss | observed loss | mean/max burst | composite success | burst distribution +--------:|--------:|--------------:|--------------:|---------------:|------------------:|:------------------ +short | 256 | 2.000% | 1.932% | 1.972/12 | 76.111% | 1:1628;2:822;3-4:635;5-8:155;9-16:12;17-32:0;33+:0 +short | 512 | 2.000% | 1.853% | 1.903/11 | 86.484% | 1:861;2:429;3-4:318;5-8:67;9-16:3;17-32:0;33+:0 +short | 1024 | 2.000% | 1.891% | 1.899/8 | 91.714% | 1:475;2:263;3-4:171;5-8:43;9-16:0;17-32:0;33+:0 +medium | 256 | 2.000% | 2.007% | 4.991/30 | 88.333% | 1:258;2:226;3-4:324;5-8:306;9-16:180;17-32:41;33+:0 +medium | 512 | 2.000% | 1.909% | 4.890/27 | 93.317% | 1:124;2:121;3-4:178;5-8:142;9-16:87;17-32:21;33+:0 +medium | 1024 | 2.000% | 2.016% | 5.084/27 | 95.357% | 1:77;2:57;3-4:100;5-8:77;9-16:53;17-32:15;33+:0 +long | 256 | 2.000% | 1.975% | 19.061/116 | 95.405% | 1:22;2:10;3-4:22;5-8:58;9-16:84;17-32:83;33+:65 +long | 512 | 2.000% | 1.983% | 19.311/124 | 96.762% | 1:14;2:4;3-4:13;5-8:32;9-16:39;17-32:40;33+:35 +long | 1024 | 2.000% | 2.192% | 20.350/124 | 97.198% | 1:12;2:1;3-4:8;5-8:15;9-16:24;17-32:21;33+:22 + +4. Сравнение payload 256/512/1024 +payload | loss 1% success | BER 1e-6 success | medium burst success | Lab028 wire kbit/s +-------:|----------------:|-----------------:|---------------------:|--------------------: +256 | 76.405% | 94.111% | 88.333% | 178.233 +512 | 87.008% | 94.714% | 93.317% | 168.396 +1024 | 92.206% | 94.754% | 95.357% | 163.662 + +Интерпретация +- При фиксированной вероятности потери пакета крупный payload уменьшает число обязательных фрагментов. Поскольку потеря любого фрагмента делает объект неполным, меньшее число пакетов обычно повышает шанс полного кадра. +- При фиксированном BER длинный отдельный пакет чаще повреждается: P(error)=1-(1-BER)^(packet_bits). Одновременно крупный payload уменьшает число 32-byte headers и общую длину wire-потока, поэтому итоговый composite success определяется обоими эффектами. +- Атомарная сборка требует одновременной готовности BASE и ROI. Даже полностью собранный BASE не даёт полезного составного кадра без ROI, поэтому composite success ниже успеха каждого объекта отдельно. +- Burst-loss особенно опасен без интерливинга: соседние фрагменты и иногда соседние кадры теряются одной серией, а CRC только обнаруживает ошибку и не восстанавливает данные. +- Payload 256 B даёт наименьшую длину отдельного пакета и наименьшую вероятность BER-повреждения одного пакета, но требует больше всего фрагментов и headers. +- Payload 512 B остаётся умеренным рабочим кандидатом между packet count и длиной отдельного пакета. +- Payload 1024 B даёт минимум пакетов и header overhead в этих моделях, но каждый пакет длиннее и при его потере пропадает более крупный фрагмент JPEG. +- Окончательный packet payload автоматически не выбирается; для решения нужны Lab029 и последующие данные реального радиоканала, FEC/ARQ и задержек. + +Артефакты +- CSV: data\processed\lab029\lab029_results.csv +- Independent loss plot: data\processed\lab029\lab029_independent_loss_success.png +- BER plot: data\processed\lab029\lab029_ber_success.png +- Burst plot: data\processed\lab029\lab029_burst_loss_results.png +- Payload comparison: data\processed\lab029\lab029_payload_comparison.png +- JPEG, пакеты и бинарные дампы на диск не сохранялись. diff --git a/data/processed/lab029/lab029_results.csv b/data/processed/lab029/lab029_results.csv new file mode 100644 index 0000000..41c8e00 --- /dev/null +++ b/data/processed/lab029/lab029_results.csv @@ -0,0 +1,46 @@ +model,parameter_name,parameter_value,scenario,payload_size,monte_carlo_repetitions,seed,good_to_bad_probability,bad_to_good_probability,expected_loss_probability,expected_mean_loss_burst,observed_loss_probability,transmitted_packets,received_packets,lost_packets,crc_rejected_packets,packet_delivery_rate,base_objects_completed,roi_objects_completed,atomic_composite_frames_completed,base_only_frames,roi_only_frames,incomplete_frames,composite_success_rate,effective_delivered_jpeg_bitrate_kbps,mean_lost_fragments_per_frame,p95_lost_fragments_per_frame,mean_loss_burst_length,max_loss_burst_length,loss_burst_distribution +independent_loss,packet_loss_probability,0,,256,200,29029,0,0,0,0,0,332000,332000,0,0,1,12600,12600,12600,0,0,0,1,157.76975923,0,0,0,0,1:0;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0,,512,200,29029,0,0,0,0,0,172400,172400,0,0,1,12600,12600,12600,0,0,0,1,157.76975923,0,0,0,0,1:0;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0,,1024,200,29029,0,0,0,0,0,95600,95600,0,0,1,12600,12600,12600,0,0,0,1,157.76975923,0,0,0,0,1:0;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.001,,256,200,29030,0,0,0.001,1.001001001,0.00101807228916,332000,331662,338,0,0.998981927711,12468,12398,12272,196,126,328,0.973968253968,153.639124238,0.0268253968254,0,1,1,1:338;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.001,,512,200,29030,0,0,0.001,1.001001001,0.000991879350348,172400,172229,171,0,0.99900812065,12530,12499,12430,100,69,170,0.986507936508,155.629063242,0.0135714285714,0,1,1,1:171;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.001,,1024,200,29030,0,0,0.001,1.001001001,0.00103556485356,95600,95501,99,0,0.998964435146,12563,12538,12501,62,37,99,0.992142857143,156.519451685,0.00785714285714,0,1,1,1:99;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.005,,256,200,29031,0,0,0.005,1.00502512563,0.00515361445783,332000,330289,1711,0,0.994846385542,11987,11558,10999,988,559,1601,0.872936507937,137.693897913,0.135793650794,1,1.00765606596,2,1:1685;2:13;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.005,,512,200,29031,0,0,0.005,1.00502512563,0.00505220417633,172400,171529,871,0,0.994947795824,12302,12046,11763,539,283,837,0.933571428571,147.277529374,0.069126984127,1,1.01044083527,2,1:853;2:9;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.005,,1024,200,29031,0,0,0.005,1.00502512563,0.00503138075314,95600,95119,481,0,0.994968619247,12408,12317,12128,280,189,472,0.96253968254,151.818947673,0.0381746031746,0,1.01050420168,2,1:471;2:5;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.01,,256,200,29032,0,0,0.01,1.0101010101,0.0101746987952,332000,328622,3378,0,0.989825301205,11449,10592,9627,1822,965,2973,0.764047619048,120.432163724,0.268095238095,1,1.00775656325,2,1:3326;2:26;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.01,,512,200,29032,0,0,0.01,1.0101010101,0.0100928074246,172400,170660,1740,0,0.989907192575,11993,11526,10963,1030,563,1637,0.870079365079,137.210552167,0.138095238095,1,1.00752750434,2,1:1714;2:13;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.01,,1024,200,29032,0,0,0.01,1.0101010101,0.0105962343096,95600,94587,1013,0,0.98940376569,12198,12003,11618,580,385,982,0.922063492063,145.437291814,0.0803968253968,1,1.007960199,2,1:997;2:8;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.02,,256,200,29033,0,0,0.02,1.02040816327,0.0198795180723,332000,325400,6600,0,0.980120481928,10436,8989,7426,3010,1563,5174,0.589365079365,92.8966767255,0.52380952381,2,1.01946246525,3,1:6350;2:122;3-4:2;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.02,,512,200,29033,0,0,0.02,1.02040816327,0.0195649651972,172400,169027,3373,0,0.980435034803,11432,10607,9641,1791,966,2959,0.765158730159,120.639628892,0.267698412698,1,1.0178032589,3,1:3256;2:57;3-4:1;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.02,,1024,200,29033,0,0,0.02,1.02040816327,0.0192573221757,95600,93759,1841,0,0.980742677824,11880,11536,10872,1008,664,1728,0.862857142857,136.121568539,0.146111111111,1,1.01712707182,3,1:1780;2:29;3-4:1;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.05,,256,200,29034,0,0,0.05,1.05263157895,0.0503072289157,332000,315298,16702,0,0.949692771084,7774,5198,3238,4536,1960,9362,0.256984126984,40.4080507223,1.32555555556,3,1.05189570475,4,1:15094;2:746;3-4:38;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.05,,512,200,29034,0,0,0.05,1.05263157895,0.0510266821346,172400,163603,8797,0,0.948973317865,9647,8012,6141,3506,1871,6459,0.487380952381,76.7984243981,0.698174603175,2,1.05139237481,4,1:7961;2:383;3-4:23;5-8:0;9-16:0;17-32:0;33+:0 +independent_loss,packet_loss_probability,0.05,,1024,200,29034,0,0,0.05,1.05263157895,0.0507949790795,95600,90744,4856,0,0.949205020921,10780,9911,8467,2313,1444,4133,0.671984126984,105.919342536,0.385396825397,2,1.0492653414,4,1:4415;2:199;3-4:14;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,0,,256,200,30029,0,0,0,0,0,332000,332000,0,0,1,12600,12600,12600,0,0,0,1,157.76975923,0,0,0,0,1:0;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,0,,512,200,30029,0,0,0,0,0,172400,172400,0,0,1,12600,12600,12600,0,0,0,1,157.76975923,0,0,0,0,1:0;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,0,,1024,200,30029,0,0,0,0,0,95600,95600,0,0,1,12600,12600,12600,0,0,0,1,157.76975923,0,0,0,0,1:0;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-07,,256,200,30030,0,0,0,0,0.000225903614458,332000,332000,0,75,0.999774096386,12570,12556,12526,44,30,74,0.994126984127,156.829585233,0.00595238095238,0,1,1,1:75;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-07,,512,200,30030,0,0,0,0,0.000348027842227,172400,172400,0,60,0.999651972158,12578,12562,12540,38,22,60,0.995238095238,157.006054575,0.0047619047619,0,1,1,1:60;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-07,,1024,200,30030,0,0,0,0,0.000847280334728,95600,95600,0,81,0.999152719665,12569,12550,12519,50,31,81,0.993571428571,156.742861637,0.00642857142857,0,1,1,1:81;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,3e-07,,256,200,30031,0,0,0,0,0.000680722891566,332000,332000,0,226,0.999319277108,12514,12461,12376,138,85,224,0.982222222222,154.967524238,0.0179365079365,0,1,1,1:226;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,3e-07,,512,200,30031,0,0,0,0,0.00111368909513,172400,172400,0,192,0.998886310905,12530,12478,12408,122,70,192,0.984761904762,155.35917496,0.0152380952381,0,1,1,1:192;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,3e-07,,1024,200,30031,0,0,0,0,0.00199790794979,95600,95600,0,191,0.99800209205,12532,12478,12410,122,68,190,0.984920634921,155.39185618,0.0151587301587,0,1,1,1:191;2:0;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-06,,256,200,30032,0,0,0,0,0.00229819277108,332000,332000,0,763,0.997701807229,12337,12111,11858,479,253,742,0.941111111111,148.434593258,0.0605555555556,1,1.00131233596,2,1:761;2:1;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-06,,512,200,30032,0,0,0,0,0.00396171693735,172400,172400,0,683,0.996038283063,12345,12182,11934,411,248,666,0.947142857143,149.411587801,0.0542063492063,1,1.0058910162,2,1:675;2:4;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-06,,1024,200,30032,0,0,0,0,0.00713389121339,95600,95600,0,682,0.992866108787,12371,12157,11939,432,218,661,0.94753968254,149.447597432,0.054126984127,1,1.0118694362,3,1:667;2:6;3-4:1;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,3e-06,,256,200,30033,0,0,0,0,0.00661445783133,332000,332000,0,2196,0.993385542169,11810,11280,10572,1238,708,2028,0.839047619048,132.241321348,0.174285714286,1,1.00687757909,2,1:2166;2:15;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,3e-06,,512,200,30033,0,0,0,0,0.0122563805104,172400,172400,0,2113,0.98774361949,11848,11321,10631,1217,690,1969,0.84373015873,133.045201926,0.167698412698,1,1.0114887506,2,1:2065;2:24;3-4:0;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,3e-06,,1024,200,30033,0,0,0,0,0.0204916317992,95600,95600,0,1959,0.979508368201,11923,11387,10760,1163,627,1840,0.853968253968,134.696365329,0.155476190476,1,1.02404600105,3,1:1868;2:44;3-4:1;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-05,,256,200,30034,0,0,0,0,0.0222018072289,332000,332000,0,7371,0.977798192771,10234,8590,6975,3259,1615,5625,0.553571428571,87.1395390048,0.585,2,1.01950207469,4,1:7095;2:130;3-4:5;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-05,,512,200,30034,0,0,0,0,0.0407192575406,172400,172400,0,7020,0.959280742459,10313,8706,7147,3166,1559,5453,0.567222222222,89.3745245586,0.557142857143,2,1.03984594875,4,1:6492;2:250;3-4:9;5-8:0;9-16:0;17-32:0;33+:0 +ber,bit_error_rate,1e-05,,1024,200,30034,0,0,0,0,0.0692573221757,95600,95600,0,6621,0.930742677824,10300,8931,7279,3021,1652,5321,0.577698412698,91.0087704655,0.525476190476,2,1.07066623545,4,1:5779;2:376;3-4:29;5-8:0;9-16:0;17-32:0;33+:0 +burst,gilbert_elliott_scenario,0,short,256,200,31029,0.0102040816327,0.5,0.02,2,0.0193162650602,332000,325587,6413,0,0.98068373494,11365,10541,9590,1775,951,3010,0.761111111111,119.954532905,0.508968253968,3,1.97201722017,12,1:1628;2:822;3-4:635;5-8:155;9-16:12;17-32:0;33+:0 +burst,gilbert_elliott_scenario,0,short,512,200,31029,0.0102040816327,0.5,0.02,2,0.0185266821346,172400,169206,3194,0,0.981473317865,11907,11463,10897,1010,566,1703,0.864841269841,136.370440449,0.253492063492,2,1.90345649583,11,1:861;2:429;3-4:318;5-8:67;9-16:3;17-32:0;33+:0 +burst,gilbert_elliott_scenario,0,short,1024,200,31029,0.0102040816327,0.5,0.02,2,0.0189121338912,95600,93792,1808,0,0.981087866109,12097,11940,11556,541,384,1044,0.917142857143,144.700911717,0.143492063492,1,1.89915966387,8,1:475;2:263;3-4:171;5-8:43;9-16:0;17-32:0;33+:0 +burst,gilbert_elliott_scenario,1,medium,256,200,31030,0.00408163265306,0.2,0.02,5,0.0200692771084,332000,325337,6663,0,0.979930722892,11962,11552,11130,832,422,1470,0.883333333333,139.320930337,0.52880952381,4,4.99101123596,30,1:258;2:226;3-4:324;5-8:306;9-16:180;17-32:41;33+:0 +burst,gilbert_elliott_scenario,1,medium,512,200,31030,0.00408163265306,0.2,0.02,5,0.0190893271462,172400,169109,3291,0,0.980910672854,12172,11987,11758,414,229,842,0.933174603175,147.194698555,0.26119047619,2,4.89004457652,27,1:124;2:121;3-4:178;5-8:142;9-16:87;17-32:21;33+:0 +burst,gilbert_elliott_scenario,1,medium,1024,200,31030,0.00408163265306,0.2,0.02,5,0.0201569037657,95600,93673,1927,0,0.979843096234,12256,12154,12015,241,139,585,0.953571428571,150.407112039,0.152936507937,0,5.08443271768,27,1:77;2:57;3-4:100;5-8:77;9-16:53;17-32:15;33+:0 +burst,gilbert_elliott_scenario,2,long,256,200,31031,0.00102040816327,0.05,0.02,20,0.01975,332000,325443,6557,0,0.98025,12247,12146,12021,226,125,579,0.954047619048,150.526746067,0.520396825397,0,19.0610465116,116,1:22;2:10;3-4:22;5-8:58;9-16:84;17-32:83;33+:65 +burst,gilbert_elliott_scenario,2,long,512,200,31031,0.00102040816327,0.05,0.02,20,0.0198259860789,172400,168982,3418,0,0.980174013921,12301,12253,12192,109,61,408,0.967619047619,152.666767897,0.27126984127,0,19.3107344633,124,1:14;2:4;3-4:13;5-8:32;9-16:39;17-32:40;33+:35 +burst,gilbert_elliott_scenario,2,long,1024,200,31031,0.00102040816327,0.05,0.02,20,0.0219246861925,95600,93504,2096,0,0.978075313808,12305,12279,12247,58,32,353,0.971984126984,153.307654575,0.166349206349,0,20.3495145631,124,1:12;2:1;3-4:8;5-8:15;9-16:24;17-32:21;33+:22 diff --git a/tests/lab029_packet_channel_simulation.py b/tests/lab029_packet_channel_simulation.py new file mode 100644 index 0000000..e1ad0ca --- /dev/null +++ b/tests/lab029_packet_channel_simulation.py @@ -0,0 +1,1353 @@ +""" +Lab029. Monte Carlo simulation of Lab028 video packets over impaired channels. + +Real synchronous BASE and ROI JPEGs are formed with the Lab028 working +profile. The existing 32-byte packet format, packet CRC32, object CRC32, and +atomic CompositeReassembler are used unchanged. JPEGs and packets remain in +memory; only aggregate CSV, text, and PNG results are written. +""" + +from __future__ import annotations + +import csv +from dataclasses import asdict, dataclass +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.video_packet import ( + CompositeReassembler, + ObjectType, + PacketCRCError, + VideoPacketError, +) +from tests.lab028_video_packetization import ( + EncodedComposite, + SOURCE_VIDEO_PATH, + VideoMetadata, + load_video_profile, + packets_for_composite, +) + + +OUTPUT_DIRECTORY = Path("data/processed/lab029") +CSV_PATH = OUTPUT_DIRECTORY / "lab029_results.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab029_report.txt" +INDEPENDENT_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab029_independent_loss_success.png" +) +BER_PLOT_PATH = OUTPUT_DIRECTORY / "lab029_ber_success.png" +BURST_PLOT_PATH = OUTPUT_DIRECTORY / "lab029_burst_loss_results.png" +PAYLOAD_COMPARISON_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab029_payload_comparison.png" +) + +PAYLOAD_SIZES = (256, 512, 1024) +PACKET_LOSS_PROBABILITIES = (0.0, 0.001, 0.005, 0.01, 0.02, 0.05) +BER_VALUES = (0.0, 1e-7, 3e-7, 1e-6, 3e-6, 1e-5) +MONTE_CARLO_REPETITIONS = 200 +MASTER_SEED = 29029 +INDEPENDENT_SEED_BASE = MASTER_SEED +BER_SEED_BASE = MASTER_SEED + 1000 +BURST_SEED_BASE = MASTER_SEED + 2000 +BURST_STATIONARY_LOSS_PROBABILITY = 0.02 + +BURST_DISTRIBUTION_LABELS = ( + "1", + "2", + "3-4", + "5-8", + "9-16", + "17-32", + "33+", +) + +CSV_FIELDS = [ + "model", + "parameter_name", + "parameter_value", + "scenario", + "payload_size", + "monte_carlo_repetitions", + "seed", + "good_to_bad_probability", + "bad_to_good_probability", + "expected_loss_probability", + "expected_mean_loss_burst", + "observed_loss_probability", + "transmitted_packets", + "received_packets", + "lost_packets", + "crc_rejected_packets", + "packet_delivery_rate", + "base_objects_completed", + "roi_objects_completed", + "atomic_composite_frames_completed", + "base_only_frames", + "roi_only_frames", + "incomplete_frames", + "composite_success_rate", + "effective_delivered_jpeg_bitrate_kbps", + "mean_lost_fragments_per_frame", + "p95_lost_fragments_per_frame", + "mean_loss_burst_length", + "max_loss_burst_length", + "loss_burst_distribution", +] + + +@dataclass(frozen=True) +class BurstScenario: + name: str + description: str + expected_mean_burst: float + good_to_bad_probability: float + bad_to_good_probability: float + + +@dataclass(frozen=True) +class PreparedFrame: + composite_frame_id: int + base_jpeg_size: int + roi_jpeg_size: int + packets: tuple[bytes, ...] + + +@dataclass(frozen=True) +class PreparedPacket: + composite_frame_id: int + wire_packet: bytes + + +@dataclass(frozen=True) +class PreparedProfile: + payload_size: int + frames: tuple[PreparedFrame, ...] + packets: tuple[PreparedPacket, ...] + + +@dataclass(frozen=True) +class SimulationResult: + model: str + parameter_name: str + parameter_value: float + scenario: str + payload_size: int + monte_carlo_repetitions: int + seed: int + good_to_bad_probability: float + bad_to_good_probability: float + expected_loss_probability: float + expected_mean_loss_burst: float + observed_loss_probability: float + transmitted_packets: int + received_packets: int + lost_packets: int + crc_rejected_packets: int + packet_delivery_rate: float + base_objects_completed: int + roi_objects_completed: int + atomic_composite_frames_completed: int + base_only_frames: int + roi_only_frames: int + incomplete_frames: int + composite_success_rate: float + effective_delivered_jpeg_bitrate_kbps: float + mean_lost_fragments_per_frame: float + p95_lost_fragments_per_frame: float + mean_loss_burst_length: float + max_loss_burst_length: int + loss_burst_distribution: str + + +@dataclass(frozen=True) +class FunctionalTestResult: + name: str + passed: bool + detail: str + + +def build_burst_scenarios() -> tuple[BurstScenario, ...]: + """ + Build three loss-only Gilbert-Elliott scenarios. + + Good packets are delivered and Bad packets are lost. All scenarios have + the same stationary Bad probability of 2%, while mean Bad-state duration + changes from 2 to 20 packets. + """ + + scenarios = [] + definitions = ( + ("short", "короткие серии потерь", 2.0), + ("medium", "средние серии потерь", 5.0), + ("long", "длинные серии потерь", 20.0), + ) + for name, description, mean_burst in definitions: + bad_to_good = 1.0 / mean_burst + good_to_bad = ( + BURST_STATIONARY_LOSS_PROBABILITY + * bad_to_good + / (1.0 - BURST_STATIONARY_LOSS_PROBABILITY) + ) + scenarios.append( + BurstScenario( + name=name, + description=description, + expected_mean_burst=mean_burst, + good_to_bad_probability=good_to_bad, + bad_to_good_probability=bad_to_good, + ) + ) + return tuple(scenarios) + + +BURST_SCENARIOS = build_burst_scenarios() + + +def prepare_profiles( + composites: list[EncodedComposite], +) -> dict[int, PreparedProfile]: + """Packetize every real JPEG once for each investigated payload size.""" + + profiles = {} + for payload_size in PAYLOAD_SIZES: + frames = [] + flattened_packets = [] + for composite in composites: + base_packets, roi_packets = packets_for_composite( + composite, payload_size + ) + packets = tuple(base_packets + roi_packets) + frame = PreparedFrame( + composite_frame_id=composite.composite_frame_id, + base_jpeg_size=len(composite.base_jpeg), + roi_jpeg_size=len(composite.roi_jpeg), + packets=packets, + ) + frames.append(frame) + flattened_packets.extend( + PreparedPacket( + composite_frame_id=composite.composite_frame_id, + wire_packet=packet, + ) + for packet in packets + ) + profiles[payload_size] = PreparedProfile( + payload_size=payload_size, + frames=tuple(frames), + packets=tuple(flattened_packets), + ) + return profiles + + +def burst_distribution_bin(length: int) -> str: + if length == 1: + return "1" + if length == 2: + return "2" + if length <= 4: + return "3-4" + if length <= 8: + return "5-8" + if length <= 16: + return "9-16" + if length <= 32: + return "17-32" + return "33+" + + +def format_burst_distribution(lengths: list[int]) -> str: + counts = {label: 0 for label in BURST_DISTRIBUTION_LABELS} + for length in lengths: + counts[burst_distribution_bin(length)] += 1 + return ";".join( + f"{label}:{counts[label]}" + for label in BURST_DISTRIBUTION_LABELS + ) + + +def corrupt_packet_bits( + wire_packet: bytes, + error_count: int, + rng: np.random.Generator, +) -> bytes: + """Flip independently selected bit positions in a packet copy.""" + + if error_count <= 0: + return wire_packet + bit_count = len(wire_packet) * 8 + error_count = min(error_count, bit_count) + positions = rng.choice( + bit_count, + size=error_count, + replace=False, + ) + corrupted = bytearray(wire_packet) + for position in np.atleast_1d(positions): + bit_position = int(position) + byte_index, bit_index = divmod(bit_position, 8) + corrupted[byte_index] ^= 1 << bit_index + return bytes(corrupted) + + +def independent_loss_flags( + packet_count: int, + loss_probability: float, + rng: np.random.Generator, +) -> np.ndarray: + return rng.random(packet_count) < loss_probability + + +def ber_error_counts( + profile: PreparedProfile, + ber: float, + rng: np.random.Generator, +) -> np.ndarray: + bit_lengths = np.fromiter( + ( + len(packet.wire_packet) * 8 + for packet in profile.packets + ), + dtype=np.int64, + count=len(profile.packets), + ) + return rng.binomial(bit_lengths, ber) + + +def burst_loss_flags( + packet_count: int, + scenario: BurstScenario, + rng: np.random.Generator, +) -> np.ndarray: + """Generate one stationary two-state Good/Bad packet sequence.""" + + flags = np.zeros(packet_count, dtype=np.bool_) + bad_state = ( + rng.random() < BURST_STATIONARY_LOSS_PROBABILITY + ) + for index in range(packet_count): + flags[index] = bad_state + transition_sample = rng.random() + if bad_state: + if transition_sample < scenario.bad_to_good_probability: + bad_state = False + elif transition_sample < scenario.good_to_bad_probability: + bad_state = True + return flags + + +def simulate_condition( + profile: PreparedProfile, + metadata: VideoMetadata, + model: str, + parameter_name: str, + parameter_value: float, + seed: int, + repetitions: int, + scenario: BurstScenario | None = None, +) -> SimulationResult: + """Simulate one channel condition and aggregate all requested metrics.""" + + if repetitions <= 0: + raise ValueError("repetitions must be positive") + if model == "burst" and scenario is None: + raise ValueError("burst model requires a scenario") + if model not in {"independent_loss", "ber", "burst"}: + raise ValueError(f"unsupported model: {model}") + + rng = np.random.default_rng(seed) + transmitted_packets = 0 + received_packets = 0 + lost_packets = 0 + crc_rejected_packets = 0 + base_objects_completed = 0 + roi_objects_completed = 0 + atomic_composite_frames_completed = 0 + base_only_frames = 0 + roi_only_frames = 0 + incomplete_frames = 0 + delivered_atomic_jpeg_bytes = 0 + lost_fragments_per_frame: list[int] = [] + loss_burst_lengths: list[int] = [] + + packet_count = len(profile.packets) + frame_count = len(profile.frames) + jpeg_sizes_by_id = { + frame.composite_frame_id: ( + frame.base_jpeg_size + frame.roi_jpeg_size + ) + for frame in profile.frames + } + + for _ in range(repetitions): + receiver = CompositeReassembler() + completed_frame_ids: set[int] = set() + unavailable_by_frame = [0] * frame_count + current_loss_burst = 0 + + if model == "independent_loss": + loss_flags = independent_loss_flags( + packet_count, parameter_value, rng + ) + error_counts = None + elif model == "ber": + loss_flags = np.zeros(packet_count, dtype=np.bool_) + error_counts = ber_error_counts(profile, parameter_value, rng) + else: + assert scenario is not None + loss_flags = burst_loss_flags(packet_count, scenario, rng) + error_counts = None + + for packet_index, prepared_packet in enumerate(profile.packets): + transmitted_packets += 1 + frame_id = prepared_packet.composite_frame_id + + if bool(loss_flags[packet_index]): + lost_packets += 1 + unavailable_by_frame[frame_id] += 1 + current_loss_burst += 1 + continue + + received_packets += 1 + wire_packet = prepared_packet.wire_packet + if error_counts is not None: + error_count = int(error_counts[packet_index]) + if error_count > 0: + wire_packet = corrupt_packet_bits( + wire_packet, error_count, rng + ) + + try: + completed = receiver.ingest(wire_packet) + except VideoPacketError: + crc_rejected_packets += 1 + unavailable_by_frame[frame_id] += 1 + current_loss_burst += 1 + continue + + if current_loss_burst: + loss_burst_lengths.append(current_loss_burst) + current_loss_burst = 0 + + if completed is not None: + completed_frame_ids.add(completed.composite_frame_id) + atomic_composite_frames_completed += 1 + delivered_atomic_jpeg_bytes += ( + len(completed.base_jpeg) + len(completed.roi_jpeg) + ) + + if current_loss_burst: + loss_burst_lengths.append(current_loss_burst) + + for frame in profile.frames: + frame_id = frame.composite_frame_id + atomic_complete = frame_id in completed_frame_ids + base_complete = ( + atomic_complete + or receiver.object_is_complete(frame_id, ObjectType.BASE) + ) + roi_complete = ( + atomic_complete + or receiver.object_is_complete(frame_id, ObjectType.ROI) + ) + base_objects_completed += int(base_complete) + roi_objects_completed += int(roi_complete) + base_only_frames += int(base_complete and not roi_complete) + roi_only_frames += int(roi_complete and not base_complete) + incomplete_frames += int(not atomic_complete) + + lost_fragments_per_frame.extend(unavailable_by_frame) + + expected_frames = frame_count * repetitions + if ( + atomic_composite_frames_completed + + incomplete_frames + != expected_frames + ): + raise RuntimeError("atomic and incomplete frame counts disagree") + if base_objects_completed != ( + atomic_composite_frames_completed + base_only_frames + ): + raise RuntimeError("BASE completion accounting disagrees") + if roi_objects_completed != ( + atomic_composite_frames_completed + roi_only_frames + ): + raise RuntimeError("ROI completion accounting disagrees") + + unavailable_packets = lost_packets + crc_rejected_packets + accepted_packets = received_packets - crc_rejected_packets + observed_loss_probability = ( + unavailable_packets / transmitted_packets + ) + if model == "burst": + assert scenario is not None + scenario_name = scenario.name + good_to_bad = scenario.good_to_bad_probability + bad_to_good = scenario.bad_to_good_probability + expected_loss = BURST_STATIONARY_LOSS_PROBABILITY + expected_mean_burst = scenario.expected_mean_burst + elif model == "independent_loss": + scenario_name = "" + good_to_bad = 0.0 + bad_to_good = 0.0 + expected_loss = parameter_value + expected_mean_burst = ( + 0.0 if parameter_value == 0.0 + else 1.0 / (1.0 - parameter_value) + ) + else: + scenario_name = "" + good_to_bad = 0.0 + bad_to_good = 0.0 + expected_loss = 0.0 + expected_mean_burst = 0.0 + + total_simulated_duration = ( + metadata.duration_seconds * repetitions + ) + return SimulationResult( + model=model, + parameter_name=parameter_name, + parameter_value=parameter_value, + scenario=scenario_name, + payload_size=profile.payload_size, + monte_carlo_repetitions=repetitions, + seed=seed, + good_to_bad_probability=good_to_bad, + bad_to_good_probability=bad_to_good, + expected_loss_probability=expected_loss, + expected_mean_loss_burst=expected_mean_burst, + observed_loss_probability=observed_loss_probability, + transmitted_packets=transmitted_packets, + received_packets=received_packets, + lost_packets=lost_packets, + crc_rejected_packets=crc_rejected_packets, + packet_delivery_rate=accepted_packets / transmitted_packets, + base_objects_completed=base_objects_completed, + roi_objects_completed=roi_objects_completed, + atomic_composite_frames_completed=( + atomic_composite_frames_completed + ), + base_only_frames=base_only_frames, + roi_only_frames=roi_only_frames, + incomplete_frames=incomplete_frames, + composite_success_rate=( + atomic_composite_frames_completed / expected_frames + ), + effective_delivered_jpeg_bitrate_kbps=( + delivered_atomic_jpeg_bytes + * 8.0 + / total_simulated_duration + / 1000.0 + ), + mean_lost_fragments_per_frame=float( + np.mean(lost_fragments_per_frame) + ), + p95_lost_fragments_per_frame=float( + np.percentile(lost_fragments_per_frame, 95) + ), + mean_loss_burst_length=( + float(np.mean(loss_burst_lengths)) + if loss_burst_lengths + else 0.0 + ), + max_loss_burst_length=( + max(loss_burst_lengths) if loss_burst_lengths else 0 + ), + loss_burst_distribution=format_burst_distribution( + loss_burst_lengths + ), + ) + + +def run_monte_carlo( + profiles: dict[int, PreparedProfile], + metadata: VideoMetadata, +) -> list[SimulationResult]: + """Run all 45 channel/payload combinations.""" + + results = [] + for parameter_index, loss_probability in enumerate( + PACKET_LOSS_PROBABILITIES + ): + seed = INDEPENDENT_SEED_BASE + parameter_index + for payload_size in PAYLOAD_SIZES: + results.append( + simulate_condition( + profiles[payload_size], + metadata, + model="independent_loss", + parameter_name="packet_loss_probability", + parameter_value=loss_probability, + seed=seed, + repetitions=MONTE_CARLO_REPETITIONS, + ) + ) + + for parameter_index, ber in enumerate(BER_VALUES): + seed = BER_SEED_BASE + parameter_index + for payload_size in PAYLOAD_SIZES: + results.append( + simulate_condition( + profiles[payload_size], + metadata, + model="ber", + parameter_name="bit_error_rate", + parameter_value=ber, + seed=seed, + repetitions=MONTE_CARLO_REPETITIONS, + ) + ) + + for scenario_index, scenario in enumerate(BURST_SCENARIOS): + seed = BURST_SEED_BASE + scenario_index + for payload_size in PAYLOAD_SIZES: + results.append( + simulate_condition( + profiles[payload_size], + metadata, + model="burst", + parameter_name="gilbert_elliott_scenario", + parameter_value=float(scenario_index), + seed=seed, + repetitions=MONTE_CARLO_REPETITIONS, + scenario=scenario, + ) + ) + return results + + +def run_functional_tests( + composites: list[EncodedComposite], + profiles: dict[int, PreparedProfile], + metadata: VideoMetadata, + results: list[SimulationResult], +) -> list[FunctionalTestResult]: + """Verify zero impairment, CRC, isolation, atomicity, and repeatability.""" + + tests: list[tuple[str, Callable[[], str]]] = [] + + def zero_impairment_is_perfect() -> str: + zero_rows = [ + result + for result in results + if ( + result.model in {"independent_loss", "ber"} + and result.parameter_value == 0.0 + ) + ] + if len(zero_rows) != len(PAYLOAD_SIZES) * 2: + raise AssertionError("zero-impairment rows are missing") + for row in zero_rows: + if ( + row.packet_delivery_rate != 1.0 + or row.composite_success_rate != 1.0 + or row.incomplete_frames != 0 + ): + raise AssertionError( + f"zero impairment failed for {row.model}/" + f"{row.payload_size}" + ) + return "all payloads delivered 100% packets and composite frames" + + def crc_rejects_corruption() -> str: + packet = profiles[512].packets[0].wire_packet + corrupted = bytearray(packet) + corrupted[-1] ^= 0x01 + try: + CompositeReassembler().ingest(bytes(corrupted)) + except PacketCRCError: + return "payload bit flip was rejected by packet CRC32" + raise AssertionError("corrupted packet was not rejected by CRC") + + def adjacent_frames_do_not_mix() -> str: + first_profile_frames = profiles[512].frames[:2] + interleaved = [] + first_packets = first_profile_frames[0].packets + second_packets = first_profile_frames[1].packets + for index in range(max(len(first_packets), len(second_packets))): + if index < len(second_packets): + interleaved.append(second_packets[index]) + if index < len(first_packets): + interleaved.append(first_packets[index]) + receiver = CompositeReassembler() + completed = {} + for packet in interleaved: + frame = receiver.ingest(packet) + if frame is not None: + completed[frame.composite_frame_id] = frame + expected = {composites[0].composite_frame_id, composites[1].composite_frame_id} + if set(completed) != expected: + raise AssertionError("adjacent frame IDs were mixed or lost") + for composite in composites[:2]: + frame = completed[composite.composite_frame_id] + if ( + frame.base_jpeg != composite.base_jpeg + or frame.roi_jpeg != composite.roi_jpeg + ): + raise AssertionError("neighboring JPEG objects were mixed") + return "two interleaved neighboring frames remained independent" + + def incomplete_frame_is_not_published() -> str: + frame = profiles[512].frames[0] + receiver = CompositeReassembler() + completed_count = 0 + for packet in frame.packets[:-1]: + if receiver.ingest(packet) is not None: + completed_count += 1 + if completed_count != 0: + raise AssertionError("incomplete composite frame was published") + if not receiver.object_is_complete( + frame.composite_frame_id, ObjectType.BASE + ): + raise AssertionError("complete BASE object was not retained") + return "missing ROI fragment prevented atomic publication" + + def identical_seed_is_reproducible() -> str: + short_profile = PreparedProfile( + payload_size=512, + frames=profiles[512].frames[:2], + packets=tuple( + packet + for packet in profiles[512].packets + if packet.composite_frame_id < 2 + ), + ) + first = simulate_condition( + short_profile, + metadata, + model="independent_loss", + parameter_name="packet_loss_probability", + parameter_value=0.02, + seed=MASTER_SEED + 9999, + repetitions=5, + ) + second = simulate_condition( + short_profile, + metadata, + model="independent_loss", + parameter_name="packet_loss_probability", + parameter_value=0.02, + seed=MASTER_SEED + 9999, + repetitions=5, + ) + if first != second: + raise AssertionError("same seed produced different aggregates") + return "identical seed produced byte-for-byte equal result fields" + + tests.extend( + [ + ("zero_impairment_100_percent", zero_impairment_is_perfect), + ("packet_crc_rejects_bit_error", crc_rejects_corruption), + ("adjacent_frame_isolation", adjacent_frames_do_not_mix), + ("atomic_incomplete_frame", incomplete_frame_is_not_published), + ("fixed_seed_reproducibility", identical_seed_is_reproducible), + ] + ) + + test_results = [] + for name, test in tests: + try: + detail = test() + except Exception as error: + test_results.append( + FunctionalTestResult(name, False, str(error)) + ) + else: + test_results.append( + FunctionalTestResult(name, True, detail) + ) + failed = [result for result in test_results if not result.passed] + if failed: + details = "; ".join( + f"{result.name}: {result.detail}" for result in failed + ) + raise RuntimeError(f"Lab029 functional checks failed: {details}") + return test_results + + +def result_lookup( + results: list[SimulationResult], + model: str, + parameter_value: float | None = None, + scenario: str | None = None, +) -> dict[int, SimulationResult]: + matches = [ + result + for result in results + if ( + result.model == model + and ( + parameter_value is None + or result.parameter_value == parameter_value + ) + and (scenario is None or result.scenario == scenario) + ) + ] + return {result.payload_size: result for result in matches} + + +def save_csv(results: list[SimulationResult]) -> None: + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS) + writer.writeheader() + for result in results: + raw_row = asdict(result) + row = {} + for field_name in CSV_FIELDS: + value = raw_row[field_name] + row[field_name] = ( + f"{value:.12g}" + if isinstance(value, float) + else value + ) + writer.writerow(row) + + +def save_independent_plot(results: list[SimulationResult]) -> None: + figure, axis = plt.subplots(figsize=(9, 5.5)) + loss_percentages = [ + probability * 100.0 + for probability in PACKET_LOSS_PROBABILITIES + ] + for payload_size in PAYLOAD_SIZES: + success = [ + result_lookup( + results, "independent_loss", probability + )[payload_size].composite_success_rate + * 100.0 + for probability in PACKET_LOSS_PROBABILITIES + ] + axis.plot( + loss_percentages, + success, + marker="o", + linewidth=2, + label=f"payload {payload_size} B", + ) + axis.set_xlabel("Independent packet loss probability, %") + axis.set_ylabel("Atomic composite success rate, %") + axis.set_title("Lab029 independent packet loss") + axis.grid(True, alpha=0.3) + axis.legend() + figure.tight_layout() + figure.savefig(INDEPENDENT_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_ber_plot(results: list[SimulationResult]) -> None: + figure, axis = plt.subplots(figsize=(9, 5.5)) + x_values = [1e-8 if ber == 0.0 else ber for ber in BER_VALUES] + for payload_size in PAYLOAD_SIZES: + success = [ + result_lookup(results, "ber", ber)[ + payload_size + ].composite_success_rate + * 100.0 + for ber in BER_VALUES + ] + axis.plot( + x_values, + success, + marker="o", + linewidth=2, + label=f"payload {payload_size} B", + ) + axis.set_xscale("log") + axis.set_xticks(x_values) + axis.set_xticklabels( + ["0", "1e-7", "3e-7", "1e-6", "3e-6", "1e-5"] + ) + axis.set_xlabel("Bit error rate (zero shown at 1e-8 position)") + axis.set_ylabel("Atomic composite success rate, %") + axis.set_title("Lab029 independent bit errors") + axis.grid(True, which="both", alpha=0.3) + axis.legend() + figure.tight_layout() + figure.savefig(BER_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_burst_plot(results: list[SimulationResult]) -> None: + figure, axis = plt.subplots(figsize=(9, 5.5)) + x_positions = np.arange(len(BURST_SCENARIOS)) + for payload_size in PAYLOAD_SIZES: + success = [ + result_lookup( + results, "burst", scenario=scenario.name + )[payload_size].composite_success_rate + * 100.0 + for scenario in BURST_SCENARIOS + ] + axis.plot( + x_positions, + success, + marker="o", + linewidth=2, + label=f"payload {payload_size} B", + ) + axis.set_xticks(x_positions) + axis.set_xticklabels( + [ + f"{scenario.name}\nmean {scenario.expected_mean_burst:g}" + for scenario in BURST_SCENARIOS + ] + ) + axis.set_xlabel("Gilbert-Elliott loss scenario") + axis.set_ylabel("Atomic composite success rate, %") + axis.set_title( + "Lab029 burst loss at 2% stationary packet loss" + ) + axis.grid(True, alpha=0.3) + axis.legend() + figure.tight_layout() + figure.savefig(BURST_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_payload_comparison_plot( + results: list[SimulationResult], +) -> None: + conditions = [ + ( + "Loss 1%", + result_lookup(results, "independent_loss", 0.01), + ), + ("BER 1e-6", result_lookup(results, "ber", 1e-6)), + ( + "Burst medium", + result_lookup(results, "burst", scenario="medium"), + ), + ] + x_positions = np.arange(len(conditions)) + bar_width = 0.24 + figure, axis = plt.subplots(figsize=(9, 5.5)) + for payload_index, payload_size in enumerate(PAYLOAD_SIZES): + values = [ + condition_results[payload_size].composite_success_rate + * 100.0 + for _, condition_results in conditions + ] + axis.bar( + x_positions + + (payload_index - 1) * bar_width, + values, + width=bar_width, + label=f"payload {payload_size} B", + ) + axis.set_xticks(x_positions) + axis.set_xticklabels([name for name, _ in conditions]) + axis.set_ylabel("Atomic composite success rate, %") + axis.set_title("Lab029 payload comparison") + axis.grid(True, axis="y", alpha=0.3) + axis.legend() + figure.tight_layout() + figure.savefig(PAYLOAD_COMPARISON_PLOT_PATH, dpi=160) + plt.close(figure) + + +def save_plots(results: list[SimulationResult]) -> None: + save_independent_plot(results) + save_ber_plot(results) + save_burst_plot(results) + save_payload_comparison_plot(results) + + +def success_matrix_lines( + results: list[SimulationResult], + model: str, + parameters: tuple[float, ...], + parameter_formatter: Callable[[float], str], +) -> list[str]: + lines = [ + "parameter | payload 256 | payload 512 | payload 1024", + "---------:|------------:|------------:|-------------:", + ] + for parameter in parameters: + by_payload = result_lookup(results, model, parameter) + lines.append( + f"{parameter_formatter(parameter)} | " + f"{by_payload[256].composite_success_rate * 100.0:.3f}% | " + f"{by_payload[512].composite_success_rate * 100.0:.3f}% | " + f"{by_payload[1024].composite_success_rate * 100.0:.3f}%" + ) + return lines + + +def burst_table_lines( + results: list[SimulationResult], +) -> list[str]: + lines = [ + ( + "scenario | payload | expected loss | observed loss | " + "mean/max burst | composite success | burst distribution" + ), + ( + "--------:|--------:|--------------:|--------------:|" + "---------------:|------------------:|:------------------" + ), + ] + for scenario in BURST_SCENARIOS: + by_payload = result_lookup( + results, "burst", scenario=scenario.name + ) + for payload_size in PAYLOAD_SIZES: + result = by_payload[payload_size] + lines.append( + f"{scenario.name} | {payload_size} | " + f"{result.expected_loss_probability * 100.0:.3f}% | " + f"{result.observed_loss_probability * 100.0:.3f}% | " + f"{result.mean_loss_burst_length:.3f}/" + f"{result.max_loss_burst_length} | " + f"{result.composite_success_rate * 100.0:.3f}% | " + f"{result.loss_burst_distribution}" + ) + return lines + + +def comparison_table_lines( + results: list[SimulationResult], +) -> list[str]: + loss = result_lookup(results, "independent_loss", 0.01) + ber = result_lookup(results, "ber", 1e-6) + burst = result_lookup(results, "burst", scenario="medium") + lines = [ + ( + "payload | loss 1% success | BER 1e-6 success | " + "medium burst success | Lab028 wire kbit/s" + ), + ( + "-------:|----------------:|-----------------:|" + "---------------------:|--------------------:" + ), + ] + lab028_wire_bitrate = { + 256: 178.233323, + 512: 168.396019, + 1024: 163.662279, + } + for payload_size in PAYLOAD_SIZES: + lines.append( + f"{payload_size} | " + f"{loss[payload_size].composite_success_rate * 100.0:.3f}% | " + f"{ber[payload_size].composite_success_rate * 100.0:.3f}% | " + f"{burst[payload_size].composite_success_rate * 100.0:.3f}% | " + f"{lab028_wire_bitrate[payload_size]:.3f}" + ) + return lines + + +def write_report( + metadata: VideoMetadata, + composites: list[EncodedComposite], + profiles: dict[int, PreparedProfile], + results: list[SimulationResult], + functional_tests: list[FunctionalTestResult], +) -> None: + lines = [ + "Lab029. Прохождение видеопакетов через канал с потерями и ошибками", + "", + "Исходные данные и неизменный транспорт Lab028", + f"- Видео: {SOURCE_VIDEO_PATH}", + ( + f"- Источник: {metadata.width}x{metadata.height}, " + f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, " + f"{metadata.duration_seconds:.6f} с." + ), + ( + f"- Реальных синхронных JPEG-пар: {len(composites)}; " + "BASE 240x135 grayscale Q23, ROI 320x180 grayscale Q33, " + "3 composite fps." + ), + ( + "- Использован protocol/video_packet.py без изменения: " + "32-byte header, packet CRC32, object CRC32, отдельные BASE/ROI " + "и атомарная выдача CompositeReassembler." + ), + ( + "- Payload: " + + ", ".join(str(value) for value in PAYLOAD_SIZES) + + " байт." + ), + ( + "- Пакетов в одном проходе: " + + ", ".join( + f"{payload} B={len(profiles[payload].packets)}" + for payload in PAYLOAD_SIZES + ) + + "." + ), + "", + "Методика Monte Carlo", + f"- Повторов на каждую строку: {MONTE_CARLO_REPETITIONS}.", + ( + f"- Master seed: {MASTER_SEED}; independent seeds " + f"{INDEPENDENT_SEED_BASE}...{INDEPENDENT_SEED_BASE + 5}; " + f"BER seeds {BER_SEED_BASE}...{BER_SEED_BASE + 5}; " + f"burst seeds {BURST_SEED_BASE}...{BURST_SEED_BASE + 2}." + ), + ( + "- Для одинакового параметра один seed повторно используется " + "для payload 256/512/1024, чтобы сравнение было коррелированным " + "и воспроизводимым." + ), + ( + "- packet_delivery_rate = (received - CRC rejected) / " + "transmitted." + ), + ( + "- effective delivered JPEG bitrate учитывает BASE+ROI bytes " + "только атомарно выданных составных кадров и полное " + "симулированное время." + ), + ( + "- Потерянный фрагмент — физически потерянный пакет или пакет, " + "отклонённый проверкой транспорта." + ), + ( + "- FEC, ARQ, повторные передачи и интерливинг не применяются." + ), + "", + "Модели канала", + ( + "- Independent loss: каждый пакет теряется независимо с " + "заданной вероятностью." + ), + ( + "- BER: число независимых битовых ошибок выбирается binomial " + "по полной длине пакета в битах, включая 32-byte header; " + "выбранные биты физически инвертируются перед reassembler." + ), + ( + "- Gilbert-Elliott: Good доставляет все пакеты, Bad теряет все. " + "Начальное состояние выбирается из стационарного распределения." + ), + ( + "- В Lab029 длительность состояния Bad и серии потерь задаётся " + "количеством пакетов, а не физическим временем." + ), + ( + "- Серия из одинакового количества пакетов имеет разную " + "физическую длительность для payload 256, 512 и 1024 байта, " + "поскольку различается время передачи пакета." + ), + ( + "- Доля успешно восстановленных кадров не показывает " + "длительность непрерывного отсутствия нового изображения." + ), + ( + "- Поэтому burst-loss результаты Lab029 не являются " + "окончательным сравнением размеров payload; для временного " + "сравнения предназначена Lab029B." + ), + ] + for scenario in BURST_SCENARIOS: + lines.append( + f" - {scenario.name}: {scenario.description}; " + f"P(G->B)={scenario.good_to_bad_probability:.9f}, " + f"P(B->G)={scenario.bad_to_good_probability:.9f}, " + f"stationary loss={BURST_STATIONARY_LOSS_PROBABILITY:.3%}, " + f"expected mean burst={scenario.expected_mean_burst:.1f}." + ) + + lines.extend(["", "Функциональные проверки"]) + lines.extend( + f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}" + for test in functional_tests + ) + lines.extend( + [ + "", + "1. Independent packet loss — atomic composite success", + *success_matrix_lines( + results, + "independent_loss", + PACKET_LOSS_PROBABILITIES, + lambda value: f"{value * 100.0:.1f}%", + ), + "", + "2. BER — atomic composite success", + *success_matrix_lines( + results, + "ber", + BER_VALUES, + lambda value: "0" if value == 0.0 else f"{value:.0e}", + ), + "", + "3. Burst-loss", + *burst_table_lines(results), + "", + "4. Сравнение payload 256/512/1024", + *comparison_table_lines(results), + "", + "Интерпретация", + ( + "- При фиксированной вероятности потери пакета крупный " + "payload уменьшает число обязательных фрагментов. Поскольку " + "потеря любого фрагмента делает объект неполным, меньшее " + "число пакетов обычно повышает шанс полного кадра." + ), + ( + "- При фиксированном BER длинный отдельный пакет чаще " + "повреждается: P(error)=1-(1-BER)^(packet_bits). Одновременно " + "крупный payload уменьшает число 32-byte headers и общую " + "длину wire-потока, поэтому итоговый composite success " + "определяется обоими эффектами." + ), + ( + "- Атомарная сборка требует одновременной готовности BASE и " + "ROI. Даже полностью собранный BASE не даёт полезного " + "составного кадра без ROI, поэтому composite success ниже " + "успеха каждого объекта отдельно." + ), + ( + "- Burst-loss особенно опасен без интерливинга: соседние " + "фрагменты и иногда соседние кадры теряются одной серией, " + "а CRC только обнаруживает ошибку и не восстанавливает " + "данные." + ), + ( + "- Payload 256 B даёт наименьшую длину отдельного пакета и " + "наименьшую вероятность BER-повреждения одного пакета, но " + "требует больше всего фрагментов и headers." + ), + ( + "- Payload 512 B остаётся умеренным рабочим кандидатом между " + "packet count и длиной отдельного пакета." + ), + ( + "- Payload 1024 B даёт минимум пакетов и header overhead в " + "этих моделях, но каждый пакет длиннее и при его потере " + "пропадает более крупный фрагмент JPEG." + ), + ( + "- Окончательный packet payload автоматически не выбирается; " + "для решения нужны Lab029 и последующие данные реального " + "радиоканала, FEC/ARQ и задержек." + ), + "", + "Артефакты", + f"- CSV: {CSV_PATH}", + f"- Independent loss plot: {INDEPENDENT_PLOT_PATH}", + f"- BER plot: {BER_PLOT_PATH}", + f"- Burst plot: {BURST_PLOT_PATH}", + f"- Payload comparison: {PAYLOAD_COMPARISON_PLOT_PATH}", + ( + "- JPEG, пакеты и бинарные дампы на диск не сохранялись." + ), + "", + ] + ) + REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") + + +def validate_results(results: list[SimulationResult]) -> None: + if len(results) != 45: + raise RuntimeError(f"expected 45 result rows, got {len(results)}") + keys = { + ( + result.model, + result.parameter_value, + result.scenario, + result.payload_size, + ) + for result in results + } + if len(keys) != len(results): + raise RuntimeError("result rows are not unique") + for result in results: + if not 0.0 <= result.packet_delivery_rate <= 1.0: + raise RuntimeError("packet delivery rate is outside 0...1") + if not 0.0 <= result.composite_success_rate <= 1.0: + raise RuntimeError("composite success rate is outside 0...1") + if ( + result.received_packets + result.lost_packets + != result.transmitted_packets + ): + raise RuntimeError("packet receive/loss accounting disagrees") + + +def validate_output_files() -> None: + for path in ( + CSV_PATH, + REPORT_PATH, + INDEPENDENT_PLOT_PATH, + BER_PLOT_PATH, + BURST_PLOT_PATH, + PAYLOAD_COMPARISON_PLOT_PATH, + ): + if not path.exists() or path.stat().st_size <= 0: + raise RuntimeError(f"missing or empty output: {path}") + + +def main() -> None: + print("Lab029: loading real Lab028 BASE/ROI JPEG profile...") + metadata, composites = load_video_profile(SOURCE_VIDEO_PATH) + profiles = prepare_profiles(composites) + print( + f" source={metadata.width}x{metadata.height}, " + f"frames={metadata.frame_count}, composites={len(composites)}" + ) + for payload_size in PAYLOAD_SIZES: + print( + f" payload={payload_size}: " + f"{len(profiles[payload_size].packets)} packets/pass" + ) + + print( + f"Running 45 Monte Carlo conditions, " + f"{MONTE_CARLO_REPETITIONS} repetitions each..." + ) + results = run_monte_carlo(profiles, metadata) + validate_results(results) + + print("Running Lab029 functional checks...") + functional_tests = run_functional_tests( + composites, profiles, metadata, results + ) + for test in functional_tests: + print(f" PASS {test.name}: {test.detail}") + + OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) + save_csv(results) + save_plots(results) + write_report( + metadata, + composites, + profiles, + results, + functional_tests, + ) + validate_output_files() + + print("Representative composite success rates:") + for payload_size in PAYLOAD_SIZES: + loss_result = result_lookup( + results, "independent_loss", 0.01 + )[payload_size] + ber_result = result_lookup( + results, "ber", 1e-6 + )[payload_size] + burst_result = result_lookup( + results, "burst", scenario="medium" + )[payload_size] + print( + f" payload={payload_size}: " + f"loss1%={loss_result.composite_success_rate:.6f}, " + f"BER1e-6={ber_result.composite_success_rate:.6f}, " + f"burst-medium={burst_result.composite_success_rate:.6f}" + ) + print(f"CSV: {CSV_PATH}") + print(f"Report: {REPORT_PATH}") + print("Lab029 completed successfully.") + + +if __name__ == "__main__": + main()