""" 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 experiments.lab028_video_packetization import ( COMPOSITE_FPS, EncodedComposite, SOURCE_VIDEO_PATH, VideoMetadata, load_video_profile, ) from experiments.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()