""" Lab032. Parameter sweep for packet-erasure FEC without interleaving. The Lab028 512-byte-payload transport, Lab030 outer packet and GF(256) implementation, and Lab029B continuous-time impairment model are reused unchanged. Eight fixed modes compare block size and parity count at D=1. Systematic symbols precede parity symbols and blocks may cross composite-frame boundaries. No ARQ, retransmission, or automatic mode selection is used. """ from __future__ import annotations import csv from dataclasses import asdict, dataclass from itertools import combinations from math import ceil 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.packet_erasure_fec import ( decode_fec_block, decode_outer_symbol, encode_fec_block, systematic_generator_matrix, ) from protocol.video_packet import ( CompositeReassembler, decode_packet as decode_inner_packet, ) from experiments.lab028_video_packetization import ( COMPOSITE_FPS, SOURCE_VIDEO_PATH, EncodedComposite, VideoMetadata, load_video_profile, ) from experiments.lab029_packet_channel_simulation import ( PreparedProfile, prepare_profiles, ) from experiments.lab029b_time_based_burst_simulation import ( BAD_TIME_FRACTION, CONTROL_STREAM_BITRATE_BPS, CONTROL_STREAM_BITRATE_KBPS, MEAN_BAD_DURATIONS_SECONDS, generate_bad_intervals, percentile, ) from experiments.lab030_packet_erasure_fec import ( FECBlockPlan, FECMode, FunctionalTestResult, ModeSchedule, RepetitionResult, SourcePacket, TransmissionUnit, build_mode_units as build_lab030_mode_units, overlap_loss_flags, prepare_source_packets, schedule_units, simulate_baseline, simulate_fec, ) OUTPUT_DIRECTORY = Path("data/processed/lab032") CSV_PATH = OUTPUT_DIRECTORY / "lab032_results.csv" SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab032_summary.csv" REPORT_PATH = OUTPUT_DIRECTORY / "lab032_report.txt" COMPOSITE_SUCCESS_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab032_composite_success.png" ) STREAM_OVERHEAD_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab032_stream_overhead.png" ) PUBLICATION_DELAY_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab032_publication_delay.png" ) NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab032_no_image_duration.png" BLOCK_SIZE_PLOT_PATH = OUTPUT_DIRECTORY / "lab032_block_size_effect.png" PRACTICAL_FRONTIER_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab032_practical_frontier.png" ) INNER_PAYLOAD_SIZE = 512 MONTE_CARLO_REPETITIONS = 200 MASTER_SEED = 300_300 SEED_BASE = MASTER_SEED @dataclass(frozen=True) class SweepMode: name: str source_count: int parity_count: int label: str @property def code_rate(self) -> float: if self.parity_count == 0: return 1.0 return self.source_count / ( self.source_count + self.parity_count ) MODES = ( SweepMode("none", 0, 0, "Без FEC"), SweepMode("4+1", 4, 1, "4+1"), SweepMode("8+2", 8, 2, "8+2"), SweepMode("12+3", 12, 3, "12+3"), SweepMode("6+2", 6, 2, "6+2"), SweepMode("8+3", 8, 3, "8+3"), SweepMode("4+2", 4, 2, "4+2"), SweepMode("8+4", 8, 4, "8+4"), ) @dataclass(frozen=True) class ParameterSchedule: mode: SweepMode fec_schedule: ModeSchedule block_durations_seconds: tuple[float, ...] decode_ready_durations_seconds: tuple[float, ...] mean_block_duration_seconds: float max_block_duration_seconds: float mean_decode_ready_duration_seconds: float @dataclass(frozen=True) class SimulationResult: mode: str label: str source_block_size: int nominal_parity_count: int code_rate: float mean_bad_duration_ms: float mean_good_duration_ms: float target_bad_time_fraction: float actual_bad_time_fraction: float monte_carlo_repetitions: int seed: int source_jpeg_bitrate_kbps: float inner_packet_stream_bitrate_kbps: float outer_fec_stream_bitrate_kbps: float margin_to_control_bitrate_kbps: float service_and_parity_percent: float control_stream_bitrate_kbps: float schedule_duration_seconds: float fec_block_count: int final_block_source_count: int final_block_parity_count: int mean_block_duration_seconds: float max_block_duration_seconds: float mean_decode_ready_duration_seconds: float mean_queue_length_packets: float max_queue_length_packets: int transmitted_source_packets: int transmitted_parity_packets: int lost_source_packets: int lost_parity_packets: int recovered_source_packets: int fec_recovered_blocks: int fec_unrecoverable_blocks: int fec_affected_block_recovery_rate: float fec_all_block_success_rate: float fec_unrecoverable_block_rate: float base_objects_completed: int base_success_rate: float roi_objects_completed: int roi_success_rate: float atomic_composite_frames_completed: int composite_success_rate: float base_only_frames: int roi_only_frames: int incomplete_frames: int effective_delivered_video_bitrate_kbps: float mean_publication_delay_seconds: float p95_publication_delay_seconds: float max_publication_delay_seconds: float mean_no_new_image_duration_seconds: float p95_no_new_image_duration_seconds: float max_no_new_image_duration_seconds: float mean_consecutive_incomplete_frames: float p95_consecutive_incomplete_frames: float max_consecutive_incomplete_frames: int CSV_FIELDS = [ field.name for field in SimulationResult.__dataclass_fields__.values() ] def parity_for_partial_block( source_count: int, nominal_source_count: int, nominal_parity_count: int, ) -> int: """Scale parity for a non-empty final block.""" if not 1 <= source_count <= nominal_source_count: raise ValueError("partial source count is outside 1...k") if nominal_parity_count < 1: raise ValueError("nominal parity count must be positive") return max( 1, ceil( source_count * nominal_parity_count / nominal_source_count ), ) def build_parameter_units( source_packets: tuple[SourcePacket, ...], mode: SweepMode, ) -> tuple[ tuple[TransmissionUnit, ...], tuple[FECBlockPlan, ...], ]: """Build non-interleaved systematic-then-parity block units.""" if mode.parity_count == 0: units = tuple( TransmissionUnit( sequence_index=index, generation_time_seconds=packet.generation_time_seconds, wire_packet=packet.inner_packet, is_parity=False, block_id=index, symbol_index=0, source_global_index=index, ) for index, packet in enumerate(source_packets) ) return units, () units = [] blocks = [] sequence_index = 0 for block_id, start in enumerate( range(0, len(source_packets), mode.source_count) ): block_sources = source_packets[ start:start + mode.source_count ] source_count = len(block_sources) parity_count = ( mode.parity_count if source_count == mode.source_count else parity_for_partial_block( source_count, mode.source_count, mode.parity_count, ) ) outer_packets = encode_fec_block( tuple( packet.inner_packet for packet in block_sources ), block_id, parity_count, ) parsed = tuple( decode_outer_symbol(packet) for packet in outer_packets ) generation_complete = max( packet.generation_time_seconds for packet in block_sources ) blocks.append( FECBlockPlan( block_id=block_id, source_global_indices=tuple( packet.global_index for packet in block_sources ), source_count=source_count, parity_count=parity_count, symbol_size=parsed[0].symbol_size, ) ) for local_index, (wire_packet, symbol) in enumerate( zip(outer_packets, parsed) ): is_parity = symbol.is_parity units.append( TransmissionUnit( sequence_index=sequence_index, generation_time_seconds=( generation_complete if is_parity else block_sources[ local_index ].generation_time_seconds ), wire_packet=wire_packet, is_parity=is_parity, block_id=block_id, symbol_index=symbol.symbol_index, source_global_index=( None if is_parity else block_sources[ local_index ].global_index ), ) ) sequence_index += 1 return tuple(units), tuple(blocks) def block_timing_metrics( schedule: ModeSchedule, ) -> tuple[tuple[float, ...], tuple[float, ...]]: if not schedule.blocks: return (), () scheduled_by_block = { plan.block_id: [] for plan in schedule.blocks } plans = {plan.block_id: plan for plan in schedule.blocks} for unit in schedule.units: scheduled_by_block[unit.unit.block_id].append(unit) block_durations = [] decode_ready = [] for block_id, units in scheduled_by_block.items(): ordered = sorted( units, key=lambda item: item.unit.symbol_index ) first_start = ordered[0].start_seconds block_durations.append( ordered[-1].end_seconds - first_start ) source_count = plans[block_id].source_count kth_source = next( unit for unit in ordered if unit.unit.symbol_index == source_count - 1 ) decode_ready.append( kth_source.end_seconds - first_start ) return tuple(block_durations), tuple(decode_ready) def build_schedules( metadata: VideoMetadata, composites: list[EncodedComposite], profile: PreparedProfile, ) -> dict[str, ParameterSchedule]: source_packets = prepare_source_packets(profile) total_jpeg_bytes = sum( len(composite.base_jpeg) + len(composite.roi_jpeg) for composite in composites ) total_inner_bytes = sum( len(packet.inner_packet) for packet in source_packets ) schedules = {} for mode in MODES: units, blocks = build_parameter_units( source_packets, mode ) scheduled, duration, mean_queue, max_queue = schedule_units( units ) fec_schedule = ModeSchedule( mode=FECMode( mode.name, mode.parity_count, mode.label ), source_packets=source_packets, blocks=blocks, units=scheduled, source_duration_seconds=metadata.duration_seconds, duration_seconds=duration, total_jpeg_bytes=total_jpeg_bytes, total_inner_bytes=total_inner_bytes, total_transmitted_bytes=sum( len(unit.unit.wire_packet) for unit in scheduled ), mean_queue_length_packets=mean_queue, max_queue_length_packets=max_queue, ) durations, decode_ready = block_timing_metrics( fec_schedule ) schedules[mode.name] = ParameterSchedule( mode=mode, fec_schedule=fec_schedule, block_durations_seconds=durations, decode_ready_durations_seconds=decode_ready, mean_block_duration_seconds=( float(np.mean(durations)) if durations else 0.0 ), max_block_duration_seconds=( max(durations) if durations else 0.0 ), mean_decode_ready_duration_seconds=( float(np.mean(decode_ready)) if decode_ready else 0.0 ), ) return schedules def simulate_condition( schedule: ParameterSchedule, frame_count: int, mean_bad_duration_seconds: float, seed: int, repetitions: int, ) -> SimulationResult: fec_schedule = schedule.fec_schedule rng = np.random.default_rng(seed) repetition_results: list[RepetitionResult] = [] bad_time_seconds = 0.0 for _ in range(repetitions): intervals = generate_bad_intervals( fec_schedule.duration_seconds, mean_bad_duration_seconds, rng, ) bad_time_seconds += sum( interval.duration_seconds for interval in intervals ) loss_flags = overlap_loss_flags(fec_schedule, intervals) repetition_results.append( simulate_baseline( fec_schedule, loss_flags, frame_count ) if schedule.mode.parity_count == 0 else simulate_fec( fec_schedule, loss_flags, frame_count ) ) def total(field: str) -> int: return sum( int(getattr(result, field)) for result in repetition_results ) def flattened(field: str) -> list[float]: return [ float(value) for result in repetition_results for value in getattr(result, field) ] publication_delays = flattened("publication_delays") no_image = flattened("no_new_image_durations") incomplete_runs = flattened("incomplete_frame_runs") recovered_blocks = total("fec_recovered_blocks") failed_blocks = total("fec_unrecoverable_blocks") affected_blocks = recovered_blocks + failed_blocks total_frames = frame_count * repetitions total_fec_blocks = len(fec_schedule.blocks) * repetitions parity_packets_per_pass = sum( int(unit.unit.is_parity) for unit in fec_schedule.units ) source_rate = ( fec_schedule.total_jpeg_bytes * 8.0 / fec_schedule.source_duration_seconds / 1000.0 ) inner_rate = ( fec_schedule.total_inner_bytes * 8.0 / fec_schedule.source_duration_seconds / 1000.0 ) outer_rate = ( fec_schedule.total_transmitted_bytes * 8.0 / fec_schedule.source_duration_seconds / 1000.0 ) final_plan = ( fec_schedule.blocks[-1] if fec_schedule.blocks else None ) return SimulationResult( mode=schedule.mode.name, label=schedule.mode.label, source_block_size=schedule.mode.source_count, nominal_parity_count=schedule.mode.parity_count, code_rate=schedule.mode.code_rate, mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0, mean_good_duration_ms=( mean_bad_duration_seconds * (1.0 - BAD_TIME_FRACTION) / BAD_TIME_FRACTION * 1000.0 ), target_bad_time_fraction=BAD_TIME_FRACTION, actual_bad_time_fraction=( bad_time_seconds / (fec_schedule.duration_seconds * repetitions) ), monte_carlo_repetitions=repetitions, seed=seed, source_jpeg_bitrate_kbps=source_rate, inner_packet_stream_bitrate_kbps=inner_rate, outer_fec_stream_bitrate_kbps=outer_rate, margin_to_control_bitrate_kbps=( CONTROL_STREAM_BITRATE_KBPS - outer_rate ), service_and_parity_percent=( ( fec_schedule.total_transmitted_bytes - fec_schedule.total_jpeg_bytes ) / fec_schedule.total_transmitted_bytes * 100.0 ), control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS, schedule_duration_seconds=fec_schedule.duration_seconds, fec_block_count=len(fec_schedule.blocks), final_block_source_count=( final_plan.source_count if final_plan else 0 ), final_block_parity_count=( final_plan.parity_count if final_plan else 0 ), mean_block_duration_seconds=( schedule.mean_block_duration_seconds ), max_block_duration_seconds=( schedule.max_block_duration_seconds ), mean_decode_ready_duration_seconds=( schedule.mean_decode_ready_duration_seconds ), mean_queue_length_packets=( fec_schedule.mean_queue_length_packets ), max_queue_length_packets=( fec_schedule.max_queue_length_packets ), transmitted_source_packets=( len(fec_schedule.source_packets) * repetitions ), transmitted_parity_packets=( parity_packets_per_pass * repetitions ), lost_source_packets=total("lost_source_packets"), lost_parity_packets=total("lost_parity_packets"), recovered_source_packets=total("recovered_source_packets"), fec_recovered_blocks=recovered_blocks, fec_unrecoverable_blocks=failed_blocks, fec_affected_block_recovery_rate=( recovered_blocks / affected_blocks if affected_blocks else 0.0 ), fec_all_block_success_rate=( (total_fec_blocks - failed_blocks) / total_fec_blocks if total_fec_blocks else 0.0 ), fec_unrecoverable_block_rate=( failed_blocks / total_fec_blocks if total_fec_blocks else 0.0 ), base_objects_completed=total("base_objects_completed"), base_success_rate=( total("base_objects_completed") / total_frames ), roi_objects_completed=total("roi_objects_completed"), roi_success_rate=( total("roi_objects_completed") / total_frames ), atomic_composite_frames_completed=total( "atomic_composite_frames_completed" ), composite_success_rate=( total("atomic_composite_frames_completed") / total_frames ), base_only_frames=total("base_only_frames"), roi_only_frames=total("roi_only_frames"), incomplete_frames=total("incomplete_frames"), effective_delivered_video_bitrate_kbps=( total("delivered_jpeg_bytes") * 8.0 / (fec_schedule.duration_seconds * repetitions) / 1000.0 ), mean_publication_delay_seconds=( float(np.mean(publication_delays)) if publication_delays else 0.0 ), p95_publication_delay_seconds=percentile( publication_delays, 95 ), max_publication_delay_seconds=( max(publication_delays) if publication_delays else 0.0 ), mean_no_new_image_duration_seconds=( float(np.mean(no_image)) if no_image else 0.0 ), p95_no_new_image_duration_seconds=percentile( no_image, 95 ), max_no_new_image_duration_seconds=( max(no_image) if no_image else 0.0 ), mean_consecutive_incomplete_frames=( float(np.mean(incomplete_runs)) if incomplete_runs else 0.0 ), p95_consecutive_incomplete_frames=percentile( incomplete_runs, 95 ), max_consecutive_incomplete_frames=( int(max(incomplete_runs)) if incomplete_runs else 0 ), ) def run_monte_carlo( schedules: dict[str, ParameterSchedule], frame_count: int, ) -> list[SimulationResult]: results = [] for duration_index, duration in enumerate( MEAN_BAD_DURATIONS_SECONDS ): seed = SEED_BASE + duration_index for mode in MODES: results.append( simulate_condition( schedules[mode.name], frame_count, duration, seed, MONTE_CARLO_REPETITIONS, ) ) return results def result_lookup( results: list[SimulationResult], mean_bad_duration_ms: float, ) -> dict[str, SimulationResult]: return { result.mode: result for result in results if result.mean_bad_duration_ms == mean_bad_duration_ms } def run_functional_tests( composites: list[EncodedComposite], schedules: dict[str, ParameterSchedule], results: list[SimulationResult], ) -> list[FunctionalTestResult]: tests: list[tuple[str, Callable[[], str]]] = [] protected_modes = tuple( mode for mode in MODES if mode.parity_count ) source_packets = schedules[ "8+2" ].fec_schedule.source_packets def all_k_r_supported() -> str: for mode in protected_modes: matrix = systematic_generator_matrix( mode.source_count, mode.parity_count, ) if len(matrix) != ( mode.source_count + mode.parity_count ): raise AssertionError( f"bad generator shape for {mode.name}" ) return "GF(256) generated every requested k+r matrix" def recover_any_r_losses() -> str: checked = 0 for mode in protected_modes: sample = tuple( packet.inner_packet for packet in source_packets[ :mode.source_count ] ) outer = encode_fec_block( sample, mode.source_count, mode.parity_count ) for missing in combinations( range(len(outer)), mode.parity_count ): available = tuple( packet for index, packet in enumerate(outer) if index not in missing ) decoded = decode_fec_block(available) if decoded.source_packets != sample: raise AssertionError( f"failed {mode.name}, missing={missing}" ) checked += 1 return f"recovered all {checked} exact-r erasure patterns" def restored_inner_is_exact() -> str: sample = tuple( packet.inner_packet for packet in source_packets[:12] ) outer = encode_fec_block(sample, 77, 3) available = tuple( packet for index, packet in enumerate(outer) if index not in {0, 6, 14} ) decoded = decode_fec_block(available) if decoded.source_packets != sample: raise AssertionError("restored inner bytes differ") return "restored Lab028 packets are byte-exact" def crc_layers_pass() -> str: sample = tuple( packet.inner_packet for packet in source_packets[:8] ) outer = encode_fec_block(sample, 78, 4) available = tuple( packet for index, packet in enumerate(outer) if index not in {1, 4, 8, 11} ) for packet in available: decode_outer_symbol(packet) decoded = decode_fec_block(available) for packet in decoded.source_packets: decode_inner_packet(packet) return "outer and recovered Lab028 CRC32 checks passed" def final_partial_blocks_are_scaled() -> str: checked = [] for mode in protected_modes: final = schedules[mode.name].fec_schedule.blocks[-1] expected = parity_for_partial_block( final.source_count, mode.source_count, mode.parity_count, ) if final.parity_count != expected: raise AssertionError( f"{mode.name}: got {final.parity_count}, " f"expected {expected}" ) checked.append( f"{mode.name}:{final.source_count}+" f"{final.parity_count}" ) return "final blocks: " + ", ".join(checked) def zero_bad_restores_all_frames() -> str: for mode in MODES: schedule = schedules[mode.name].fec_schedule flags = np.zeros(len(schedule.units), dtype=np.bool_) repetition = ( simulate_baseline( schedule, flags, len(composites) ) if mode.parity_count == 0 else simulate_fec( schedule, flags, len(composites) ) ) if ( repetition.atomic_composite_frames_completed != len(composites) ): raise AssertionError( f"zero-Bad failed for {mode.name}" ) return "all eight modes restored 100% of frames without Bad" def fixed_seed_is_reproducible() -> str: first = simulate_condition( schedules["8+3"], len(composites), 0.05, 329_329, 3, ) second = simulate_condition( schedules["8+3"], len(composites), 0.05, 329_329, 3, ) if first != second: raise AssertionError("same seed changed result fields") return "identical seed produced identical result fields" def queue_counts_all_parity() -> str: baseline = schedules["none"].fec_schedule for mode in protected_modes: schedule = schedules[mode.name].fec_schedule parity = sum( int(unit.unit.is_parity) for unit in schedule.units ) if len(schedule.units) != len( baseline.source_packets ) + parity: raise AssertionError( f"{mode.name}: parity absent from FIFO" ) if ( schedule.total_transmitted_bytes <= baseline.total_transmitted_bytes ): raise AssertionError( f"{mode.name}: parity did not add bytes" ) return "every parity packet is included in FIFO timing" def overload_grows_queue() -> str: source = schedules["8+4"].fec_schedule overloaded = tuple( TransmissionUnit( sequence_index=unit.unit.sequence_index, generation_time_seconds=( unit.unit.generation_time_seconds * 0.5 ), wire_packet=unit.unit.wire_packet, is_parity=unit.unit.is_parity, block_id=unit.unit.block_id, symbol_index=unit.unit.symbol_index, source_global_index=( unit.unit.source_global_index ), ) for unit in source.units ) _, duration, mean_queue, max_queue = schedule_units( overloaded ) offered_horizon = ( source.source_duration_seconds * 0.5 ) offered_rate = ( source.total_transmitted_bytes * 8.0 / offered_horizon / 1000.0 ) if offered_rate <= CONTROL_STREAM_BITRATE_KBPS: raise AssertionError("synthetic stream is not overloaded") if duration <= offered_horizon or max_queue <= mean_queue: raise AssertionError("overload did not grow the queue") return ( f"{offered_rate:.3f} kbit/s produced queue " f"mean/max {mean_queue:.3f}/{max_queue}" ) def incomplete_composite_is_not_published() -> str: receiver = CompositeReassembler() first_frame = [ packet.inner_packet for packet in source_packets if packet.composite_frame_id == 0 ] for packet in first_frame[:-1]: if receiver.ingest(packet) is not None: raise AssertionError( "incomplete composite was published" ) return "missing final ROI fragment prevented publication" def lab030_8_2_is_reproduced() -> str: reference_units, reference_blocks = ( build_lab030_mode_units( source_packets, FECMode("8+2", 2, "8+2"), ) ) schedule = schedules["8+2"].fec_schedule if tuple( unit.wire_packet for unit in reference_units ) != tuple( unit.unit.wire_packet for unit in schedule.units ): raise AssertionError("8+2 wire order differs from Lab030") if reference_blocks != schedule.blocks: raise AssertionError("8+2 block plans differ from Lab030") with Path( "data/processed/lab030/lab030_results.csv" ).open(newline="", encoding="utf-8") as file: reference_rows = { float(row["mean_bad_duration_ms"]): row for row in csv.DictReader(file) if row["mode"] == "8+2" } current_rows = { row.mean_bad_duration_ms: row for row in results if row.mode == "8+2" } for duration, current in current_rows.items(): reference = reference_rows[duration] if abs( current.composite_success_rate - float(reference["composite_success_rate"]) ) > 1e-12: raise AssertionError( f"8+2 composite differs at {duration} ms" ) if abs( current.p95_publication_delay_seconds - float( reference[ "p95_publication_delay_seconds" ] ) ) > 1e-12: raise AssertionError( f"8+2 delay differs at {duration} ms" ) return "8+2 exactly reproduced Lab030 order and four rows" tests.extend( [ ("all_k_r_supported", all_k_r_supported), ("recover_any_r_erasures", recover_any_r_losses), ("restored_inner_byte_exact", restored_inner_is_exact), ("inner_outer_crc", crc_layers_pass), ( "final_partial_block_scaling", final_partial_blocks_are_scaled, ), ("zero_bad_100_percent", zero_bad_restores_all_frames), ("fixed_seed_reproducibility", fixed_seed_is_reproducible), ("queue_counts_parity", queue_counts_all_parity), ("overload_grows_queue", overload_grows_queue), ( "atomic_incomplete_composite", incomplete_composite_is_not_published, ), ("lab030_8_2_reproduction", lab030_8_2_is_reproduced), ] ) 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: raise RuntimeError( "Lab032 functional checks failed: " + "; ".join( f"{result.name}: {result.detail}" for result in failed ) ) return test_results def validate_results(results: list[SimulationResult]) -> None: expected = len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS) if len(results) != expected: raise RuntimeError( f"expected {expected} rows, got {len(results)}" ) keys = { (result.mode, result.mean_bad_duration_ms) for result in results } if len(keys) != expected: raise RuntimeError("Lab032 result rows are not unique") for result in results: if not 0.0 <= result.composite_success_rate <= 1.0: raise RuntimeError("composite success outside 0...1") if not 0.0 <= result.base_success_rate <= 1.0: raise RuntimeError("BASE success outside 0...1") if not 0.0 <= result.roi_success_rate <= 1.0: raise RuntimeError("ROI success outside 0...1") def save_results_csv(results: list[SimulationResult]) -> None: with CSV_PATH.open("w", newline="", encoding="utf-8") as file: writer = csv.DictWriter(file, fieldnames=CSV_FIELDS) writer.writeheader() for result in results: writer.writerow(asdict(result)) def summary_rows( results: list[SimulationResult], ) -> list[dict[str, object]]: lookups = { duration: result_lookup(results, duration) for duration in (10.0, 50.0, 200.0, 1000.0) } rows = [] for mode in MODES: representative = lookups[200.0][mode.name] row: dict[str, object] = { "mode": mode.name, "label": mode.label, "source_block_size": mode.source_count, "nominal_parity_count": mode.parity_count, "code_rate": mode.code_rate, "outer_fec_stream_bitrate_kbps": ( representative.outer_fec_stream_bitrate_kbps ), "margin_to_control_bitrate_kbps": ( representative.margin_to_control_bitrate_kbps ), "service_and_parity_percent": ( representative.service_and_parity_percent ), "fec_block_count": representative.fec_block_count, "mean_block_duration_seconds": ( representative.mean_block_duration_seconds ), "max_block_duration_seconds": ( representative.max_block_duration_seconds ), "mean_decode_ready_duration_seconds": ( representative.mean_decode_ready_duration_seconds ), "mean_queue_length_packets": ( representative.mean_queue_length_packets ), "max_queue_length_packets": ( representative.max_queue_length_packets ), } for duration in (10, 50, 200, 1000): result = lookups[float(duration)][mode.name] row[f"composite_success_{duration}ms"] = ( result.composite_success_rate ) row[f"p95_publication_delay_{duration}ms_seconds"] = ( result.p95_publication_delay_seconds ) row[f"p95_no_new_image_{duration}ms_seconds"] = ( result.p95_no_new_image_duration_seconds ) rows.append(row) return rows def save_summary_csv(results: list[SimulationResult]) -> None: rows = summary_rows(results) with SUMMARY_CSV_PATH.open( "w", newline="", encoding="utf-8" ) as file: writer = csv.DictWriter( file, fieldnames=list(rows[0]) ) writer.writeheader() writer.writerows(rows) def _rows_for_mode( results: list[SimulationResult], mode_name: str, ) -> list[SimulationResult]: return sorted( ( result for result in results if result.mode == mode_name ), key=lambda result: result.mean_bad_duration_ms, ) def _save_line_plot( results: list[SimulationResult], field: str, ylabel: str, title: str, path: Path, ) -> None: figure, axis = plt.subplots(figsize=(11.0, 6.5)) for mode in MODES: rows = _rows_for_mode(results, mode.name) axis.plot( [row.mean_bad_duration_ms for row in rows], [getattr(row, field) for row in rows], marker="o", linewidth=1.8, label=mode.label, ) axis.set_xscale("log") axis.set_xlabel("Средняя длительность Bad, мс") axis.set_ylabel(ylabel) axis.set_title(title) axis.grid(True, which="both", alpha=0.28) axis.legend(ncol=2, fontsize=8) figure.tight_layout() figure.savefig(path, dpi=160) plt.close(figure) def practical_frontier( results: list[SimulationResult], mean_bad_duration_ms: float = 200.0, ) -> list[SimulationResult]: rows = list( result_lookup(results, mean_bad_duration_ms).values() ) frontier = [] for candidate in rows: dominated = any( other.mode != candidate.mode and ( other.outer_fec_stream_bitrate_kbps <= candidate.outer_fec_stream_bitrate_kbps ) and ( other.p95_publication_delay_seconds <= candidate.p95_publication_delay_seconds ) and ( other.composite_success_rate >= candidate.composite_success_rate ) and ( other.outer_fec_stream_bitrate_kbps < candidate.outer_fec_stream_bitrate_kbps or other.p95_publication_delay_seconds < candidate.p95_publication_delay_seconds or other.composite_success_rate > candidate.composite_success_rate ) for other in rows ) if not dominated: frontier.append(candidate) return sorted( frontier, key=lambda result: result.outer_fec_stream_bitrate_kbps, ) def save_plots(results: list[SimulationResult]) -> None: _save_line_plot( results, "composite_success_rate", "Доля восстановленных составных кадров", "Lab032. Атомарное восстановление BASE + ROI", COMPOSITE_SUCCESS_PLOT_PATH, ) _save_line_plot( results, "p95_publication_delay_seconds", "P95 задержки публикации, с", "Lab032. Задержка публикации составного кадра", PUBLICATION_DELAY_PLOT_PATH, ) _save_line_plot( results, "p95_no_new_image_duration_seconds", "P95 отсутствия нового изображения, с", "Lab032. Длительность удержания изображения", NO_IMAGE_PLOT_PATH, ) representative = result_lookup(results, 200.0) rows = [representative[mode.name] for mode in MODES] positions = np.arange(len(rows)) figure, rate_axis = plt.subplots(figsize=(11.4, 6.5)) overhead_axis = rate_axis.twinx() rate_bars = rate_axis.bar( positions - 0.2, [row.outer_fec_stream_bitrate_kbps for row in rows], width=0.4, color="#1565c0", label="Внешний поток", ) overhead_bars = overhead_axis.bar( positions + 0.2, [row.service_and_parity_percent for row in rows], width=0.4, color="#ef6c00", alpha=0.72, label="Служебная доля", ) limit_line = rate_axis.axhline( CONTROL_STREAM_BITRATE_KBPS, color="#b71c1c", linestyle="--", linewidth=1.5, label="300 кбит/с", ) rate_axis.set_xticks( positions, [mode.label for mode in MODES], rotation=24, ha="right", ) rate_axis.set_ylabel("Внешний поток, кбит/с") overhead_axis.set_ylabel("Служебные и parity, %") rate_axis.set_title("Lab032. Скорость потока и избыточность") rate_axis.grid(True, axis="y", alpha=0.25) rate_axis.legend( [rate_bars, overhead_bars, limit_line], ["Внешний поток", "Служебная доля", "300 кбит/с"], loc="upper left", ) figure.tight_layout() figure.savefig(STREAM_OVERHEAD_PLOT_PATH, dpi=160) plt.close(figure) equal_rate_modes = ("4+1", "8+2", "12+3") figure, success_axis = plt.subplots(figsize=(10.4, 6.3)) decode_axis = success_axis.twinx() block_sizes = [ representative[name].source_block_size for name in equal_rate_modes ] for duration in (10.0, 50.0, 200.0, 1000.0): lookup = result_lookup(results, duration) success_axis.plot( block_sizes, [ lookup[name].composite_success_rate for name in equal_rate_modes ], marker="o", linewidth=1.8, label=f"Bad {duration:.0f} мс", ) decode_axis.plot( block_sizes, [ representative[ name ].mean_decode_ready_duration_seconds for name in equal_rate_modes ], color="#c62828", marker="s", linestyle="--", linewidth=2.0, label="До декодирования", ) success_axis.set_xticks(block_sizes) success_axis.set_xlabel("Число исходных пакетов k") success_axis.set_ylabel("Доля восстановленных кадров") decode_axis.set_ylabel("Среднее время до декодирования, с") success_axis.set_title( "Lab032. Влияние размера блока при кодовой скорости 0.8" ) success_axis.grid(True, alpha=0.28) lines = success_axis.lines + decode_axis.lines success_axis.legend( lines, [line.get_label() for line in lines], loc="best", fontsize=8, ) figure.tight_layout() figure.savefig(BLOCK_SIZE_PLOT_PATH, dpi=160) plt.close(figure) figure, axis = plt.subplots(figsize=(10.7, 6.5)) delay_values = np.array( [row.p95_publication_delay_seconds for row in rows] ) scatter = axis.scatter( [row.outer_fec_stream_bitrate_kbps for row in rows], [row.composite_success_rate for row in rows], c=delay_values, s=100, cmap="viridis_r", edgecolors="black", linewidths=0.6, ) for row in rows: axis.annotate( row.label, ( row.outer_fec_stream_bitrate_kbps, row.composite_success_rate, ), xytext=(5, 5), textcoords="offset points", fontsize=8, ) frontier = practical_frontier(results) axis.plot( [ row.outer_fec_stream_bitrate_kbps for row in frontier ], [row.composite_success_rate for row in frontier], color="#d32f2f", linestyle="--", linewidth=1.4, label="Недоминируемая граница", ) axis.axvline( CONTROL_STREAM_BITRATE_KBPS, color="#555555", linestyle=":", linewidth=1.3, ) axis.set_xlabel("Внешний поток, кбит/с") axis.set_ylabel("Доля восстановленных кадров") axis.set_title( "Lab032. Практическая граница при Bad 200 мс" ) axis.grid(True, alpha=0.25) axis.legend(loc="lower right") colorbar = figure.colorbar(scatter, ax=axis) colorbar.set_label("P95 задержки публикации, с") figure.tight_layout() figure.savefig(PRACTICAL_FRONTIER_PLOT_PATH, dpi=160) plt.close(figure) def _summary_table( results: list[SimulationResult], ) -> list[str]: lookups = { duration: result_lookup(results, duration) for duration in (10.0, 50.0, 200.0, 1000.0) } lines = [ ( "mode | rate | outer/margin kbit/s | composite " "10/50/200/1000 ms | pub p95 200 ms s | no-image " "p95 200 ms s | block mean/max ms | decode mean ms | " "queue mean/max" ), ( "----:|-----:|----------------------:|" "----------------------------:|-------------------:" "|------------------------:|------------------:" "|---------------:|---------------:" ), ] for mode in MODES: row = lookups[200.0][mode.name] composites = "/".join( f"{lookups[duration][mode.name].composite_success_rate:.6f}" for duration in (10.0, 50.0, 200.0, 1000.0) ) lines.append( f"{mode.label} | {mode.code_rate:.6f} | " f"{row.outer_fec_stream_bitrate_kbps:.3f}/" f"{row.margin_to_control_bitrate_kbps:.3f} | " f"{composites} | " f"{row.p95_publication_delay_seconds:.6f} | " f"{row.p95_no_new_image_duration_seconds:.6f} | " f"{row.mean_block_duration_seconds * 1000:.3f}/" f"{row.max_block_duration_seconds * 1000:.3f} | " f"{row.mean_decode_ready_duration_seconds * 1000:.3f} | " f"{row.mean_queue_length_packets:.3f}/" f"{row.max_queue_length_packets}" ) return lines def _detailed_200ms_table( results: list[SimulationResult], ) -> list[str]: rows = result_lookup(results, 200.0) lines = [ ( "mode | BASE/ROI/composite | blocks ok/fail | restored " "packets | delivered kbit/s | publication mean/p95/max s " "| no-image mean/p95/max s | incomplete mean/p95/max" ), ( "----:|-------------------:|---------------:|" "-----------------:|-----------------:|" "--------------------------:|-----------------------:" "|------------------------:" ), ] for mode in MODES: row = rows[mode.name] lines.append( f"{mode.label} | {row.base_success_rate:.6f}/" f"{row.roi_success_rate:.6f}/" f"{row.composite_success_rate:.6f} | " f"{row.fec_all_block_success_rate:.6f}/" f"{row.fec_unrecoverable_block_rate:.6f} | " f"{row.recovered_source_packets} | " f"{row.effective_delivered_video_bitrate_kbps:.3f} | " f"{row.mean_publication_delay_seconds:.6f}/" f"{row.p95_publication_delay_seconds:.6f}/" f"{row.max_publication_delay_seconds:.6f} | " f"{row.mean_no_new_image_duration_seconds:.6f}/" f"{row.p95_no_new_image_duration_seconds:.6f}/" f"{row.max_no_new_image_duration_seconds:.6f} | " f"{row.mean_consecutive_incomplete_frames:.3f}/" f"{row.p95_consecutive_incomplete_frames:.3f}/" f"{row.max_consecutive_incomplete_frames}" ) return lines def write_report( metadata: VideoMetadata, composites: list[EncodedComposite], schedules: dict[str, ParameterSchedule], results: list[SimulationResult], tests: list[FunctionalTestResult], ) -> None: representative = result_lookup(results, 200.0) equal_rate = [ representative[name] for name in ("4+1", "8+2", "12+3") ] strong = [ representative[name] for name in ("4+2", "8+4") ] intermediate = representative["8+3"] lower = representative["8+2"] upper = representative["8+4"] frontier = practical_frontier(results) best_resilience = max( representative.values(), key=lambda result: result.composite_success_rate, ) minimum_rate = min( representative.values(), key=lambda result: result.outer_fec_stream_bitrate_kbps, ) protected = [ row for row in representative.values() if row.nominal_parity_count ] minimum_protected_delay = min( protected, key=lambda result: result.p95_publication_delay_seconds, ) lines = [ "Lab032. Подбор параметров блочного исправления потерь", "", "Исходный профиль и неизменные слои", f"- Видео: {SOURCE_VIDEO_PATH}", ( f"- {len(composites)} реальных пар BASE/ROI, " f"{COMPOSITE_FPS:.0f} fps; длительность " f"{metadata.duration_seconds:.6f} с." ), ( "- BASE 240x135 grayscale JPEG Q23; ROI 320x180 " "grayscale JPEG Q33." ), ( "- Внутренний Lab028 packet: payload 512 байт; внешний " "формат и GF(256) Lab030 не изменены." ), ( "- Глубина D=1: systematic symbols блока передаются " "первыми, затем parity; перемежение отсутствует." ), ( "- Блоки формируются из непрерывного потока внутренних " "пакетов и могут пересекать composite_frame_id." ), "", "Последний неполный блок", "- Используется единое правило:", " r_last = max(1, ceil(k_last × r / k)).", ( "- Нулевое дополнение существует только внутри математики " "GF(256); внешние пакеты синтетически не добавляются." ), "", "Сравнительная таблица восьми режимов", *_summary_table(results), "", "Подробные результаты при средней Bad 200 мс", *_detailed_200ms_table(results), "", "Временная модель и очередь", ( "- Непрерывные чередующиеся экспоненциальные Good/Bad " "интервалы Lab029B, средняя доля Bad около 2%." ), ( "- Средние Bad 10, 50, 200 и 1000 мс; 200 повторов; " f"fixed seeds {SEED_BASE}...{SEED_BASE + 3}." ), ( "- Пакет теряется при любом пересечении передачи с Bad. " "FIFO учитывает фактическую длину каждого внешнего пакета " "и скорость 300 кбит/с." ), ( "- Все исследованные режимы остаются ниже 300 кбит/с; " "положительный запас показан в таблице. Отдельная " "функциональная проверка ускоряет поток 8+4 вдвое и " "подтверждает реальное накопление очереди при перегрузке." ), ( "- Block duration измеряется от начала первого до конца " "последнего символа. Decode-ready без потерь — от начала " "первого до конца k-го systematic symbol." ), "", "1. Одинаковая кодовая скорость 0.8: 4+1, 8+2 и 12+3", *[ ( f"- {row.label}: outer " f"{row.outer_fec_stream_bitrate_kbps:.3f} кбит/с, " f"block mean " f"{row.mean_block_duration_seconds * 1000:.3f} мс, " f"decode-ready " f"{row.mean_decode_ready_duration_seconds * 1000:.3f} " f"мс, composite@200 " f"{row.composite_success_rate:.6f}." ) for row in equal_rate ], ( "- При одинаковой номинальной code rate длина блока " "меняет временное окно, в котором одна помеха может собрать " "несколько стираний. Короткий блок раньше получает k " "systematic symbols и быстрее допускает декодирование." ), ( "- Длинный блок дольше остаётся открытым и может включить " "больше потерянных пакетов одной длительной помехи; при этом " "его больший абсолютный r иногда полезен для рассеянных " "стираний." ), "", "2. Одинаковая code rate 2/3: 4+2 и 8+4", *[ ( f"- {row.label}: outer " f"{row.outer_fec_stream_bitrate_kbps:.3f} кбит/с, " f"composite@200 " f"{row.composite_success_rate:.6f}, " f"publication P95 " f"{row.p95_publication_delay_seconds:.6f} с, " f"no-image P95 " f"{row.p95_no_new_image_duration_seconds:.6f} с." ) for row in strong ], ( "- Сравнение изолирует влияние длины блока при одинаковой " "сильной избыточности: короткий 4+2 быстрее закрывается, " "длинный 8+4 имеет больше абсолютных parity symbols, но " "дольше накапливает стирания." ), "", "3. Влияние увеличения r", ( "- Большее r позволяет исправить больше стираний, но " "увеличивает внешний поток, служебную долю, время передачи " "блока и публикационную задержку." ), ( f"- 8+3: outer " f"{intermediate.outer_fec_stream_bitrate_kbps:.3f} кбит/с " f"(8+2 {lower.outer_fec_stream_bitrate_kbps:.3f}, " f"8+4 {upper.outer_fec_stream_bitrate_kbps:.3f}); " f"composite@200 {intermediate.composite_success_rate:.6f} " f"(8+2 {lower.composite_success_rate:.6f}, " f"8+4 {upper.composite_success_rate:.6f}); " f"P95 delay {intermediate.p95_publication_delay_seconds:.6f} " f"с (8+2 {lower.p95_publication_delay_seconds:.6f}, " f"8+4 {upper.p95_publication_delay_seconds:.6f})." ), ( "- Поэтому 8+3 оценивается по фактическому положению между " "8+2 и 8+4 сразу по скорости, устойчивости и задержке, без " "автоматического назначения рабочим режимом." ), "", "4. Практическая граница без автоматического выбора", ( f"- Минимальный поток: {minimum_rate.label}, " f"{minimum_rate.outer_fec_stream_bitrate_kbps:.3f} кбит/с." ), ( f"- Максимальная composite-устойчивость при Bad 200 мс: " f"{best_resilience.label}, " f"{best_resilience.composite_success_rate:.6f}." ), ( f"- Минимальная P95-задержка среди защищённых режимов: " f"{minimum_protected_delay.label}, " f"{minimum_protected_delay.p95_publication_delay_seconds:.6f} " "с." ), ( "- Недоминируемые по outer bitrate↓, publication P95↓ и " "composite success↑ режимы при Bad 200 мс: " + ", ".join(row.label for row in frontier) + "." ), ( "- Это набор компромиссов, а не автоматический выбор " "единственного режима." ), "", "Функциональные проверки", *[ f"- {'PASS' if test.passed else 'FAIL'} " f"{test.name}: {test.detail}" for test in tests ], "", "Допущения модели", ( "- JPEG кодируются один раз в памяти; вычислительная " "задержка GF(256) считается нулевой." ), ( "- Стирается весь внешний пакет; отдельные битовые ошибки " "не моделируются, но оба уровня CRC проверяются." ), ( "- Publication delay считается от frame_id/3 до конца " "пакета, завершившего атомарную сборку BASE+ROI." ), ( "- No-image заканчивается публикацией следующего полного " "кадра или концом расписания; неполный кадр не публикуется." ), ( "- ARQ, повторы, глубокое перемежение, команды, телеметрия, " "модуляция и реальный SDR отсутствуют." ), "", "Артефакты", f"- Полный CSV: {CSV_PATH}", f"- Сводный CSV: {SUMMARY_CSV_PATH}", f"- Отчёт: {REPORT_PATH}", f"- Доля восстановленных кадров: {COMPOSITE_SUCCESS_PLOT_PATH}", f"- Скорость и избыточность: {STREAM_OVERHEAD_PLOT_PATH}", f"- Задержка публикации: {PUBLICATION_DELAY_PLOT_PATH}", f"- Отсутствие изображения: {NO_IMAGE_PLOT_PATH}", f"- Влияние размера блока: {BLOCK_SIZE_PLOT_PATH}", f"- Практическая граница: {PRACTICAL_FRONTIER_PLOT_PATH}", "- JPEG, пакеты и бинарные дампы не сохранялись.", "", ] REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") def validate_outputs() -> None: expected = ( CSV_PATH, SUMMARY_CSV_PATH, REPORT_PATH, COMPOSITE_SUCCESS_PLOT_PATH, STREAM_OVERHEAD_PLOT_PATH, PUBLICATION_DELAY_PLOT_PATH, NO_IMAGE_PLOT_PATH, BLOCK_SIZE_PLOT_PATH, PRACTICAL_FRONTIER_PLOT_PATH, ) for path in expected: if not path.exists() or path.stat().st_size <= 0: raise RuntimeError(f"missing or empty output: {path}") with CSV_PATH.open(newline="", encoding="utf-8") as file: rows = list(csv.DictReader(file)) if len(rows) != 32 or len(rows[0]) != len(CSV_FIELDS): raise RuntimeError("Lab032 full CSV shape is invalid") with SUMMARY_CSV_PATH.open( newline="", encoding="utf-8" ) as file: summary = list(csv.DictReader(file)) if len(summary) != 8: raise RuntimeError("Lab032 summary CSV must have eight rows") for path in expected: if path.suffix == ".png": image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if image is None: raise RuntimeError(f"OpenCV cannot read {path}") def main() -> None: print("Lab032: loading real 512-byte-payload Lab028 stream...") metadata, composites = load_video_profile(SOURCE_VIDEO_PATH) profile = prepare_profiles(composites)[INNER_PAYLOAD_SIZE] schedules = build_schedules(metadata, composites, profile) for mode in MODES: schedule = schedules[mode.name] fec = schedule.fec_schedule rate = ( fec.total_transmitted_bytes * 8.0 / metadata.duration_seconds / 1000.0 ) print( f" {mode.label}: blocks={len(fec.blocks)}, " f"units={len(fec.units)}, offered={rate:.3f} kbit/s, " f"block={schedule.mean_block_duration_seconds:.6f} s, " f"queue={fec.mean_queue_length_packets:.3f}/" f"{fec.max_queue_length_packets}" ) print( f"Running {len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS)} " f"conditions, {MONTE_CARLO_REPETITIONS} repetitions each..." ) results = run_monte_carlo(schedules, len(composites)) validate_results(results) print("Running Lab032 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_results_csv(results) save_summary_csv(results) save_plots(results) write_report( metadata, composites, schedules, results, tests ) validate_outputs() representative = result_lookup(results, 200.0) print("Representative Bad=200 ms results:") for mode in MODES: result = representative[mode.name] print( f" {mode.label}: outer=" f"{result.outer_fec_stream_bitrate_kbps:.3f} kbit/s, " f"composite={result.composite_success_rate:.6f}, " f"delay_p95={result.p95_publication_delay_seconds:.6f} s" ) print(f"Full CSV: {CSV_PATH}") print(f"Summary CSV: {SUMMARY_CSV_PATH}") print(f"Report: {REPORT_PATH}") print("Lab032 completed successfully.") if __name__ == "__main__": main()