""" Lab031. Packet interleaving between packet-erasure FEC blocks. The Lab028 512-byte-payload transport, Lab030 outer packet format and GF(256) implementation, and Lab029B continuous-time channel are reused unchanged. Complete consecutive FEC blocks are buffered in groups and sent by symbol index across the group. Seven fixed modes compare no FEC, 8+2 at depths 1/2/4/8, and 8+4 at depths 1/4. """ from __future__ import annotations import csv from dataclasses import asdict, dataclass, replace from pathlib import Path from typing import Callable import cv2 import matplotlib import numpy as np matplotlib.use("Agg") import matplotlib.pyplot as plt from protocol.fec_interleaver import ( deinterleave_symbols, interleave_blocks, interleave_group, ) from protocol.packet_erasure_fec import ( decode_fec_block, decode_outer_symbol, encode_fec_block, ) from protocol.video_packet import ( CompositeReassembler, decode_packet as decode_inner_packet, ) from tests.lab028_video_packetization import ( COMPOSITE_FPS, SOURCE_VIDEO_PATH, EncodedComposite, VideoMetadata, load_video_profile, ) from tests.lab029_packet_channel_simulation import ( PreparedProfile, prepare_profiles, ) from tests.lab029b_time_based_burst_simulation import ( BAD_TIME_FRACTION, CONTROL_STREAM_BITRATE_BPS, CONTROL_STREAM_BITRATE_KBPS, MEAN_BAD_DURATIONS_SECONDS, generate_bad_intervals, percentile, ) from tests.lab030_packet_erasure_fec import ( FECBlockPlan, FECMode, FunctionalTestResult, ModeSchedule, RepetitionResult, SourcePacket, TransmissionUnit, build_mode_units, overlap_loss_flags, prepare_source_packets, schedule_units, simulate_baseline, simulate_fec, ) OUTPUT_DIRECTORY = Path("data/processed/lab031") CSV_PATH = OUTPUT_DIRECTORY / "lab031_results.csv" REPORT_PATH = OUTPUT_DIRECTORY / "lab031_report.txt" COMPOSITE_SUCCESS_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab031_composite_success.png" ) NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab031_no_image_duration.png" PUBLICATION_DELAY_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab031_publication_delay.png" ) BLOCK_RECOVERY_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab031_block_recovery.png" ) DEPTH_TRADEOFF_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab031_depth_tradeoff.png" ) MODE_COMPARISON_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab031_mode_comparison.png" ) INNER_PAYLOAD_SIZE = 512 SOURCE_BLOCK_SIZE = 8 MONTE_CARLO_REPETITIONS = 200 MASTER_SEED = 310_310 SEED_BASE = MASTER_SEED @dataclass(frozen=True) class InterleavingMode: name: str parity_count: int depth: int label: str MODES = ( InterleavingMode("none", 0, 1, "Без FEC"), InterleavingMode("8+2_D1", 2, 1, "8+2, D=1"), InterleavingMode("8+2_D2", 2, 2, "8+2, D=2"), InterleavingMode("8+2_D4", 2, 4, "8+2, D=4"), InterleavingMode("8+2_D8", 2, 8, "8+2, D=8"), InterleavingMode("8+4_D1", 4, 1, "8+4, D=1"), InterleavingMode("8+4_D4", 4, 4, "8+4, D=4"), ) @dataclass(frozen=True) class InterleavingGroupPlan: group_index: int block_ids: tuple[int, ...] ready_seconds: tuple[float, ...] release_seconds: float unit_count: int @dataclass(frozen=True) class InterleavedSchedule: mode: InterleavingMode fec_schedule: ModeSchedule groups: tuple[InterleavingGroupPlan, ...] group_wait_delays_seconds: tuple[float, ...] mean_group_wait_delay_seconds: float p95_group_wait_delay_seconds: float max_group_wait_delay_seconds: float mean_buffered_blocks: float max_buffered_blocks: int mean_intrablock_symbol_gap_seconds: float mean_intrablock_symbol_span_seconds: float max_intrablock_symbol_span_seconds: float @dataclass(frozen=True) class SimulationResult: mode: str label: str source_block_size: int nominal_parity_count: int interleaving_depth: int mean_bad_duration_ms: float mean_good_duration_ms: float target_bad_time_fraction: float actual_bad_time_fraction: float monte_carlo_repetitions: int seed: int source_jpeg_bitrate_kbps: float inner_packet_stream_bitrate_kbps: float outer_fec_stream_bitrate_kbps: float service_and_parity_percent: float control_stream_bitrate_kbps: float schedule_duration_seconds: float interleaving_group_count: int final_group_block_count: int mean_group_wait_delay_seconds: float p95_group_wait_delay_seconds: float max_group_wait_delay_seconds: float mean_queue_length_packets: float max_queue_length_packets: int mean_buffered_blocks: float max_buffered_blocks: int mean_intrablock_symbol_gap_seconds: float mean_intrablock_symbol_span_seconds: float max_intrablock_symbol_span_seconds: float transmitted_source_packets: int transmitted_parity_packets: int lost_source_packets: int lost_parity_packets: int recovered_source_packets: int fec_recovered_blocks: int fec_unrecoverable_blocks: int fec_affected_block_recovery_rate: float fec_all_block_success_rate: float fec_unrecoverable_block_rate: float base_objects_completed: int roi_objects_completed: int atomic_composite_frames_completed: int composite_success_rate: float base_only_frames: int roi_only_frames: int incomplete_frames: int effective_delivered_video_bitrate_kbps: float mean_publication_delay_seconds: float p95_publication_delay_seconds: float max_publication_delay_seconds: float mean_no_new_image_duration_seconds: float p95_no_new_image_duration_seconds: float max_no_new_image_duration_seconds: float mean_consecutive_incomplete_frames: float p95_consecutive_incomplete_frames: float max_consecutive_incomplete_frames: int CSV_FIELDS = [ field.name for field in SimulationResult.__dataclass_fields__.values() ] def _block_units( units: tuple[TransmissionUnit, ...], ) -> tuple[tuple[TransmissionUnit, ...], ...]: grouped: dict[int, list[TransmissionUnit]] = {} order = [] for unit in units: if unit.block_id not in grouped: order.append(unit.block_id) grouped[unit.block_id] = [] grouped[unit.block_id].append(unit) return tuple(tuple(grouped[block_id]) for block_id in order) def _symbol_timing( schedule: ModeSchedule, ) -> tuple[float, float, float]: endings: dict[int, list[tuple[int, float]]] = {} for scheduled in schedule.units: unit = scheduled.unit endings.setdefault(unit.block_id, []).append( (unit.symbol_index, scheduled.end_seconds) ) gaps = [] spans = [] for values in endings.values(): ordered = [ end for _, end in sorted(values) ] gaps.extend( right - left for left, right in zip(ordered, ordered[1:]) ) if len(ordered) > 1: spans.append(ordered[-1] - ordered[0]) return ( float(np.mean(gaps)) if gaps else 0.0, float(np.mean(spans)) if spans else 0.0, max(spans) if spans else 0.0, ) def _make_mode_schedule( metadata: VideoMetadata, composites: list[EncodedComposite], source_packets: tuple[SourcePacket, ...], mode: InterleavingMode, ) -> InterleavedSchedule: lab030_mode = FECMode(mode.name, mode.parity_count, mode.label) sequential_units, blocks = build_mode_units( source_packets, lab030_mode ) groups = [] waits = [] buffer_samples = [] if mode.parity_count == 0: reordered_units = sequential_units else: plans_by_id = {plan.block_id: plan for plan in blocks} units_by_block = _block_units(sequential_units) reordered = [] sequence_index = 0 for group_index, start in enumerate( range(0, len(units_by_block), mode.depth) ): group_units = units_by_block[start:start + mode.depth] block_ids = tuple( units[0].block_id for units in group_units ) ready_times = tuple( max( source_packets[index].generation_time_seconds for index in plans_by_id[ block_id ].source_global_indices ) for block_id in block_ids ) complete_group = len(group_units) == mode.depth release_time = max(ready_times) if mode.depth > 1 and not complete_group: release_time = max( release_time, metadata.duration_seconds ) waits.extend( release_time - ready for ready in ready_times ) buffer_samples.extend( range(1, len(group_units) + 1) ) wire_blocks = tuple( tuple(unit.wire_packet for unit in units) for units in group_units ) interleaved = interleave_group(wire_blocks) lookup = { (unit.block_id, unit.symbol_index): unit for units in group_units for unit in units } for wire_packet in interleaved: parsed = decode_outer_symbol(wire_packet) original = lookup[ (parsed.block_id, parsed.symbol_index) ] generation_time = ( original.generation_time_seconds if mode.depth == 1 else release_time ) reordered.append( replace( original, sequence_index=sequence_index, generation_time_seconds=generation_time, ) ) sequence_index += 1 groups.append( InterleavingGroupPlan( group_index=group_index, block_ids=block_ids, ready_seconds=ready_times, release_seconds=release_time, unit_count=len(interleaved), ) ) reordered_units = tuple(reordered) scheduled, duration, mean_queue, max_queue = schedule_units( reordered_units ) total_jpeg_bytes = sum( len(composite.base_jpeg) + len(composite.roi_jpeg) for composite in composites ) total_inner_bytes = sum( len(packet.inner_packet) for packet in source_packets ) fec_schedule = ModeSchedule( mode=lab030_mode, source_packets=source_packets, blocks=blocks, units=scheduled, source_duration_seconds=metadata.duration_seconds, duration_seconds=duration, total_jpeg_bytes=total_jpeg_bytes, total_inner_bytes=total_inner_bytes, total_transmitted_bytes=sum( len(unit.unit.wire_packet) for unit in scheduled ), mean_queue_length_packets=mean_queue, max_queue_length_packets=max_queue, ) if mode.parity_count: symbol_gap, symbol_span, max_symbol_span = _symbol_timing( fec_schedule ) else: symbol_gap, symbol_span, max_symbol_span = 0.0, 0.0, 0.0 return InterleavedSchedule( mode=mode, fec_schedule=fec_schedule, groups=tuple(groups), group_wait_delays_seconds=tuple(waits), mean_group_wait_delay_seconds=( float(np.mean(waits)) if waits else 0.0 ), p95_group_wait_delay_seconds=percentile(waits, 95), max_group_wait_delay_seconds=max(waits) if waits else 0.0, mean_buffered_blocks=( float(np.mean(buffer_samples)) if buffer_samples else 0.0 ), max_buffered_blocks=( max(buffer_samples) if buffer_samples else 0 ), mean_intrablock_symbol_gap_seconds=symbol_gap, mean_intrablock_symbol_span_seconds=symbol_span, max_intrablock_symbol_span_seconds=max_symbol_span, ) def build_schedules( metadata: VideoMetadata, composites: list[EncodedComposite], profile: PreparedProfile, ) -> dict[str, InterleavedSchedule]: source_packets = prepare_source_packets(profile) return { mode.name: _make_mode_schedule( metadata, composites, source_packets, mode ) for mode in MODES } def _aggregate_condition( schedule: InterleavedSchedule, frame_count: int, mean_bad_duration_seconds: float, seed: int, repetitions: int, ) -> SimulationResult: fec_schedule = schedule.fec_schedule rng = np.random.default_rng(seed) repetition_results: list[RepetitionResult] = [] bad_time_seconds = 0.0 for _ in range(repetitions): intervals = generate_bad_intervals( fec_schedule.duration_seconds, mean_bad_duration_seconds, rng, ) bad_time_seconds += sum( interval.duration_seconds for interval in intervals ) loss_flags = overlap_loss_flags(fec_schedule, intervals) repetition_results.append( simulate_baseline( fec_schedule, loss_flags, frame_count ) if schedule.mode.parity_count == 0 else simulate_fec( fec_schedule, loss_flags, frame_count ) ) def total(field: str) -> int: return sum( int(getattr(result, field)) for result in repetition_results ) def flattened(field: str) -> list[float]: return [ float(value) for result in repetition_results for value in getattr(result, field) ] publication_delays = flattened("publication_delays") no_image = flattened("no_new_image_durations") incomplete_runs = flattened("incomplete_frame_runs") recovered_blocks = total("fec_recovered_blocks") failed_blocks = total("fec_unrecoverable_blocks") affected_blocks = recovered_blocks + failed_blocks total_frames = frame_count * repetitions source_packets_per_pass = len(fec_schedule.source_packets) parity_packets_per_pass = sum( int(unit.unit.is_parity) for unit in fec_schedule.units ) total_fec_blocks = len(fec_schedule.blocks) * repetitions delivered_bytes = total("delivered_jpeg_bytes") source_jpeg_rate = ( fec_schedule.total_jpeg_bytes * 8.0 / fec_schedule.source_duration_seconds / 1000.0 ) inner_rate = ( fec_schedule.total_inner_bytes * 8.0 / fec_schedule.source_duration_seconds / 1000.0 ) outer_rate = ( fec_schedule.total_transmitted_bytes * 8.0 / fec_schedule.source_duration_seconds / 1000.0 ) return SimulationResult( mode=schedule.mode.name, label=schedule.mode.label, source_block_size=SOURCE_BLOCK_SIZE, nominal_parity_count=schedule.mode.parity_count, interleaving_depth=schedule.mode.depth, mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0, mean_good_duration_ms=( mean_bad_duration_seconds * (1.0 - BAD_TIME_FRACTION) / BAD_TIME_FRACTION * 1000.0 ), target_bad_time_fraction=BAD_TIME_FRACTION, actual_bad_time_fraction=( bad_time_seconds / (fec_schedule.duration_seconds * repetitions) ), monte_carlo_repetitions=repetitions, seed=seed, source_jpeg_bitrate_kbps=source_jpeg_rate, inner_packet_stream_bitrate_kbps=inner_rate, outer_fec_stream_bitrate_kbps=outer_rate, service_and_parity_percent=( ( fec_schedule.total_transmitted_bytes - fec_schedule.total_jpeg_bytes ) / fec_schedule.total_transmitted_bytes * 100.0 ), control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS, schedule_duration_seconds=fec_schedule.duration_seconds, interleaving_group_count=len(schedule.groups), final_group_block_count=( len(schedule.groups[-1].block_ids) if schedule.groups else 0 ), mean_group_wait_delay_seconds=( schedule.mean_group_wait_delay_seconds ), p95_group_wait_delay_seconds=( schedule.p95_group_wait_delay_seconds ), max_group_wait_delay_seconds=( schedule.max_group_wait_delay_seconds ), mean_queue_length_packets=( fec_schedule.mean_queue_length_packets ), max_queue_length_packets=( fec_schedule.max_queue_length_packets ), mean_buffered_blocks=schedule.mean_buffered_blocks, max_buffered_blocks=schedule.max_buffered_blocks, mean_intrablock_symbol_gap_seconds=( schedule.mean_intrablock_symbol_gap_seconds ), mean_intrablock_symbol_span_seconds=( schedule.mean_intrablock_symbol_span_seconds ), max_intrablock_symbol_span_seconds=( schedule.max_intrablock_symbol_span_seconds ), transmitted_source_packets=( source_packets_per_pass * repetitions ), transmitted_parity_packets=( parity_packets_per_pass * repetitions ), lost_source_packets=total("lost_source_packets"), lost_parity_packets=total("lost_parity_packets"), recovered_source_packets=total("recovered_source_packets"), fec_recovered_blocks=recovered_blocks, fec_unrecoverable_blocks=failed_blocks, fec_affected_block_recovery_rate=( recovered_blocks / affected_blocks if affected_blocks else 0.0 ), fec_all_block_success_rate=( (total_fec_blocks - failed_blocks) / total_fec_blocks if total_fec_blocks else 0.0 ), fec_unrecoverable_block_rate=( failed_blocks / total_fec_blocks if total_fec_blocks else 0.0 ), base_objects_completed=total("base_objects_completed"), roi_objects_completed=total("roi_objects_completed"), atomic_composite_frames_completed=total( "atomic_composite_frames_completed" ), composite_success_rate=( total("atomic_composite_frames_completed") / total_frames ), base_only_frames=total("base_only_frames"), roi_only_frames=total("roi_only_frames"), incomplete_frames=total("incomplete_frames"), effective_delivered_video_bitrate_kbps=( delivered_bytes * 8.0 / (fec_schedule.duration_seconds * repetitions) / 1000.0 ), mean_publication_delay_seconds=( float(np.mean(publication_delays)) if publication_delays else 0.0 ), p95_publication_delay_seconds=percentile( publication_delays, 95 ), max_publication_delay_seconds=( max(publication_delays) if publication_delays else 0.0 ), mean_no_new_image_duration_seconds=( float(np.mean(no_image)) if no_image else 0.0 ), p95_no_new_image_duration_seconds=percentile(no_image, 95), max_no_new_image_duration_seconds=( max(no_image) if no_image else 0.0 ), mean_consecutive_incomplete_frames=( float(np.mean(incomplete_runs)) if incomplete_runs else 0.0 ), p95_consecutive_incomplete_frames=percentile( incomplete_runs, 95 ), max_consecutive_incomplete_frames=( int(max(incomplete_runs)) if incomplete_runs else 0 ), ) def run_monte_carlo( schedules: dict[str, InterleavedSchedule], frame_count: int, ) -> list[SimulationResult]: results = [] for duration_index, duration in enumerate( MEAN_BAD_DURATIONS_SECONDS ): seed = SEED_BASE + duration_index for mode in MODES: results.append( _aggregate_condition( schedules[mode.name], frame_count, duration, seed, MONTE_CARLO_REPETITIONS, ) ) return results def result_lookup( results: list[SimulationResult], mean_bad_duration_ms: float, ) -> dict[str, SimulationResult]: return { result.mode: result for result in results if result.mean_bad_duration_ms == mean_bad_duration_ms } def run_functional_tests( composites: list[EncodedComposite], schedules: dict[str, InterleavedSchedule], ) -> list[FunctionalTestResult]: tests: list[tuple[str, Callable[[], str]]] = [] protected_modes = tuple( mode for mode in MODES if mode.parity_count ) sequential_82, _ = build_mode_units( schedules["8+2_D1"].fec_schedule.source_packets, FECMode("lab030_8+2", 2, "8+2"), ) def d1_matches_lab030_order() -> str: actual = tuple( unit.unit.wire_packet for unit in schedules["8+2_D1"].fec_schedule.units ) expected = tuple( unit.wire_packet for unit in sequential_82 ) if actual != expected: raise AssertionError("D=1 changed Lab030 wire order") return "D=1 wire order is byte-identical to Lab030" def permutation_preserves_multiset() -> str: schedule = schedules["8+2_D8"] original, _ = build_mode_units( schedule.fec_schedule.source_packets, FECMode("reference", 2, "8+2"), ) before = sorted(unit.wire_packet for unit in original) after = sorted( unit.unit.wire_packet for unit in schedule.fec_schedule.units ) if before != after: raise AssertionError("interleaving added or removed packets") return f"all {len(before)} packets preserved" def every_packet_once() -> str: for mode in protected_modes: packets = [ unit.unit.wire_packet for unit in schedules[mode.name].fec_schedule.units ] if len(packets) != len(set(packets)): raise AssertionError( f"{mode.name} contains a duplicate packet" ) return "every protected-mode packet appears exactly once" def identifiers_are_preserved() -> str: schedule = schedules["8+2_D4"].fec_schedule for unit in schedule.units: parsed = decode_outer_symbol(unit.unit.wire_packet) if ( parsed.block_id != unit.unit.block_id or parsed.symbol_index != unit.unit.symbol_index ): raise AssertionError("outer identifiers changed") return "block_id and symbol_index survived permutation" def inverse_distribution_works() -> str: checked = [] source_packets = schedules[ "8+2_D1" ].fec_schedule.source_packets[:64] for depth in (2, 4, 8): wire_blocks = tuple( encode_fec_block( tuple( packet.inner_packet for packet in source_packets[ start:start + SOURCE_BLOCK_SIZE ] ), start // SOURCE_BLOCK_SIZE, 2, ) for start in range( 0, len(source_packets), SOURCE_BLOCK_SIZE ) ) interleaved = interleave_blocks(wire_blocks, depth) restored = deinterleave_symbols(interleaved) if restored != wire_blocks: raise AssertionError( f"inverse distribution failed for D={depth}" ) checked.append(depth) return f"inverse grouping passed for D={checked}" def decoded_packets_are_exact() -> str: source_packets = schedules[ "8+2_D1" ].fec_schedule.source_packets[:32] wire_blocks = tuple( encode_fec_block( tuple( packet.inner_packet for packet in source_packets[ start:start + SOURCE_BLOCK_SIZE ] ), start // SOURCE_BLOCK_SIZE, 2, ) for start in range(0, 32, SOURCE_BLOCK_SIZE) ) interleaved = interleave_blocks(wire_blocks, 4) received = tuple( packet for packet in interleaved if decode_outer_symbol(packet).symbol_index not in {1, 7} ) restored_blocks = deinterleave_symbols(received) restored_packets = [] for block in restored_blocks: decoded = decode_fec_block(block) restored_packets.extend(decoded.source_packets) expected = [ packet.inner_packet for packet in source_packets ] if restored_packets != expected: raise AssertionError("decoded inner bytes differ") return "decoded inner packets are byte-exact" def both_crc_layers_pass() -> str: schedule = schedules["8+4_D4"].fec_schedule first_block = [ unit.unit.wire_packet for unit in schedule.units if unit.unit.block_id == 0 and unit.unit.symbol_index not in {0, 2, 8, 10} ] for outer in first_block: decode_outer_symbol(outer) decoded = decode_fec_block(tuple(first_block)) for inner in decoded.source_packets: decode_inner_packet(inner) return "outer and recovered Lab028 CRC32 checks passed" def zero_bad_restores_every_frame() -> str: for mode in MODES: schedule = schedules[mode.name].fec_schedule flags = np.zeros(len(schedule.units), dtype=np.bool_) result = ( simulate_baseline(schedule, flags, len(composites)) if mode.parity_count == 0 else simulate_fec(schedule, flags, len(composites)) ) if ( result.atomic_composite_frames_completed != len(composites) ): raise AssertionError( f"zero-Bad failed for {mode.name}" ) return "all seven modes restored 100% of frames without Bad" def fixed_seed_is_reproducible() -> str: first = _aggregate_condition( schedules["8+2_D4"], len(composites), 0.05, 319_319, 3, ) second = _aggregate_condition( schedules["8+2_D4"], len(composites), 0.05, 319_319, 3, ) if first != second: raise AssertionError("same seed changed the result") return "identical seed produced identical result fields" def incomplete_composite_is_not_published() -> str: receiver = CompositeReassembler() first_frame = [ packet.inner_packet for packet in schedules[ "none" ].fec_schedule.source_packets if packet.composite_frame_id == 0 ] for packet in first_frame[:-1]: if receiver.ingest(packet) is not None: raise AssertionError( "incomplete composite was published" ) return "missing final ROI fragment prevented publication" def depth_increases_symbol_distance() -> str: gaps = [ schedules[f"8+2_D{depth}"].mean_intrablock_symbol_gap_seconds for depth in (1, 2, 4, 8) ] if any( right <= left for left, right in zip(gaps, gaps[1:]) ): raise AssertionError( f"symbol gaps are not strictly increasing: {gaps}" ) return "D=1/2/4/8 strictly increase intrablock symbol spacing" def group_wait_is_in_total_delay() -> str: d1 = schedules["8+2_D1"] d4 = schedules["8+2_D4"] flags1 = np.zeros( len(d1.fec_schedule.units), dtype=np.bool_ ) flags4 = np.zeros( len(d4.fec_schedule.units), dtype=np.bool_ ) result1 = simulate_fec( d1.fec_schedule, flags1, len(composites) ) result4 = simulate_fec( d4.fec_schedule, flags4, len(composites) ) mean1 = float(np.mean(result1.publication_delays)) mean4 = float(np.mean(result4.publication_delays)) if d4.mean_group_wait_delay_seconds <= 0.0: raise AssertionError("D=4 has no group wait") if mean4 <= mean1: raise AssertionError( "group wait did not increase publication delay" ) return ( f"D=4 group wait {d4.mean_group_wait_delay_seconds:.6f} s " f"is reflected in publication delay" ) def final_partial_group_is_transmitted() -> str: schedule = schedules["8+2_D8"] if not schedule.groups: raise AssertionError("no interleaving groups") final = schedule.groups[-1] if len(final.block_ids) >= schedule.mode.depth: raise AssertionError("test stream has no partial final group") packets = [ unit.unit.wire_packet for unit in schedule.fec_schedule.units if unit.unit.block_id in final.block_ids ] if len(packets) != final.unit_count: raise AssertionError("partial final group lost packets") if ( final.release_seconds < schedule.fec_schedule.source_duration_seconds ): raise AssertionError("partial group was released before stream end") return ( f"final {len(final.block_ids)}-block group emitted " f"{len(packets)} packets at end of stream" ) tests.extend( [ ("d1_matches_lab030", d1_matches_lab030_order), ("permutation_preserves_packets", permutation_preserves_multiset), ("each_packet_once", every_packet_once), ("identifiers_preserved", identifiers_are_preserved), ("inverse_distribution", inverse_distribution_works), ("decoded_inner_byte_exact", decoded_packets_are_exact), ("inner_outer_crc", both_crc_layers_pass), ("zero_bad_100_percent", zero_bad_restores_every_frame), ("fixed_seed_reproducibility", fixed_seed_is_reproducible), ( "atomic_incomplete_composite", incomplete_composite_is_not_published, ), ( "depth_increases_symbol_distance", depth_increases_symbol_distance, ), ( "group_wait_in_publication_delay", group_wait_is_in_total_delay, ), ( "final_partial_group", final_partial_group_is_transmitted, ), ] ) results = [] for name, test in tests: try: detail = test() except Exception as error: results.append(FunctionalTestResult(name, False, str(error))) else: results.append(FunctionalTestResult(name, True, detail)) failed = [result for result in results if not result.passed] if failed: raise RuntimeError( "Lab031 functional checks failed: " + "; ".join( f"{result.name}: {result.detail}" for result in failed ) ) return results def validate_results(results: list[SimulationResult]) -> None: expected = len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS) if len(results) != expected: raise RuntimeError( f"expected {expected} result rows, got {len(results)}" ) keys = { (result.mode, result.mean_bad_duration_ms) for result in results } if len(keys) != expected: raise RuntimeError("Lab031 result rows are not unique") if any( not 0.0 <= result.composite_success_rate <= 1.0 for result in results ): raise RuntimeError("composite success is outside 0...1") if any( not 0.0 <= result.fec_unrecoverable_block_rate <= 1.0 for result in results ): raise RuntimeError("unrecoverable block rate is outside 0...1") def save_csv(results: list[SimulationResult]) -> None: with CSV_PATH.open("w", newline="", encoding="utf-8") as file: writer = csv.DictWriter(file, fieldnames=CSV_FIELDS) writer.writeheader() for result in results: writer.writerow(asdict(result)) def _rows_for_mode( results: list[SimulationResult], mode_name: str, ) -> list[SimulationResult]: return sorted( ( result for result in results if result.mode == mode_name ), key=lambda result: result.mean_bad_duration_ms, ) def _save_metric_plot( results: list[SimulationResult], field: str, ylabel: str, title: str, path: Path, ) -> None: figure, axis = plt.subplots(figsize=(10.8, 6.4)) for mode in MODES: rows = _rows_for_mode(results, mode.name) axis.plot( [row.mean_bad_duration_ms for row in rows], [getattr(row, field) for row in rows], marker="o", linewidth=1.8, label=mode.label, ) axis.set_xscale("log") axis.set_xlabel("Средняя длительность Bad, мс") axis.set_ylabel(ylabel) axis.set_title(title) axis.grid(True, which="both", alpha=0.28) axis.legend(ncol=2, fontsize=8) figure.tight_layout() figure.savefig(path, dpi=160) plt.close(figure) def save_plots(results: list[SimulationResult]) -> None: _save_metric_plot( results, "composite_success_rate", "Доля восстановленных составных кадров", "Lab031. Атомарное восстановление BASE + ROI", COMPOSITE_SUCCESS_PLOT_PATH, ) _save_metric_plot( results, "p95_no_new_image_duration_seconds", "P95 отсутствия нового изображения, с", "Lab031. Длительность удержания последнего изображения", NO_IMAGE_PLOT_PATH, ) _save_metric_plot( results, "p95_publication_delay_seconds", "P95 задержки публикации, с", "Lab031. Задержка публикации составного кадра", PUBLICATION_DELAY_PLOT_PATH, ) figure, axis = plt.subplots(figsize=(10.8, 6.4)) for mode in MODES: if not mode.parity_count: continue rows = _rows_for_mode(results, mode.name) axis.plot( [row.mean_bad_duration_ms for row in rows], [row.fec_all_block_success_rate for row in rows], marker="o", linewidth=1.8, label=mode.label, ) axis.set_xscale("log") axis.set_xlabel("Средняя длительность Bad, мс") axis.set_ylabel("Доля восстановимых FEC-блоков") axis.set_title("Lab031. Восстановление FEC-блоков") axis.grid(True, which="both", alpha=0.28) axis.legend(ncol=2, fontsize=8) figure.tight_layout() figure.savefig(BLOCK_RECOVERY_PLOT_PATH, dpi=160) plt.close(figure) representative = result_lookup(results, 200.0) depth_rows = [ representative[f"8+2_D{depth}"] for depth in (1, 2, 4, 8) ] figure, success_axis = plt.subplots(figsize=(10.2, 6.2)) delay_axis = success_axis.twinx() depths = [row.interleaving_depth for row in depth_rows] success_axis.plot( depths, [row.composite_success_rate for row in depth_rows], color="#1565c0", marker="o", linewidth=2.2, label="Составные кадры", ) delay_axis.plot( depths, [row.p95_publication_delay_seconds for row in depth_rows], color="#c62828", marker="s", linewidth=2.0, label="P95 публикации", ) delay_axis.plot( depths, [row.mean_group_wait_delay_seconds for row in depth_rows], color="#ef6c00", marker="^", linestyle="--", linewidth=1.8, label="Среднее ожидание группы", ) success_axis.set_xticks(depths) success_axis.set_xlabel("Глубина перемежения D") success_axis.set_ylabel( "Доля восстановленных составных кадров", color="#1565c0", ) delay_axis.set_ylabel("Задержка, с", color="#c62828") success_axis.set_title( "Lab031. Устойчивость и задержка 8+2 при Bad 200 мс" ) success_axis.grid(True, alpha=0.28) lines = success_axis.lines + delay_axis.lines success_axis.legend( lines, [line.get_label() for line in lines], loc="best", ) figure.tight_layout() figure.savefig(DEPTH_TRADEOFF_PLOT_PATH, dpi=160) plt.close(figure) rows = [representative[mode.name] for mode in MODES] positions = np.arange(len(rows)) figure, success_axis = plt.subplots(figsize=(12.0, 6.5)) delay_axis = success_axis.twinx() success_axis.bar( positions - 0.2, [row.composite_success_rate for row in rows], width=0.4, color="#2e7d32", label="Составные кадры", ) delay_axis.bar( positions + 0.2, [row.p95_publication_delay_seconds for row in rows], width=0.4, color="#6a1b9a", alpha=0.72, label="P95 публикации", ) success_axis.set_xticks( positions, [mode.label for mode in MODES], rotation=24, ha="right", ) success_axis.set_ylabel("Доля восстановленных кадров") delay_axis.set_ylabel("P95 задержки публикации, с") success_axis.set_title( "Lab031. Итоговое сравнение режимов при Bad 200 мс" ) success_axis.grid(True, axis="y", alpha=0.25) lines = [ success_axis.patches[0], delay_axis.patches[0], ] success_axis.legend( lines, ["Составные кадры", "P95 публикации"], loc="upper left", ) figure.tight_layout() figure.savefig(MODE_COMPARISON_PLOT_PATH, dpi=160) plt.close(figure) def _mode_configuration_table( schedules: dict[str, InterleavedSchedule], ) -> list[str]: lines = [ ( "mode | k+r | D | groups | final group | source/parity " "packets | outer kbit/s | overhead | wait mean/max s | " "buffer mean/max | queue mean/max | symbol gap/span ms" ), ( "----:|----:|--:|-------:|------------:|----------------------:" "|-------------:|---------:|----------------:|----------------:" "|---------------:|-------------------:" ), ] for mode in MODES: schedule = schedules[mode.name] fec = schedule.fec_schedule source_count = len(fec.source_packets) parity_count = sum( int(unit.unit.is_parity) for unit in fec.units ) outer_rate = ( fec.total_transmitted_bytes * 8.0 / fec.source_duration_seconds / 1000.0 ) overhead = ( ( fec.total_transmitted_bytes - fec.total_jpeg_bytes ) / fec.total_transmitted_bytes * 100.0 ) block_code = ( "none" if not mode.parity_count else f"8+{mode.parity_count}" ) lines.append( f"{mode.label} | {block_code} | {mode.depth} | " f"{len(schedule.groups)} | " f"{len(schedule.groups[-1].block_ids) if schedule.groups else 0} " f"| {source_count}/{parity_count} | {outer_rate:.3f} | " f"{overhead:.3f}% | " f"{schedule.mean_group_wait_delay_seconds:.6f}/" f"{schedule.max_group_wait_delay_seconds:.6f} | " f"{schedule.mean_buffered_blocks:.3f}/" f"{schedule.max_buffered_blocks} | " f"{fec.mean_queue_length_packets:.3f}/" f"{fec.max_queue_length_packets} | " f"{schedule.mean_intrablock_symbol_gap_seconds * 1000:.3f}/" f"{schedule.mean_intrablock_symbol_span_seconds * 1000:.3f}" ) return lines def _representative_table( results: list[SimulationResult], ) -> list[str]: rows = result_lookup(results, 200.0) lines = [ ( "mode | composite | no-image mean/p95/max s | publication " "mean/p95/max s | affected blocks | all blocks | " "unrecoverable | delivered kbit/s | incomplete run " "mean/p95/max" ), ( "----:|----------:|------------------------:|-------------------------:" "|----------------:|-----------:|--------------:|----------------:" "|------------------------:" ), ] for mode in MODES: row = rows[mode.name] lines.append( f"{mode.label} | {row.composite_success_rate:.6f} | " f"{row.mean_no_new_image_duration_seconds:.6f}/" f"{row.p95_no_new_image_duration_seconds:.6f}/" f"{row.max_no_new_image_duration_seconds:.6f} | " f"{row.mean_publication_delay_seconds:.6f}/" f"{row.p95_publication_delay_seconds:.6f}/" f"{row.max_publication_delay_seconds:.6f} | " f"{row.fec_affected_block_recovery_rate:.6f} | " f"{row.fec_all_block_success_rate:.6f} | " f"{row.fec_unrecoverable_block_rate:.6f} | " f"{row.effective_delivered_video_bitrate_kbps:.3f} | " f"{row.mean_consecutive_incomplete_frames:.3f}/" f"{row.p95_consecutive_incomplete_frames:.3f}/" f"{row.max_consecutive_incomplete_frames}" ) return lines def _outperformance_lines( results: list[SimulationResult], ) -> list[str]: lines = [] for duration in ( value * 1000.0 for value in MEAN_BAD_DURATIONS_SECONDS ): rows = result_lookup(results, duration) control = rows["8+4_D1"] for name in ("8+2_D2", "8+2_D4", "8+2_D8"): candidate = rows[name] if ( candidate.composite_success_rate > control.composite_success_rate ): lines.append( f"- Bad {duration:.0f} мс, {candidate.label}: " f"composite {candidate.composite_success_rate:.6f} " f"> {control.composite_success_rate:.6f}; " f"P95 no-image " f"{candidate.p95_no_new_image_duration_seconds:.6f} " f"с против " f"{control.p95_no_new_image_duration_seconds:.6f} с; " f"P95 публикации " f"{candidate.p95_publication_delay_seconds:.6f} " f"с против " f"{control.p95_publication_delay_seconds:.6f} с." ) if not lines: lines.append( "- В исследованных условиях 8+2 с перемежением не превзошёл " "8+4, D=1 по доле составных кадров." ) return lines def write_report( metadata: VideoMetadata, composites: list[EncodedComposite], schedules: dict[str, InterleavedSchedule], results: list[SimulationResult], tests: list[FunctionalTestResult], ) -> None: lines = [ "Lab031. Перемежение пакетов между FEC-блоками", "", "Исходные данные и неизменные слои", f"- Видео: {SOURCE_VIDEO_PATH}", ( f"- {len(composites)} реальных синхронных пар BASE/ROI, " f"{COMPOSITE_FPS:.0f} fps; длительность источника " f"{metadata.duration_seconds:.6f} с." ), ( "- BASE 240x135 grayscale JPEG Q23; ROI 320x180 " "grayscale JPEG Q33." ), ( "- Внутренний транспорт Lab028: payload 512 байт, " "packet CRC32, object CRC32, атомарная публикация BASE+ROI." ), ( "- Внешний формат и GF(256) Lab030 используются без " "изменений; k=8, parity r=2 или r=4." ), "", "Точное правило перестановки", ( "- Последовательные готовые FEC-блоки делятся на группы " "не более D блоков." ), ( "- Для symbol_index=0,1,2,... передаётся символ с этим " "индексом блока 0, затем блока 1, ... блока D-1." ), ( "- Если блок короче, отсутствующий symbol_index пропускается. " "Внутри блока сохраняется порядок systematic, затем parity." ), ( "- Перестановка не изменяет байты, block_id или symbol_index " "и не добавляет пакеты." ), "", "Формирование и буферизация групп", ( "- Полная группа D>1 выпускается после готовности D-го " "последовательного блока; все её пакеты получают это время " "доступности перед FIFO-передатчиком." ), ( "- Неполная последняя группа выпускается в момент окончания " "видеопотока; синтетические блоки и символы не добавляются." ), ( "- D=1 сохраняет порядок и времена доступности Lab030 и " "имеет нулевую добавочную задержку ожидания группы." ), ( "- Размер группы формируется в порядке возрастания block_id; " "полные группы имеют D блоков, последняя — остаток." ), ( "- Средний буфер — среднее заполнение после каждого прихода " "готового блока (1..размер группы); максимум включает момент " "непосредственно перед выпуском группы." ), "", "Семь фиксированных режимов и статические метрики", *_mode_configuration_table(schedules), "", "Временная модель и статистика", ( "- Непрерывные чередующиеся экспоненциальные Good/Bad " "интервалы Lab029B, средняя доля Bad около 2%." ), ( "- Средние Bad: 10, 50, 200, 1000 мс; 200 повторов на " f"условие; seeds {SEED_BASE}...{SEED_BASE + 3}." ), ( "- Пакет теряется при любом пересечении интервала его " "передачи с Bad; скорость FIFO 300 кбит/с." ), ( "- Учитываются фактическая длина каждого внутреннего и " "внешнего пакета, ожидание группы и накопление очереди." ), ( "- Приёмник группирует внешние символы по block_id, " "декодирует после любых k принятых символов и передаёт " "восстановленные внутренние пакеты в CompositeReassembler." ), ( "- Соседние composite_frame_id не смешиваются; неполный " "BASE+ROI кадр не публикуется." ), "", "Результаты при средней Bad 200 мс", *_representative_table(results), "", "Когда 8+2 с перемежением превосходит 8+4 без него", ( "- Критерий ниже: строго более высокая доля атомарно " "восстановленных составных кадров; задержки приведены рядом " "как цена перемежения." ), *_outperformance_lines(results), "", "Функциональные проверки", *[ f"- {'PASS' if test.passed else 'FAIL'} " f"{test.name}: {test.detail}" for test in tests ], "", "Допущения модели", ( "- Видеопрофиль конечный и детерминированный; JPEG " "кодируются в памяти один раз перед Monte Carlo." ), ( "- Устройство знает конец потока и только тогда выпускает " "неполную последнюю группу D>1." ), ( "- Время готовности FEC-блока равно максимальному времени " "генерации его исходных пакетов; вычислительная задержка " "GF(256) считается нулевой." ), ( "- D=1 определён как точный режим Lab030; поэтому он не " "ждёт завершения блока перед отправкой уже готовых " "systematic symbols." ), ( "- Передатчик и канал общие FIFO; параллельные радиоканалы, " "ARQ, повторные передачи, команды, телеметрия, модуляция и " "реальный SDR не моделируются." ), ( "- Потеря является стиранием полного внешнего пакета; " "отдельные битовые ошибки не добавляются." ), ( "- Publication delay считается от frame_id/3 до конца " "пакета, завершившего атомарную сборку; no-image включает " "время до следующего опубликованного кадра или конца " "расписания." ), ( "- Intrablock symbol gap измеряется между временами конца " "последовательных symbol_index; span — от первого до " "последнего символа блока." ), "", "Артефакты", f"- CSV: {CSV_PATH}", f"- Отчёт: {REPORT_PATH}", f"- Доля восстановленных кадров: {COMPOSITE_SUCCESS_PLOT_PATH}", f"- Отсутствие нового изображения: {NO_IMAGE_PLOT_PATH}", f"- Задержка публикации: {PUBLICATION_DELAY_PLOT_PATH}", f"- Доля восстановленных блоков: {BLOCK_RECOVERY_PLOT_PATH}", f"- Устойчивость/задержка от D: {DEPTH_TRADEOFF_PLOT_PATH}", f"- Итоговое сравнение: {MODE_COMPARISON_PLOT_PATH}", "- JPEG, внешние пакеты и бинарные дампы на диск не сохранялись.", "", ] REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") def validate_outputs() -> None: expected = ( CSV_PATH, REPORT_PATH, COMPOSITE_SUCCESS_PLOT_PATH, NO_IMAGE_PLOT_PATH, PUBLICATION_DELAY_PLOT_PATH, BLOCK_RECOVERY_PLOT_PATH, DEPTH_TRADEOFF_PLOT_PATH, MODE_COMPARISON_PLOT_PATH, ) for path in expected: if not path.exists() or path.stat().st_size <= 0: raise RuntimeError(f"missing or empty output: {path}") with CSV_PATH.open(newline="", encoding="utf-8") as file: rows = list(csv.DictReader(file)) if len(rows) != 28: raise RuntimeError( f"Lab031 CSV must contain 28 rows, got {len(rows)}" ) if len(rows[0]) != len(CSV_FIELDS): raise RuntimeError("Lab031 CSV column count changed") for path in expected: if path.suffix == ".png": image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if image is None: raise RuntimeError(f"OpenCV cannot read {path}") def main() -> None: print("Lab031: loading real 512-byte-payload Lab028 stream...") metadata, composites = load_video_profile(SOURCE_VIDEO_PATH) profile = prepare_profiles(composites)[INNER_PAYLOAD_SIZE] schedules = build_schedules(metadata, composites, profile) for mode in MODES: schedule = schedules[mode.name] fec = schedule.fec_schedule print( f" {mode.label}: units={len(fec.units)}, " f"groups={len(schedule.groups)}, " f"wait={schedule.mean_group_wait_delay_seconds:.6f} s, " f"gap={schedule.mean_intrablock_symbol_gap_seconds:.6f} s, " f"timeline={fec.duration_seconds:.6f} s" ) print("Running Lab031 functional checks...") tests = run_functional_tests(composites, schedules) for test in tests: print(f" PASS {test.name}: {test.detail}") print( f"Running {len(MODES) * len(MEAN_BAD_DURATIONS_SECONDS)} " f"conditions, {MONTE_CARLO_REPETITIONS} repetitions each..." ) results = run_monte_carlo(schedules, len(composites)) validate_results(results) OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) save_csv(results) save_plots(results) write_report( metadata, composites, schedules, results, tests ) validate_outputs() representative = result_lookup(results, 200.0) print("Representative Bad=200 ms results:") for mode in MODES: result = representative[mode.name] print( f" {mode.label}: composite=" f"{result.composite_success_rate:.6f}, " f"block={result.fec_all_block_success_rate:.6f}, " f"delay_p95={result.p95_publication_delay_seconds:.6f} s" ) print(f"CSV: {CSV_PATH}") print(f"Report: {REPORT_PATH}") print("Lab031 completed successfully.") if __name__ == "__main__": main()