diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index abe6fb2..3f8a36d 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -75,3 +75,24 @@ git version 2.51.1.windows.1 - Подтверждены packet CRC32, изоляция соседних кадров и атомарная сборка BASE + ROI. - Зафиксировано ограничение пакетной временной шкалы Lab029. - Сравнение помех одинаковой физической длительности перенесено в Lab029B. + +--- + +# Запись 004 + +## Дата + +27 июля 2026 года + +## Тема + +Завершение Lab029B: временная модель серийных потерь. + +## Выполнено + +- Помехи смоделированы на непрерывной временной шкале. +- Контрольная скорость передачи установлена равной 300 кбит/с. +- Исследованы средние длительности Bad 10, 50, 200 и 1000 мс. +- Payload 512 байт выбран для дальнейшей разработки. +- Payload 1024 байта оставлен резервным вариантом. +- Подтверждено, что без исправления ошибок возможны длительные прерывания видеопотока. diff --git a/data/processed/lab029b/lab029b_composite_success.png b/data/processed/lab029b/lab029b_composite_success.png new file mode 100644 index 0000000..0ba9405 Binary files /dev/null and b/data/processed/lab029b/lab029b_composite_success.png differ diff --git a/data/processed/lab029b/lab029b_missing_frame_runs.png b/data/processed/lab029b/lab029b_missing_frame_runs.png new file mode 100644 index 0000000..9e66099 Binary files /dev/null and b/data/processed/lab029b/lab029b_missing_frame_runs.png differ diff --git a/data/processed/lab029b/lab029b_no_image_duration.png b/data/processed/lab029b/lab029b_no_image_duration.png new file mode 100644 index 0000000..d2d46cb Binary files /dev/null and b/data/processed/lab029b/lab029b_no_image_duration.png differ diff --git a/data/processed/lab029b/lab029b_payload_comparison.png b/data/processed/lab029b/lab029b_payload_comparison.png new file mode 100644 index 0000000..daddcab Binary files /dev/null and b/data/processed/lab029b/lab029b_payload_comparison.png differ diff --git a/data/processed/lab029b/lab029b_report.txt b/data/processed/lab029b/lab029b_report.txt new file mode 100644 index 0000000..b6f93b6 --- /dev/null +++ b/data/processed/lab029b/lab029b_report.txt @@ -0,0 +1,74 @@ +Lab029B. Временная модель серийных потерь видеопакетов + +Исходные данные и транспорт +- Видео: data\raw\lab026_rover_source.mp4 +- Источник: 1280x720, 30.000000 fps, 623 кадров, 20.766667 с. +- Использованы все 63 реальные пары: BASE 240x135 grayscale JPEG Q23, ROI 320x180 grayscale JPEG Q33, 3 составных кадра/с. +- Формат Lab028 не изменён: заголовок 32 байта, packet CRC32, object CRC32, отдельные BASE/ROI, атомарный CompositeReassembler. +- Порядок передачи внутри кадра: сначала все BASE-фрагменты, затем все ROI-фрагменты. Поэтому длительная помеха может затрагивать объекты несимметрично. + +Временная модель передачи +- Контрольная скорость: 300 кбит/с. Это параметр лабораторной, а не выбранная скорость радиоканала. +- Длительность пакета = фактическая длина (32-byte header + фактический payload) × 8 / 300000. +- Кадр доступен в момент frame_id / 3. Пакеты BASE и ROI передаются подряд; искусственных межпакетных интервалов нет. Остаток периода до следующего кадра является естественным idle. +- Payload 256: packets=1660, timeline=20.881627 с, packet duration min/max=0.986667/7.680000 мс. +- Payload 512: packets=862, timeline=20.869680 с, packet duration min/max=1.093333/14.506667 мс. +- Payload 1024: packets=478, timeline=20.863707 с, packet duration min/max=1.093333/28.160000 мс. + +Временная модель канала +- Good пропускает пакеты, Bad теряет пакеты. Длительности состояний независимы и экспоненциально распределены. +- Целевая стационарная доля Bad: 2.0%. +- mean Good = mean Bad × (1-0.02)/0.02 = 49 × mean Bad. +- Консервативное допущение: пакет считается потерянным, если хотя бы часть его передачи пересекается с Bad. +- Monte Carlo: 200 повторов на условие; master seed=290290; seeds 290290...290293. +- Один seed для одинаковой длительности повторно используется для payload 256/512/1024. + +Параметры состояний +mean Bad | mean Good | target Bad +--------:|----------:|----------: +10 мс | 490 мс | 2.0% +50 мс | 2450 мс | 2.0% +200 мс | 9800 мс | 2.0% +1000 мс | 49000 мс | 2.0% + +Функциональные проверки +- PASS zero_bad_state_100_percent: all 63 composite frames restored for all payloads +- PASS fixed_seed_reproducibility: identical seed produced identical aggregate fields +- PASS adjacent_frame_isolation: two interleaved neighboring frames remained independent +- PASS atomic_incomplete_frame: missing ROI fragment prevented atomic publication +- PASS stationary_bad_fraction: all observed Bad fractions are within 0.323 percentage points of 2% +- PASS longer_bad_longer_outage: mean held-image interval increased from 0.819350 to 1.295846 s +- PASS actual_packet_transmission_time: duration equals actual wire bits / 300000 for full and short final packets + +Полная сравнительная таблица +Bad ms | payload | Bad actual | tx/rx/lost packets | packet delivery | BASE | ROI | composite | BASE-only | ROI-only | incomplete | lost burst mean/p95/max | incomplete run mean/p95/max | no image mean/p95/max s | recovery mean/p95/max s | video kbit/s +------:|--------:|-----------:|-------------------:|----------------:|-----:|----:|----------:|----------:|---------:|-----------:|-----------------------:|----------------------------:|-------------------------:|------------------------:|------------: +10 | 256 | 2.016% | 332000/320339/11661 | 96.488% | 84.611% | 75.659% | 65.357% | 2426 | 1298 | 4365 | 2.27/5.0/13 | 1.52/3.0/10 | 0.831/1.350/3.650 | 0.547/1.180/3.364 | 102.487 +10 | 512 | 2.008% | 172400/164214/8186 | 95.252% | 85.183% | 77.008% | 66.968% | 2295 | 1265 | 4162 | 1.72/3.0/8 | 1.48/3.0/10 | 0.815/1.343/3.651 | 0.521/1.131/3.353 | 105.043 +10 | 1024 | 2.022% | 95600/89121/6479 | 93.223% | 85.595% | 76.984% | 67.397% | 2293 | 1208 | 4108 | 1.43/3.0/6 | 1.47/3.0/10 | 0.812/1.339/3.651 | 0.513/1.115/3.348 | 105.783 +50 | 256 | 2.041% | 332000/324191/7809 | 97.648% | 95.333% | 92.960% | 90.405% | 621 | 322 | 1209 | 6.43/17.3/37 | 1.10/2.0/3 | 0.694/1.001/1.348 | 0.441/0.727/1.207 | 141.796 +50 | 512 | 2.083% | 172400/167870/4530 | 97.372% | 95.492% | 93.198% | 90.889% | 580 | 291 | 1148 | 3.94/10.0/19 | 1.10/2.0/3 | 0.693/1.000/1.348 | 0.428/0.714/1.196 | 142.643 +50 | 1024 | 2.046% | 95600/92757/2843 | 97.026% | 95.619% | 93.421% | 91.095% | 570 | 293 | 1122 | 2.55/6.0/12 | 1.10/2.0/3 | 0.691/1.000/1.348 | 0.422/0.702/1.191 | 143.006 +200 | 256 | 1.854% | 332000/325460/6540 | 98.030% | 97.540% | 96.762% | 96.230% | 165 | 67 | 475 | 17.82/49.0/80 | 1.32/3.0/4 | 0.765/1.312/1.657 | 0.547/0.970/1.468 | 150.968 +200 | 512 | 1.854% | 172400/168900/3500 | 97.970% | 97.579% | 96.857% | 96.333% | 157 | 66 | 462 | 9.72/26.0/41 | 1.31/3.0/4 | 0.761/1.158/1.657 | 0.536/0.959/1.456 | 151.215 +200 | 1024 | 1.854% | 95600/93582/2018 | 97.889% | 97.627% | 96.905% | 96.381% | 157 | 66 | 456 | 5.70/15.0/24 | 1.31/3.0/4 | 0.762/1.228/1.658 | 0.531/0.954/1.451 | 151.334 +1000 | 256 | 1.678% | 332000/326429/5571 | 98.322% | 98.143% | 98.008% | 97.802% | 43 | 26 | 277 | 59.27/172.4/403 | 2.98/7.4/16 | 1.302/2.809/5.665 | 1.087/2.487/5.585 | 153.470 +1000 | 512 | 1.678% | 172400/169489/2911 | 98.311% | 98.143% | 98.048% | 97.841% | 38 | 26 | 272 | 31.30/91.6/210 | 2.96/7.5/16 | 1.295/2.826/5.665 | 1.077/2.476/5.576 | 153.618 +1000 | 1024 | 1.677% | 95600/93965/1635 | 98.290% | 98.143% | 98.079% | 97.873% | 34 | 26 | 268 | 17.77/50.9/116 | 2.95/7.5/16 | 1.291/2.843/5.665 | 1.071/2.471/5.571 | 153.712 + +Допущения и ограничения +- Не моделируются FEC, интерливинг, ARQ, повторные передачи, искусственные межпакетные интервалы, преамбула физического уровня, управление и телеметрия. +- Bad-интервалы не зависят от границ пакетов; любое частичное пересечение уничтожает пакет целиком. +- no-new-image duration для серии невосстановленных кадров измеряется от публикации предыдущего полного кадра до публикации следующего; при краевых сериях используются начало и конец временной шкалы. +- interference-to-next-frame измеряется от начала каждого Bad-интервала до первого следующего опубликованного составного кадра или до конца шкалы. +- Эффективный видеопоток учитывает JPEG bytes только атомарно восстановленных BASE+ROI и полную длительность расписания. +- Результаты зависят от порядка BASE затем ROI и от контрольной скорости 300 кбит/с. +- Окончательный размер payload автоматически не выбирается. + +Артефакты +- CSV: data\processed\lab029b\lab029b_results.csv +- Composite success: data\processed\lab029b\lab029b_composite_success.png +- No-new-image duration: data\processed\lab029b\lab029b_no_image_duration.png +- Missing frame runs: data\processed\lab029b\lab029b_missing_frame_runs.png +- Payload comparison: data\processed\lab029b\lab029b_payload_comparison.png +- JPEG, пакеты и бинарные дампы не сохранялись. diff --git a/data/processed/lab029b/lab029b_results.csv b/data/processed/lab029b/lab029b_results.csv new file mode 100644 index 0000000..68cfbb8 --- /dev/null +++ b/data/processed/lab029b/lab029b_results.csv @@ -0,0 +1,13 @@ +payload_size,mean_bad_duration_ms,mean_good_duration_ms,target_bad_time_fraction,actual_bad_time_fraction,control_stream_bitrate_kbps,schedule_duration_seconds,monte_carlo_repetitions,seed,transmitted_packets,received_packets,lost_packets,packet_delivery_rate,base_objects_completed,base_object_success_rate,roi_objects_completed,roi_object_success_rate,atomic_composite_frames_completed,composite_success_rate,base_only_frames,roi_only_frames,incomplete_frames,mean_lost_packet_burst,p95_lost_packet_burst,max_lost_packet_burst,mean_consecutive_incomplete_frames,p95_consecutive_incomplete_frames,max_consecutive_incomplete_frames,mean_no_new_image_duration_seconds,p95_no_new_image_duration_seconds,max_no_new_image_duration_seconds,mean_interference_to_next_frame_seconds,p95_interference_to_next_frame_seconds,max_interference_to_next_frame_seconds,effective_delivered_video_bitrate_kbps,bad_interval_count,actual_mean_bad_duration_ms,actual_p95_bad_duration_ms,actual_max_bad_duration_ms +256,10,490,0.02,0.0201609958077,300,20.8816266667,200,290290,332000,320339,11661,0.964876506024,10661,0.846111111111,9533,0.756587301587,8235,0.653571428571,2426,1298,4365,2.27442949093,5,13,1.52409217877,3,10,0.831002299814,1.35024,3.64970666667,0.546677484673,1.18029690393,3.36407888773,102.486509991,8442,9.97380686292,29.8125428395,83.5331876125 +512,10,490,0.02,0.0200817045481,300,20.86968,200,290290,172400,164214,8186,0.952517401392,10733,0.851825396825,9703,0.770079365079,8438,0.669682539683,2295,1265,4162,1.72409435552,3,8,1.47903340441,3,10,0.814651021085,1.34314,3.65056,0.521191488299,1.13097049803,3.3529855544,105.043418011,8411,9.96549156518,29.9160688622,83.5331876125 +1024,10,490,0.02,0.0202168068758,300,20.8637066667,200,290290,95600,89121,6479,0.932228033473,10785,0.855952380952,9700,0.769841269841,8492,0.673968253968,2293,1208,4108,1.43467670505,3,6,1.46976744186,3,10,0.812397042338,1.33936,3.65141333333,0.512839823089,1.11539721678,3.3478655544,105.78281392,8446,9.98810154849,29.7582896487,83.5331876125 +256,50,2450,0.02,0.0204084786408,300,20.8816266667,200,290291,332000,324191,7809,0.976478915663,12012,0.953333333333,11713,0.929603174603,11391,0.904047619048,621,322,1209,6.42716049383,17.3,37,1.10209662716,2,3,0.693585900942,1.00062933333,1.34818666667,0.440632357759,0.727111920855,1.20734749202,141.795746436,1677,50.8243568053,152.864106144,405.431274995 +512,50,2450,0.02,0.0208318699816,300,20.86968,200,290291,172400,167870,4530,0.973723897912,12032,0.954920634921,11743,0.931984126984,11452,0.908888888889,580,291,1148,3.93913043478,10,19,1.1027857829,2,3,0.693375395453,1.00016,1.34818666667,0.427559832342,0.714012825361,1.19625415869,142.643151213,1691,51.4198060694,156.002187328,405.431274995 +1024,50,2450,0.02,0.0204619230445,300,20.8637066667,200,290291,95600,92757,2843,0.970261506276,12048,0.95619047619,11771,0.934206349206,11478,0.910952380952,570,293,1122,2.54749103943,6,12,1.095703125,2,3,0.691450052083,1.00014,1.34818666667,0.422249750197,0.702414015204,1.19113415869,143.006280124,1677,50.913722151,152.938028086,405.431274995 +256,200,9800,0.02,0.0185410363364,300,20.8816266667,200,290292,332000,325460,6540,0.980301204819,12290,0.975396825397,12192,0.967619047619,12125,0.962301587302,165,67,475,17.8201634877,49,80,1.32311977716,3,4,0.765005088208,1.312048,1.65677333333,0.546872959401,0.96963489665,1.46756878424,150.967631513,414,187.037197483,563.305764922,989.194024493 +512,200,9800,0.02,0.0185430633718,300,20.86968,200,290292,172400,168900,3500,0.97969837587,12295,0.975793650794,12204,0.968571428571,12138,0.963333333333,157,66,462,9.72222222222,26,41,1.3125,3,4,0.761154924242,1.15764533333,1.65677333333,0.536053764553,0.95939489665,1.45647545091,151.21462907,414,186.950627434,563.305764922,989.194024493 +1024,200,9800,0.02,0.01854407776,300,20.8637066667,200,290292,95600,93582,2018,0.978891213389,12301,0.97626984127,12210,0.969047619048,12144,0.96380952381,157,66,456,5.70056497175,15,24,1.31412103746,3,4,0.761923535062,1.227696,1.65762666667,0.530838949739,0.95427489665,1.45135545091,151.334042912,414,186.90734241,563.305764922,989.194024493 +256,1000,49000,0.02,0.0167841318375,300,20.8816266667,200,290293,332000,326429,5571,0.983219879518,12366,0.981428571429,12349,0.980079365079,12323,0.978015873016,43,26,277,59.2659574468,172.45,403,2.97849462366,7.4,16,1.30231827957,2.80936533333,5.66493333333,1.08734051838,2.48701382037,5.58511553493,153.469720111,97,722.639123618,2207.25170213,5246.24764036 +512,1000,49000,0.02,0.0167765665288,300,20.86968,200,290293,172400,169489,2911,0.983114849188,12366,0.981428571429,12354,0.980476190476,12328,0.978412698413,38,26,272,31.3010752688,91.6,210,2.95652173913,7.45,16,1.29471797101,2.82593466667,5.66493333333,1.07655508883,2.47643248703,5.57572886827,153.618205933,97,721.900154546,2207.25170213,5246.24764036 +1024,1000,49000,0.02,0.0167727806255,300,20.8637066667,200,290293,95600,93965,1635,0.98289748954,12366,0.981428571429,12358,0.980793650794,12332,0.97873015873,34,26,268,17.7717391304,50.85,116,2.94505494505,7.5,16,1.2905010989,2.84318666667,5.66493333333,1.07132952182,2.47131248703,5.57060886827,153.711503485,97,721.53067001,2207.25170213,5246.24764036 diff --git a/tests/lab029b_time_based_burst_simulation.py b/tests/lab029b_time_based_burst_simulation.py new file mode 100644 index 0000000..038d78d --- /dev/null +++ b/tests/lab029b_time_based_burst_simulation.py @@ -0,0 +1,1378 @@ +""" +Lab029B. Time-based burst loss simulation for Lab028 video packets. + +The existing Lab028 packet format and reassembler are used unchanged. Real +BASE/ROI JPEGs are packetized in the existing order: all BASE fragments of a +composite frame, then all ROI fragments. Packet transmission intervals use +the actual wire length at a laboratory bit rate of 300 kbit/s. + +The channel alternates between exponentially distributed Good and Bad time +states. A packet is conservatively lost if any part of its transmission +interval overlaps Bad. JPEGs and packets remain in memory. +""" + +from __future__ import annotations + +from bisect import bisect_right +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, + HEADER_SIZE, + ObjectType, + decode_packet, +) +from tests.lab028_video_packetization import ( + COMPOSITE_FPS, + EncodedComposite, + SOURCE_VIDEO_PATH, + VideoMetadata, + load_video_profile, +) +from tests.lab029_packet_channel_simulation import ( + PAYLOAD_SIZES, + PreparedProfile, + prepare_profiles, +) + + +OUTPUT_DIRECTORY = Path("data/processed/lab029b") +CSV_PATH = OUTPUT_DIRECTORY / "lab029b_results.csv" +REPORT_PATH = OUTPUT_DIRECTORY / "lab029b_report.txt" +COMPOSITE_SUCCESS_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab029b_composite_success.png" +) +NO_IMAGE_DURATION_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab029b_no_image_duration.png" +) +MISSING_FRAME_RUN_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab029b_missing_frame_runs.png" +) +PAYLOAD_COMPARISON_PLOT_PATH = ( + OUTPUT_DIRECTORY / "lab029b_payload_comparison.png" +) + +CONTROL_STREAM_BITRATE_BPS = 300_000.0 +CONTROL_STREAM_BITRATE_KBPS = CONTROL_STREAM_BITRATE_BPS / 1000.0 +BAD_TIME_FRACTION = 0.02 +MEAN_BAD_DURATIONS_SECONDS = (0.010, 0.050, 0.200, 1.000) +MONTE_CARLO_REPETITIONS = 200 +MASTER_SEED = 290_290 +SEED_BASE = MASTER_SEED +TIME_EPSILON_SECONDS = 1e-12 + +CSV_FIELDS = [ + "payload_size", + "mean_bad_duration_ms", + "mean_good_duration_ms", + "target_bad_time_fraction", + "actual_bad_time_fraction", + "control_stream_bitrate_kbps", + "schedule_duration_seconds", + "monte_carlo_repetitions", + "seed", + "transmitted_packets", + "received_packets", + "lost_packets", + "packet_delivery_rate", + "base_objects_completed", + "base_object_success_rate", + "roi_objects_completed", + "roi_object_success_rate", + "atomic_composite_frames_completed", + "composite_success_rate", + "base_only_frames", + "roi_only_frames", + "incomplete_frames", + "mean_lost_packet_burst", + "p95_lost_packet_burst", + "max_lost_packet_burst", + "mean_consecutive_incomplete_frames", + "p95_consecutive_incomplete_frames", + "max_consecutive_incomplete_frames", + "mean_no_new_image_duration_seconds", + "p95_no_new_image_duration_seconds", + "max_no_new_image_duration_seconds", + "mean_interference_to_next_frame_seconds", + "p95_interference_to_next_frame_seconds", + "max_interference_to_next_frame_seconds", + "effective_delivered_video_bitrate_kbps", + "bad_interval_count", + "actual_mean_bad_duration_ms", + "actual_p95_bad_duration_ms", + "actual_max_bad_duration_ms", +] + + +@dataclass(frozen=True) +class TimeInterval: + start_seconds: float + end_seconds: float + + @property + def duration_seconds(self) -> float: + return self.end_seconds - self.start_seconds + + +@dataclass(frozen=True) +class ScheduledPacket: + composite_frame_id: int + wire_packet: bytes + start_seconds: float + end_seconds: float + + @property + def duration_seconds(self) -> float: + return self.end_seconds - self.start_seconds + + +@dataclass(frozen=True) +class TransmissionSchedule: + payload_size: int + frames: tuple + packets: tuple[ScheduledPacket, ...] + duration_seconds: float + + +@dataclass(frozen=True) +class RepetitionResult: + transmitted_packets: int + received_packets: int + lost_packets: int + base_objects_completed: int + roi_objects_completed: int + atomic_composite_frames_completed: int + base_only_frames: int + roi_only_frames: int + incomplete_frames: int + delivered_jpeg_bytes: int + lost_packet_bursts: tuple[int, ...] + incomplete_frame_runs: tuple[int, ...] + no_new_image_durations: tuple[float, ...] + interference_to_next_frame_delays: tuple[float, ...] + bad_time_seconds: float + bad_durations: tuple[float, ...] + + +@dataclass(frozen=True) +class TimeSimulationResult: + payload_size: int + mean_bad_duration_ms: float + mean_good_duration_ms: float + target_bad_time_fraction: float + actual_bad_time_fraction: float + control_stream_bitrate_kbps: float + schedule_duration_seconds: float + monte_carlo_repetitions: int + seed: int + transmitted_packets: int + received_packets: int + lost_packets: int + packet_delivery_rate: float + base_objects_completed: int + base_object_success_rate: float + roi_objects_completed: int + roi_object_success_rate: float + atomic_composite_frames_completed: int + composite_success_rate: float + base_only_frames: int + roi_only_frames: int + incomplete_frames: int + mean_lost_packet_burst: float + p95_lost_packet_burst: float + max_lost_packet_burst: int + mean_consecutive_incomplete_frames: float + p95_consecutive_incomplete_frames: float + max_consecutive_incomplete_frames: int + mean_no_new_image_duration_seconds: float + p95_no_new_image_duration_seconds: float + max_no_new_image_duration_seconds: float + mean_interference_to_next_frame_seconds: float + p95_interference_to_next_frame_seconds: float + max_interference_to_next_frame_seconds: float + effective_delivered_video_bitrate_kbps: float + bad_interval_count: int + actual_mean_bad_duration_ms: float + actual_p95_bad_duration_ms: float + actual_max_bad_duration_ms: float + + +@dataclass(frozen=True) +class FunctionalTestResult: + name: str + passed: bool + detail: str + + +def mean_good_duration(mean_bad_duration: float) -> float: + """Keep the stationary Bad time fraction at two percent.""" + + return ( + mean_bad_duration + * (1.0 - BAD_TIME_FRACTION) + / BAD_TIME_FRACTION + ) + + +def percentile(values: list[float] | tuple[float, ...], q: float) -> float: + if not values: + return 0.0 + return float(np.percentile(values, q)) + + +def build_transmission_schedule( + profile: PreparedProfile, +) -> TransmissionSchedule: + """ + Schedule real packets at 3 composite fps and 300 kbit/s. + + A frame becomes available at frame_id / 3. BASE packets precede ROI + packets exactly as returned by the Lab028 packets_for_composite helper. + Packets inside one frame are contiguous. If transmission finishes before + the next frame is generated, the remaining frame period is naturally + idle; no artificial inter-packet interval is added. + """ + + scheduled_packets = [] + cursor_seconds = 0.0 + for frame in profile.frames: + nominal_frame_time = ( + frame.composite_frame_id / COMPOSITE_FPS + ) + cursor_seconds = max(cursor_seconds, nominal_frame_time) + object_types = [ + decode_packet(packet).object_type + for packet in frame.packets + ] + first_roi = next( + ( + index + for index, object_type in enumerate(object_types) + if object_type == ObjectType.ROI + ), + len(object_types), + ) + if any( + object_type != ObjectType.BASE + for object_type in object_types[:first_roi] + ) or any( + object_type != ObjectType.ROI + for object_type in object_types[first_roi:] + ): + raise RuntimeError("Lab028 packet order is not BASE then ROI") + + for wire_packet in frame.packets: + duration_seconds = ( + len(wire_packet) * 8.0 / CONTROL_STREAM_BITRATE_BPS + ) + end_seconds = cursor_seconds + duration_seconds + scheduled_packets.append( + ScheduledPacket( + composite_frame_id=frame.composite_frame_id, + wire_packet=wire_packet, + start_seconds=cursor_seconds, + end_seconds=end_seconds, + ) + ) + cursor_seconds = end_seconds + + if not scheduled_packets: + raise RuntimeError("transmission schedule is empty") + return TransmissionSchedule( + payload_size=profile.payload_size, + frames=profile.frames, + packets=tuple(scheduled_packets), + duration_seconds=scheduled_packets[-1].end_seconds, + ) + + +def build_schedules( + profiles: dict[int, PreparedProfile], +) -> dict[int, TransmissionSchedule]: + return { + payload_size: build_transmission_schedule(profiles[payload_size]) + for payload_size in PAYLOAD_SIZES + } + + +def generate_bad_intervals( + horizon_seconds: float, + mean_bad_duration_seconds: float, + rng: np.random.Generator, +) -> tuple[TimeInterval, ...]: + """Generate a stationary alternating exponential Good/Bad timeline.""" + + if horizon_seconds <= 0.0: + raise ValueError("horizon must be positive") + if mean_bad_duration_seconds <= 0.0: + raise ValueError("mean Bad duration must be positive") + + mean_good_seconds = mean_good_duration( + mean_bad_duration_seconds + ) + bad_state = rng.random() < BAD_TIME_FRACTION + current_time = 0.0 + intervals = [] + while current_time < horizon_seconds: + state_mean = ( + mean_bad_duration_seconds + if bad_state + else mean_good_seconds + ) + state_duration = float(rng.exponential(state_mean)) + state_end = min( + current_time + state_duration, + horizon_seconds, + ) + if bad_state and state_end > current_time: + intervals.append( + TimeInterval(current_time, state_end) + ) + current_time += state_duration + bad_state = not bad_state + return tuple(intervals) + + +def packet_loss_flags( + schedule: TransmissionSchedule, + bad_intervals: tuple[TimeInterval, ...], +) -> np.ndarray: + """ + Mark a packet lost when any part of its transmission overlaps Bad. + """ + + flags = np.zeros(len(schedule.packets), dtype=np.bool_) + interval_index = 0 + for packet_index, packet in enumerate(schedule.packets): + while ( + interval_index < len(bad_intervals) + and bad_intervals[interval_index].end_seconds + <= packet.start_seconds + TIME_EPSILON_SECONDS + ): + interval_index += 1 + if interval_index >= len(bad_intervals): + break + interval = bad_intervals[interval_index] + if ( + interval.start_seconds + < packet.end_seconds - TIME_EPSILON_SECONDS + and interval.end_seconds + > packet.start_seconds + TIME_EPSILON_SECONDS + ): + flags[packet_index] = True + return flags + + +def positive_runs(flags: list[bool] | np.ndarray) -> tuple[int, ...]: + runs = [] + current = 0 + for flag in flags: + if bool(flag): + current += 1 + elif current: + runs.append(current) + current = 0 + if current: + runs.append(current) + return tuple(runs) + + +def frame_outage_metrics( + schedule: TransmissionSchedule, + completion_times: dict[int, float], + bad_intervals: tuple[TimeInterval, ...], +) -> tuple[ + tuple[int, ...], + tuple[float, ...], + tuple[float, ...], +]: + """ + Measure failed-frame runs, held-image durations, and recovery delays. + + For each run of incomplete frames, no-new-image duration is measured + from publication of the preceding complete frame to publication of the + next complete frame. At the boundaries, zero or schedule end is used. + Only runs containing at least one incomplete frame are included. + """ + + frame_ids = [ + frame.composite_frame_id for frame in schedule.frames + ] + complete_flags = [ + frame_id in completion_times for frame_id in frame_ids + ] + incomplete_runs = positive_runs( + [not flag for flag in complete_flags] + ) + + no_image_durations = [] + index = 0 + while index < len(frame_ids): + if complete_flags[index]: + index += 1 + continue + run_start = index + while index < len(frame_ids) and not complete_flags[index]: + index += 1 + next_complete_index = index + if run_start > 0: + previous_id = frame_ids[run_start - 1] + start_time = completion_times.get(previous_id, 0.0) + else: + start_time = 0.0 + if next_complete_index < len(frame_ids): + next_id = frame_ids[next_complete_index] + end_time = completion_times[next_id] + else: + end_time = schedule.duration_seconds + no_image_durations.append(max(0.0, end_time - start_time)) + + sorted_completion_times = sorted(completion_times.values()) + recovery_delays = [] + for interval in bad_intervals: + completion_index = bisect_right( + sorted_completion_times, + interval.start_seconds, + ) + if completion_index < len(sorted_completion_times): + next_completion = sorted_completion_times[completion_index] + else: + next_completion = schedule.duration_seconds + recovery_delays.append( + max(0.0, next_completion - interval.start_seconds) + ) + return ( + incomplete_runs, + tuple(no_image_durations), + tuple(recovery_delays), + ) + + +def simulate_repetition( + schedule: TransmissionSchedule, + bad_intervals: tuple[TimeInterval, ...], +) -> RepetitionResult: + """Run one already generated time-channel realization.""" + + loss_flags = packet_loss_flags(schedule, bad_intervals) + receiver = CompositeReassembler() + completion_times: dict[int, float] = {} + received_packets = 0 + delivered_jpeg_bytes = 0 + + for lost, packet in zip(loss_flags, schedule.packets): + if bool(lost): + continue + received_packets += 1 + completed = receiver.ingest(packet.wire_packet) + if completed is not None: + completion_times[completed.composite_frame_id] = ( + packet.end_seconds + ) + delivered_jpeg_bytes += ( + len(completed.base_jpeg) + len(completed.roi_jpeg) + ) + + base_completed = 0 + roi_completed = 0 + base_only = 0 + roi_only = 0 + for frame in schedule.frames: + frame_id = frame.composite_frame_id + atomic = frame_id in completion_times + base = ( + atomic + or receiver.object_is_complete(frame_id, ObjectType.BASE) + ) + roi = ( + atomic + or receiver.object_is_complete(frame_id, ObjectType.ROI) + ) + base_completed += int(base) + roi_completed += int(roi) + base_only += int(base and not roi) + roi_only += int(roi and not base) + + incomplete_runs, no_image_durations, recovery_delays = ( + frame_outage_metrics( + schedule, completion_times, bad_intervals + ) + ) + bad_durations = tuple( + interval.duration_seconds for interval in bad_intervals + ) + transmitted = len(schedule.packets) + completed_count = len(completion_times) + return RepetitionResult( + transmitted_packets=transmitted, + received_packets=received_packets, + lost_packets=transmitted - received_packets, + base_objects_completed=base_completed, + roi_objects_completed=roi_completed, + atomic_composite_frames_completed=completed_count, + base_only_frames=base_only, + roi_only_frames=roi_only, + incomplete_frames=len(schedule.frames) - completed_count, + delivered_jpeg_bytes=delivered_jpeg_bytes, + lost_packet_bursts=positive_runs(loss_flags), + incomplete_frame_runs=incomplete_runs, + no_new_image_durations=no_image_durations, + interference_to_next_frame_delays=recovery_delays, + bad_time_seconds=sum(bad_durations), + bad_durations=bad_durations, + ) + + +def simulate_condition( + schedule: TransmissionSchedule, + mean_bad_duration_seconds: float, + seed: int, + repetitions: int, +) -> TimeSimulationResult: + """Aggregate one payload/time-based interference condition.""" + + rng = np.random.default_rng(seed) + repetitions_results = [] + for _ in range(repetitions): + bad_intervals = generate_bad_intervals( + schedule.duration_seconds, + mean_bad_duration_seconds, + rng, + ) + repetitions_results.append( + simulate_repetition(schedule, bad_intervals) + ) + + transmitted = sum( + result.transmitted_packets for result in repetitions_results + ) + received = sum( + result.received_packets for result in repetitions_results + ) + lost = sum( + result.lost_packets for result in repetitions_results + ) + base_completed = sum( + result.base_objects_completed for result in repetitions_results + ) + roi_completed = sum( + result.roi_objects_completed for result in repetitions_results + ) + composite_completed = sum( + result.atomic_composite_frames_completed + for result in repetitions_results + ) + base_only = sum( + result.base_only_frames for result in repetitions_results + ) + roi_only = sum( + result.roi_only_frames for result in repetitions_results + ) + incomplete = sum( + result.incomplete_frames for result in repetitions_results + ) + delivered_bytes = sum( + result.delivered_jpeg_bytes for result in repetitions_results + ) + lost_bursts = [ + value + for result in repetitions_results + for value in result.lost_packet_bursts + ] + incomplete_runs = [ + value + for result in repetitions_results + for value in result.incomplete_frame_runs + ] + no_image_durations = [ + value + for result in repetitions_results + for value in result.no_new_image_durations + ] + recovery_delays = [ + value + for result in repetitions_results + for value in result.interference_to_next_frame_delays + ] + bad_durations = [ + value + for result in repetitions_results + for value in result.bad_durations + ] + bad_time = sum( + result.bad_time_seconds for result in repetitions_results + ) + + total_frames = len(schedule.frames) * repetitions + total_time = schedule.duration_seconds * repetitions + if received + lost != transmitted: + raise RuntimeError("packet accounting disagrees") + if composite_completed + incomplete != total_frames: + raise RuntimeError("frame accounting disagrees") + if base_completed != composite_completed + base_only: + raise RuntimeError("BASE object accounting disagrees") + if roi_completed != composite_completed + roi_only: + raise RuntimeError("ROI object accounting disagrees") + + return TimeSimulationResult( + payload_size=schedule.payload_size, + mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0, + mean_good_duration_ms=( + mean_good_duration(mean_bad_duration_seconds) * 1000.0 + ), + target_bad_time_fraction=BAD_TIME_FRACTION, + actual_bad_time_fraction=bad_time / total_time, + control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS, + schedule_duration_seconds=schedule.duration_seconds, + monte_carlo_repetitions=repetitions, + seed=seed, + transmitted_packets=transmitted, + received_packets=received, + lost_packets=lost, + packet_delivery_rate=received / transmitted, + base_objects_completed=base_completed, + base_object_success_rate=base_completed / total_frames, + roi_objects_completed=roi_completed, + roi_object_success_rate=roi_completed / total_frames, + atomic_composite_frames_completed=composite_completed, + composite_success_rate=composite_completed / total_frames, + base_only_frames=base_only, + roi_only_frames=roi_only, + incomplete_frames=incomplete, + mean_lost_packet_burst=( + float(np.mean(lost_bursts)) if lost_bursts else 0.0 + ), + p95_lost_packet_burst=percentile(lost_bursts, 95), + max_lost_packet_burst=max(lost_bursts) if lost_bursts else 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=( + max(incomplete_runs) if incomplete_runs else 0 + ), + mean_no_new_image_duration_seconds=( + float(np.mean(no_image_durations)) + if no_image_durations + else 0.0 + ), + p95_no_new_image_duration_seconds=percentile( + no_image_durations, 95 + ), + max_no_new_image_duration_seconds=( + max(no_image_durations) if no_image_durations else 0.0 + ), + mean_interference_to_next_frame_seconds=( + float(np.mean(recovery_delays)) + if recovery_delays + else 0.0 + ), + p95_interference_to_next_frame_seconds=percentile( + recovery_delays, 95 + ), + max_interference_to_next_frame_seconds=( + max(recovery_delays) if recovery_delays else 0.0 + ), + effective_delivered_video_bitrate_kbps=( + delivered_bytes * 8.0 / total_time / 1000.0 + ), + bad_interval_count=len(bad_durations), + actual_mean_bad_duration_ms=( + float(np.mean(bad_durations)) * 1000.0 + if bad_durations + else 0.0 + ), + actual_p95_bad_duration_ms=( + percentile(bad_durations, 95) * 1000.0 + ), + actual_max_bad_duration_ms=( + max(bad_durations) * 1000.0 + if bad_durations + else 0.0 + ), + ) + + +def run_monte_carlo( + schedules: dict[int, TransmissionSchedule], +) -> list[TimeSimulationResult]: + results = [] + for duration_index, mean_bad_duration_seconds in enumerate( + MEAN_BAD_DURATIONS_SECONDS + ): + seed = SEED_BASE + duration_index + for payload_size in PAYLOAD_SIZES: + results.append( + simulate_condition( + schedules[payload_size], + mean_bad_duration_seconds, + seed, + MONTE_CARLO_REPETITIONS, + ) + ) + return results + + +def result_lookup( + results: list[TimeSimulationResult], + mean_bad_duration_ms: float, +) -> dict[int, TimeSimulationResult]: + return { + result.payload_size: result + for result in results + if result.mean_bad_duration_ms == mean_bad_duration_ms + } + + +def run_functional_tests( + composites: list[EncodedComposite], + schedules: dict[int, TransmissionSchedule], + results: list[TimeSimulationResult], +) -> list[FunctionalTestResult]: + tests: list[tuple[str, Callable[[], str]]] = [] + + def no_bad_state_is_perfect() -> str: + for payload_size in PAYLOAD_SIZES: + repetition = simulate_repetition( + schedules[payload_size], () + ) + if ( + repetition.lost_packets != 0 + or repetition.atomic_composite_frames_completed + != len(schedules[payload_size].frames) + ): + raise AssertionError( + f"zero-Bad failed for payload {payload_size}" + ) + return "all 63 composite frames restored for all payloads" + + def same_seed_is_reproducible() -> str: + first = simulate_condition( + schedules[512], 0.050, MASTER_SEED + 999, 5 + ) + second = simulate_condition( + schedules[512], 0.050, MASTER_SEED + 999, 5 + ) + if first != second: + raise AssertionError("identical seed changed the result") + return "identical seed produced identical aggregate fields" + + def adjacent_frames_do_not_mix() -> str: + schedule = schedules[512] + receiver = CompositeReassembler() + completed = {} + selected = [ + packet + for packet in schedule.packets + if packet.composite_frame_id in {0, 1} + ] + first = [p for p in selected if p.composite_frame_id == 0] + second = [p for p in selected if p.composite_frame_id == 1] + interleaved = [] + for index in range(max(len(first), len(second))): + if index < len(second): + interleaved.append(second[index]) + if index < len(first): + interleaved.append(first[index]) + for packet in interleaved: + frame = receiver.ingest(packet.wire_packet) + if frame is not None: + completed[frame.composite_frame_id] = frame + if set(completed) != {0, 1}: + raise AssertionError("neighboring frame IDs were mixed") + for composite in composites[:2]: + restored = completed[composite.composite_frame_id] + if ( + restored.base_jpeg != composite.base_jpeg + or restored.roi_jpeg != composite.roi_jpeg + ): + raise AssertionError("neighboring JPEGs were mixed") + return "two interleaved neighboring frames remained independent" + + def incomplete_frame_is_not_published() -> str: + frame_packets = [ + packet + for packet in schedules[512].packets + if packet.composite_frame_id == 0 + ] + receiver = CompositeReassembler() + published = 0 + for packet in frame_packets[:-1]: + published += int( + receiver.ingest(packet.wire_packet) is not None + ) + if published: + raise AssertionError("incomplete frame was published") + if not receiver.object_is_complete(0, ObjectType.BASE): + raise AssertionError("complete BASE was not retained") + return "missing ROI fragment prevented atomic publication" + + def bad_fraction_is_close() -> str: + worst_error = max( + abs( + result.actual_bad_time_fraction + - result.target_bad_time_fraction + ) + for result in results + ) + if worst_error > 0.01: + raise AssertionError( + f"Bad fraction error is too large: {worst_error:.6f}" + ) + return ( + "all observed Bad fractions are within " + f"{worst_error * 100.0:.3f} percentage points of 2%" + ) + + def longer_bad_means_longer_outage() -> str: + short_mean = float( + np.mean( + [ + result.mean_no_new_image_duration_seconds + for result in results + if result.mean_bad_duration_ms == 10.0 + ] + ) + ) + long_mean = float( + np.mean( + [ + result.mean_no_new_image_duration_seconds + for result in results + if result.mean_bad_duration_ms == 1000.0 + ] + ) + ) + if long_mean <= short_mean: + raise AssertionError( + f"long outage {long_mean} <= short outage {short_mean}" + ) + return ( + f"mean held-image interval increased from " + f"{short_mean:.6f} to {long_mean:.6f} s" + ) + + def packet_time_uses_actual_size() -> str: + schedule = schedules[512] + durations = [ + packet.duration_seconds for packet in schedule.packets + ] + sizes = [len(packet.wire_packet) for packet in schedule.packets] + if len(set(sizes)) < 2 or len(set(durations)) < 2: + raise AssertionError("last packet duration did not differ") + for packet in schedule.packets: + expected = ( + len(packet.wire_packet) + * 8.0 + / CONTROL_STREAM_BITRATE_BPS + ) + if abs(packet.duration_seconds - expected) > 1e-12: + raise AssertionError("packet duration formula disagrees") + if ( + max( + packet.duration_seconds + for packet in schedules[1024].packets + ) + <= max( + packet.duration_seconds + for packet in schedules[256].packets + ) + ): + raise AssertionError("larger payload did not take longer") + return ( + "duration equals actual wire bits / 300000 for full and " + "short final packets" + ) + + tests.extend( + [ + ("zero_bad_state_100_percent", no_bad_state_is_perfect), + ("fixed_seed_reproducibility", same_seed_is_reproducible), + ("adjacent_frame_isolation", adjacent_frames_do_not_mix), + ("atomic_incomplete_frame", incomplete_frame_is_not_published), + ("stationary_bad_fraction", bad_fraction_is_close), + ("longer_bad_longer_outage", longer_bad_means_longer_outage), + ("actual_packet_transmission_time", packet_time_uses_actual_size), + ] + ) + + 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"Lab029B functional checks failed: {details}") + return test_results + + +def validate_results(results: list[TimeSimulationResult]) -> None: + if len(results) != 12: + raise RuntimeError(f"expected 12 rows, got {len(results)}") + keys = { + (result.payload_size, result.mean_bad_duration_ms) + for result in results + } + if len(keys) != 12: + raise RuntimeError("result conditions are not unique") + for result in results: + if ( + result.received_packets + result.lost_packets + != result.transmitted_packets + ): + raise RuntimeError("packet counts disagree") + if not 0.0 <= result.composite_success_rate <= 1.0: + raise RuntimeError("composite success is outside 0...1") + + +def save_csv(results: list[TimeSimulationResult]) -> 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 = asdict(result) + row = { + field: ( + f"{raw[field]:.12g}" + if isinstance(raw[field], float) + else raw[field] + ) + for field in CSV_FIELDS + } + writer.writerow(row) + + +def save_line_plot( + results: list[TimeSimulationResult], + value_getter: Callable[[TimeSimulationResult], float], + ylabel: str, + title: str, + output_path: Path, +) -> None: + x_values = [ + duration * 1000.0 + for duration in MEAN_BAD_DURATIONS_SECONDS + ] + figure, axis = plt.subplots(figsize=(9, 5.5)) + for payload_size in PAYLOAD_SIZES: + values = [ + value_getter( + result_lookup(results, duration_ms)[payload_size] + ) + for duration_ms in x_values + ] + axis.plot( + x_values, + values, + marker="o", + linewidth=2, + label=f"payload {payload_size} B", + ) + axis.set_xscale("log") + axis.set_xticks(x_values) + axis.set_xticklabels( + [f"{value:g}" for value in x_values] + ) + axis.set_xlabel("Средняя длительность Bad, мс") + axis.set_ylabel(ylabel) + axis.set_title(title) + axis.grid(True, which="both", alpha=0.3) + axis.legend() + figure.tight_layout() + figure.savefig(output_path, dpi=160) + plt.close(figure) + + +def save_plots(results: list[TimeSimulationResult]) -> None: + save_line_plot( + results, + lambda result: result.composite_success_rate * 100.0, + "Полностью восстановленные составные кадры, %", + "Lab029B. Успешная атомарная сборка", + COMPOSITE_SUCCESS_PLOT_PATH, + ) + save_line_plot( + results, + lambda result: result.p95_no_new_image_duration_seconds, + "P95 отсутствия нового изображения, с", + "Lab029B. Длительность удержания последнего изображения", + NO_IMAGE_DURATION_PLOT_PATH, + ) + save_line_plot( + results, + lambda result: result.p95_consecutive_incomplete_frames, + "P95 последовательных невосстановленных кадров", + "Lab029B. Серии невосстановленных составных кадров", + MISSING_FRAME_RUN_PLOT_PATH, + ) + + comparison = result_lookup(results, 200.0) + x_positions = np.arange(len(PAYLOAD_SIZES)) + success = [ + comparison[payload].composite_success_rate * 100.0 + for payload in PAYLOAD_SIZES + ] + outage = [ + comparison[ + payload + ].p95_no_new_image_duration_seconds + for payload in PAYLOAD_SIZES + ] + figure, success_axis = plt.subplots(figsize=(9, 5.5)) + outage_axis = success_axis.twinx() + bars = success_axis.bar( + x_positions - 0.18, + success, + width=0.36, + color="tab:blue", + label="Composite success", + ) + outage_bars = outage_axis.bar( + x_positions + 0.18, + outage, + width=0.36, + color="tab:orange", + label="P95 no-new-image", + ) + success_axis.set_xticks(x_positions) + success_axis.set_xticklabels( + [str(payload) for payload in PAYLOAD_SIZES] + ) + success_axis.set_xlabel("Payload, байт") + success_axis.set_ylabel( + "Восстановленные составные кадры, %", + color="tab:blue", + ) + outage_axis.set_ylabel( + "P95 отсутствия нового изображения, с", + color="tab:orange", + ) + success_axis.set_title( + "Lab029B. Сравнение payload при среднем Bad 200 мс" + ) + success_axis.grid(True, axis="y", alpha=0.3) + success_axis.legend( + [bars, outage_bars], + ["Composite success", "P95 no-new-image"], + loc="best", + ) + figure.tight_layout() + figure.savefig(PAYLOAD_COMPARISON_PLOT_PATH, dpi=160) + plt.close(figure) + + +def comparison_table_lines( + results: list[TimeSimulationResult], +) -> list[str]: + lines = [ + ( + "Bad ms | payload | Bad actual | tx/rx/lost packets | " + "packet delivery | BASE | ROI | composite | BASE-only | " + "ROI-only | incomplete | " + "lost burst mean/p95/max | incomplete run mean/p95/max | " + "no image mean/p95/max s | recovery mean/p95/max s | " + "video kbit/s" + ), + ( + "------:|--------:|-----------:|-------------------:|" + "----------------:|-----:|----:|----------:|----------:|" + "---------:|-----------:|" + "-----------------------:|----------------------------:|" + "-------------------------:|------------------------:|" + "------------:" + ), + ] + for duration_seconds in MEAN_BAD_DURATIONS_SECONDS: + duration_ms = duration_seconds * 1000.0 + by_payload = result_lookup(results, duration_ms) + for payload in PAYLOAD_SIZES: + result = by_payload[payload] + lines.append( + f"{duration_ms:.0f} | {payload} | " + f"{result.actual_bad_time_fraction * 100.0:.3f}% | " + f"{result.transmitted_packets}/" + f"{result.received_packets}/" + f"{result.lost_packets} | " + f"{result.packet_delivery_rate * 100.0:.3f}% | " + f"{result.base_object_success_rate * 100.0:.3f}% | " + f"{result.roi_object_success_rate * 100.0:.3f}% | " + f"{result.composite_success_rate * 100.0:.3f}% | " + f"{result.base_only_frames} | {result.roi_only_frames} | " + f"{result.incomplete_frames} | " + f"{result.mean_lost_packet_burst:.2f}/" + f"{result.p95_lost_packet_burst:.1f}/" + f"{result.max_lost_packet_burst} | " + f"{result.mean_consecutive_incomplete_frames:.2f}/" + f"{result.p95_consecutive_incomplete_frames:.1f}/" + f"{result.max_consecutive_incomplete_frames} | " + f"{result.mean_no_new_image_duration_seconds:.3f}/" + f"{result.p95_no_new_image_duration_seconds:.3f}/" + f"{result.max_no_new_image_duration_seconds:.3f} | " + f"{result.mean_interference_to_next_frame_seconds:.3f}/" + f"{result.p95_interference_to_next_frame_seconds:.3f}/" + f"{result.max_interference_to_next_frame_seconds:.3f} | " + f"{result.effective_delivered_video_bitrate_kbps:.3f}" + ) + return lines + + +def write_report( + metadata: VideoMetadata, + composites: list[EncodedComposite], + schedules: dict[int, TransmissionSchedule], + results: list[TimeSimulationResult], + tests: list[FunctionalTestResult], +) -> None: + lines = [ + "Lab029B. Временная модель серийных потерь видеопакетов", + "", + "Исходные данные и транспорт", + f"- Видео: {SOURCE_VIDEO_PATH}", + ( + f"- Источник: {metadata.width}x{metadata.height}, " + f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, " + f"{metadata.duration_seconds:.6f} с." + ), + ( + f"- Использованы все {len(composites)} реальные пары: " + "BASE 240x135 grayscale JPEG Q23, ROI 320x180 grayscale " + "JPEG Q33, 3 составных кадра/с." + ), + ( + f"- Формат Lab028 не изменён: заголовок {HEADER_SIZE} байта, " + "packet CRC32, object CRC32, отдельные BASE/ROI, атомарный " + "CompositeReassembler." + ), + ( + "- Порядок передачи внутри кадра: сначала все BASE-фрагменты, " + "затем все ROI-фрагменты. Поэтому длительная помеха может " + "затрагивать объекты несимметрично." + ), + "", + "Временная модель передачи", + ( + f"- Контрольная скорость: {CONTROL_STREAM_BITRATE_KBPS:.0f} " + "кбит/с. Это параметр лабораторной, а не выбранная скорость " + "радиоканала." + ), + ( + "- Длительность пакета = фактическая длина " + "(32-byte header + фактический payload) × 8 / 300000." + ), + ( + "- Кадр доступен в момент frame_id / 3. Пакеты BASE и ROI " + "передаются подряд; искусственных межпакетных интервалов нет. " + "Остаток периода до следующего кадра является естественным idle." + ), + ] + for payload in PAYLOAD_SIZES: + schedule = schedules[payload] + packet_durations_ms = [ + packet.duration_seconds * 1000.0 + for packet in schedule.packets + ] + lines.append( + f"- Payload {payload}: packets={len(schedule.packets)}, " + f"timeline={schedule.duration_seconds:.6f} с, " + f"packet duration min/max=" + f"{min(packet_durations_ms):.6f}/" + f"{max(packet_durations_ms):.6f} мс." + ) + + lines.extend( + [ + "", + "Временная модель канала", + ( + "- Good пропускает пакеты, Bad теряет пакеты. Длительности " + "состояний независимы и экспоненциально распределены." + ), + ( + f"- Целевая стационарная доля Bad: " + f"{BAD_TIME_FRACTION * 100.0:.1f}%." + ), + ( + "- mean Good = mean Bad × (1-0.02)/0.02 = " + "49 × mean Bad." + ), + ( + "- Консервативное допущение: пакет считается потерянным, " + "если хотя бы часть его передачи пересекается с Bad." + ), + ( + f"- Monte Carlo: {MONTE_CARLO_REPETITIONS} повторов на " + f"условие; master seed={MASTER_SEED}; seeds " + f"{SEED_BASE}...{SEED_BASE + 3}." + ), + ( + "- Один seed для одинаковой длительности повторно " + "используется для payload 256/512/1024." + ), + "", + "Параметры состояний", + "mean Bad | mean Good | target Bad", + "--------:|----------:|----------:", + ] + ) + for duration in MEAN_BAD_DURATIONS_SECONDS: + lines.append( + f"{duration * 1000.0:.0f} мс | " + f"{mean_good_duration(duration) * 1000.0:.0f} мс | " + f"{BAD_TIME_FRACTION * 100.0:.1f}%" + ) + + lines.extend(["", "Функциональные проверки"]) + lines.extend( + f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}" + for test in tests + ) + lines.extend( + [ + "", + "Полная сравнительная таблица", + *comparison_table_lines(results), + "", + "Допущения и ограничения", + ( + "- Не моделируются FEC, интерливинг, ARQ, повторные " + "передачи, искусственные межпакетные интервалы, преамбула " + "физического уровня, управление и телеметрия." + ), + ( + "- Bad-интервалы не зависят от границ пакетов; любое " + "частичное пересечение уничтожает пакет целиком." + ), + ( + "- no-new-image duration для серии невосстановленных кадров " + "измеряется от публикации предыдущего полного кадра до " + "публикации следующего; при краевых сериях используются " + "начало и конец временной шкалы." + ), + ( + "- interference-to-next-frame измеряется от начала каждого " + "Bad-интервала до первого следующего опубликованного " + "составного кадра или до конца шкалы." + ), + ( + "- Эффективный видеопоток учитывает JPEG bytes только " + "атомарно восстановленных BASE+ROI и полную длительность " + "расписания." + ), + ( + "- Результаты зависят от порядка BASE затем ROI и от " + "контрольной скорости 300 кбит/с." + ), + ( + "- Окончательный размер payload автоматически не " + "выбирается." + ), + "", + "Артефакты", + f"- CSV: {CSV_PATH}", + f"- Composite success: {COMPOSITE_SUCCESS_PLOT_PATH}", + f"- No-new-image duration: {NO_IMAGE_DURATION_PLOT_PATH}", + f"- Missing frame runs: {MISSING_FRAME_RUN_PLOT_PATH}", + f"- Payload comparison: {PAYLOAD_COMPARISON_PLOT_PATH}", + "- JPEG, пакеты и бинарные дампы не сохранялись.", + "", + ] + ) + REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") + + +def validate_output_files() -> None: + for path in ( + CSV_PATH, + REPORT_PATH, + COMPOSITE_SUCCESS_PLOT_PATH, + NO_IMAGE_DURATION_PLOT_PATH, + MISSING_FRAME_RUN_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("Lab029B: loading real BASE/ROI JPEGs...") + metadata, composites = load_video_profile(SOURCE_VIDEO_PATH) + profiles = prepare_profiles(composites) + schedules = build_schedules(profiles) + for payload in PAYLOAD_SIZES: + schedule = schedules[payload] + print( + f" payload={payload}: packets={len(schedule.packets)}, " + f"timeline={schedule.duration_seconds:.6f} s" + ) + + print( + f"Running 12 time-channel conditions, " + f"{MONTE_CARLO_REPETITIONS} repetitions each..." + ) + results = run_monte_carlo(schedules) + validate_results(results) + + print("Running Lab029B functional checks...") + tests = run_functional_tests( + composites, schedules, results + ) + for test in 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, + schedules, + results, + tests, + ) + validate_output_files() + + print("Composite success and P95 no-new-image duration:") + for duration in MEAN_BAD_DURATIONS_SECONDS: + duration_ms = duration * 1000.0 + by_payload = result_lookup(results, duration_ms) + for payload in PAYLOAD_SIZES: + result = by_payload[payload] + print( + f" Bad={duration_ms:.0f} ms, payload={payload}: " + f"success={result.composite_success_rate:.6f}, " + f"no-image-p95=" + f"{result.p95_no_new_image_duration_seconds:.6f} s" + ) + print(f"CSV: {CSV_PATH}") + print(f"Report: {REPORT_PATH}") + print("Lab029B completed successfully.") + + +if __name__ == "__main__": + main()