""" Lab029. Monte Carlo simulation of Lab028 video packets over impaired channels. Real synchronous BASE and ROI JPEGs are formed with the Lab028 working profile. The existing 32-byte packet format, packet CRC32, object CRC32, and atomic CompositeReassembler are used unchanged. JPEGs and packets remain in memory; only aggregate CSV, text, and PNG results are written. """ from __future__ import annotations import csv from dataclasses import asdict, dataclass from pathlib import Path from typing import Callable import cv2 import matplotlib import numpy as np matplotlib.use("Agg") import matplotlib.pyplot as plt from protocol.video_packet import ( CompositeReassembler, ObjectType, PacketCRCError, VideoPacketError, ) from experiments.lab028_video_packetization import ( EncodedComposite, SOURCE_VIDEO_PATH, VideoMetadata, load_video_profile, packets_for_composite, ) OUTPUT_DIRECTORY = Path("data/processed/lab029") CSV_PATH = OUTPUT_DIRECTORY / "lab029_results.csv" REPORT_PATH = OUTPUT_DIRECTORY / "lab029_report.txt" INDEPENDENT_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab029_independent_loss_success.png" ) BER_PLOT_PATH = OUTPUT_DIRECTORY / "lab029_ber_success.png" BURST_PLOT_PATH = OUTPUT_DIRECTORY / "lab029_burst_loss_results.png" PAYLOAD_COMPARISON_PLOT_PATH = ( OUTPUT_DIRECTORY / "lab029_payload_comparison.png" ) PAYLOAD_SIZES = (256, 512, 1024) PACKET_LOSS_PROBABILITIES = (0.0, 0.001, 0.005, 0.01, 0.02, 0.05) BER_VALUES = (0.0, 1e-7, 3e-7, 1e-6, 3e-6, 1e-5) MONTE_CARLO_REPETITIONS = 200 MASTER_SEED = 29029 INDEPENDENT_SEED_BASE = MASTER_SEED BER_SEED_BASE = MASTER_SEED + 1000 BURST_SEED_BASE = MASTER_SEED + 2000 BURST_STATIONARY_LOSS_PROBABILITY = 0.02 BURST_DISTRIBUTION_LABELS = ( "1", "2", "3-4", "5-8", "9-16", "17-32", "33+", ) CSV_FIELDS = [ "model", "parameter_name", "parameter_value", "scenario", "payload_size", "monte_carlo_repetitions", "seed", "good_to_bad_probability", "bad_to_good_probability", "expected_loss_probability", "expected_mean_loss_burst", "observed_loss_probability", "transmitted_packets", "received_packets", "lost_packets", "crc_rejected_packets", "packet_delivery_rate", "base_objects_completed", "roi_objects_completed", "atomic_composite_frames_completed", "base_only_frames", "roi_only_frames", "incomplete_frames", "composite_success_rate", "effective_delivered_jpeg_bitrate_kbps", "mean_lost_fragments_per_frame", "p95_lost_fragments_per_frame", "mean_loss_burst_length", "max_loss_burst_length", "loss_burst_distribution", ] @dataclass(frozen=True) class BurstScenario: name: str description: str expected_mean_burst: float good_to_bad_probability: float bad_to_good_probability: float @dataclass(frozen=True) class PreparedFrame: composite_frame_id: int base_jpeg_size: int roi_jpeg_size: int packets: tuple[bytes, ...] @dataclass(frozen=True) class PreparedPacket: composite_frame_id: int wire_packet: bytes @dataclass(frozen=True) class PreparedProfile: payload_size: int frames: tuple[PreparedFrame, ...] packets: tuple[PreparedPacket, ...] @dataclass(frozen=True) class SimulationResult: model: str parameter_name: str parameter_value: float scenario: str payload_size: int monte_carlo_repetitions: int seed: int good_to_bad_probability: float bad_to_good_probability: float expected_loss_probability: float expected_mean_loss_burst: float observed_loss_probability: float transmitted_packets: int received_packets: int lost_packets: int crc_rejected_packets: int packet_delivery_rate: float base_objects_completed: int roi_objects_completed: int atomic_composite_frames_completed: int base_only_frames: int roi_only_frames: int incomplete_frames: int composite_success_rate: float effective_delivered_jpeg_bitrate_kbps: float mean_lost_fragments_per_frame: float p95_lost_fragments_per_frame: float mean_loss_burst_length: float max_loss_burst_length: int loss_burst_distribution: str @dataclass(frozen=True) class FunctionalTestResult: name: str passed: bool detail: str def build_burst_scenarios() -> tuple[BurstScenario, ...]: """ Build three loss-only Gilbert-Elliott scenarios. Good packets are delivered and Bad packets are lost. All scenarios have the same stationary Bad probability of 2%, while mean Bad-state duration changes from 2 to 20 packets. """ scenarios = [] definitions = ( ("short", "короткие серии потерь", 2.0), ("medium", "средние серии потерь", 5.0), ("long", "длинные серии потерь", 20.0), ) for name, description, mean_burst in definitions: bad_to_good = 1.0 / mean_burst good_to_bad = ( BURST_STATIONARY_LOSS_PROBABILITY * bad_to_good / (1.0 - BURST_STATIONARY_LOSS_PROBABILITY) ) scenarios.append( BurstScenario( name=name, description=description, expected_mean_burst=mean_burst, good_to_bad_probability=good_to_bad, bad_to_good_probability=bad_to_good, ) ) return tuple(scenarios) BURST_SCENARIOS = build_burst_scenarios() def prepare_profiles( composites: list[EncodedComposite], ) -> dict[int, PreparedProfile]: """Packetize every real JPEG once for each investigated payload size.""" profiles = {} for payload_size in PAYLOAD_SIZES: frames = [] flattened_packets = [] for composite in composites: base_packets, roi_packets = packets_for_composite( composite, payload_size ) packets = tuple(base_packets + roi_packets) frame = PreparedFrame( composite_frame_id=composite.composite_frame_id, base_jpeg_size=len(composite.base_jpeg), roi_jpeg_size=len(composite.roi_jpeg), packets=packets, ) frames.append(frame) flattened_packets.extend( PreparedPacket( composite_frame_id=composite.composite_frame_id, wire_packet=packet, ) for packet in packets ) profiles[payload_size] = PreparedProfile( payload_size=payload_size, frames=tuple(frames), packets=tuple(flattened_packets), ) return profiles def burst_distribution_bin(length: int) -> str: if length == 1: return "1" if length == 2: return "2" if length <= 4: return "3-4" if length <= 8: return "5-8" if length <= 16: return "9-16" if length <= 32: return "17-32" return "33+" def format_burst_distribution(lengths: list[int]) -> str: counts = {label: 0 for label in BURST_DISTRIBUTION_LABELS} for length in lengths: counts[burst_distribution_bin(length)] += 1 return ";".join( f"{label}:{counts[label]}" for label in BURST_DISTRIBUTION_LABELS ) def corrupt_packet_bits( wire_packet: bytes, error_count: int, rng: np.random.Generator, ) -> bytes: """Flip independently selected bit positions in a packet copy.""" if error_count <= 0: return wire_packet bit_count = len(wire_packet) * 8 error_count = min(error_count, bit_count) positions = rng.choice( bit_count, size=error_count, replace=False, ) corrupted = bytearray(wire_packet) for position in np.atleast_1d(positions): bit_position = int(position) byte_index, bit_index = divmod(bit_position, 8) corrupted[byte_index] ^= 1 << bit_index return bytes(corrupted) def independent_loss_flags( packet_count: int, loss_probability: float, rng: np.random.Generator, ) -> np.ndarray: return rng.random(packet_count) < loss_probability def ber_error_counts( profile: PreparedProfile, ber: float, rng: np.random.Generator, ) -> np.ndarray: bit_lengths = np.fromiter( ( len(packet.wire_packet) * 8 for packet in profile.packets ), dtype=np.int64, count=len(profile.packets), ) return rng.binomial(bit_lengths, ber) def burst_loss_flags( packet_count: int, scenario: BurstScenario, rng: np.random.Generator, ) -> np.ndarray: """Generate one stationary two-state Good/Bad packet sequence.""" flags = np.zeros(packet_count, dtype=np.bool_) bad_state = ( rng.random() < BURST_STATIONARY_LOSS_PROBABILITY ) for index in range(packet_count): flags[index] = bad_state transition_sample = rng.random() if bad_state: if transition_sample < scenario.bad_to_good_probability: bad_state = False elif transition_sample < scenario.good_to_bad_probability: bad_state = True return flags def simulate_condition( profile: PreparedProfile, metadata: VideoMetadata, model: str, parameter_name: str, parameter_value: float, seed: int, repetitions: int, scenario: BurstScenario | None = None, ) -> SimulationResult: """Simulate one channel condition and aggregate all requested metrics.""" if repetitions <= 0: raise ValueError("repetitions must be positive") if model == "burst" and scenario is None: raise ValueError("burst model requires a scenario") if model not in {"independent_loss", "ber", "burst"}: raise ValueError(f"unsupported model: {model}") rng = np.random.default_rng(seed) transmitted_packets = 0 received_packets = 0 lost_packets = 0 crc_rejected_packets = 0 base_objects_completed = 0 roi_objects_completed = 0 atomic_composite_frames_completed = 0 base_only_frames = 0 roi_only_frames = 0 incomplete_frames = 0 delivered_atomic_jpeg_bytes = 0 lost_fragments_per_frame: list[int] = [] loss_burst_lengths: list[int] = [] packet_count = len(profile.packets) frame_count = len(profile.frames) jpeg_sizes_by_id = { frame.composite_frame_id: ( frame.base_jpeg_size + frame.roi_jpeg_size ) for frame in profile.frames } for _ in range(repetitions): receiver = CompositeReassembler() completed_frame_ids: set[int] = set() unavailable_by_frame = [0] * frame_count current_loss_burst = 0 if model == "independent_loss": loss_flags = independent_loss_flags( packet_count, parameter_value, rng ) error_counts = None elif model == "ber": loss_flags = np.zeros(packet_count, dtype=np.bool_) error_counts = ber_error_counts(profile, parameter_value, rng) else: assert scenario is not None loss_flags = burst_loss_flags(packet_count, scenario, rng) error_counts = None for packet_index, prepared_packet in enumerate(profile.packets): transmitted_packets += 1 frame_id = prepared_packet.composite_frame_id if bool(loss_flags[packet_index]): lost_packets += 1 unavailable_by_frame[frame_id] += 1 current_loss_burst += 1 continue received_packets += 1 wire_packet = prepared_packet.wire_packet if error_counts is not None: error_count = int(error_counts[packet_index]) if error_count > 0: wire_packet = corrupt_packet_bits( wire_packet, error_count, rng ) try: completed = receiver.ingest(wire_packet) except VideoPacketError: crc_rejected_packets += 1 unavailable_by_frame[frame_id] += 1 current_loss_burst += 1 continue if current_loss_burst: loss_burst_lengths.append(current_loss_burst) current_loss_burst = 0 if completed is not None: completed_frame_ids.add(completed.composite_frame_id) atomic_composite_frames_completed += 1 delivered_atomic_jpeg_bytes += ( len(completed.base_jpeg) + len(completed.roi_jpeg) ) if current_loss_burst: loss_burst_lengths.append(current_loss_burst) for frame in profile.frames: frame_id = frame.composite_frame_id atomic_complete = frame_id in completed_frame_ids base_complete = ( atomic_complete or receiver.object_is_complete(frame_id, ObjectType.BASE) ) roi_complete = ( atomic_complete or receiver.object_is_complete(frame_id, ObjectType.ROI) ) base_objects_completed += int(base_complete) roi_objects_completed += int(roi_complete) base_only_frames += int(base_complete and not roi_complete) roi_only_frames += int(roi_complete and not base_complete) incomplete_frames += int(not atomic_complete) lost_fragments_per_frame.extend(unavailable_by_frame) expected_frames = frame_count * repetitions if ( atomic_composite_frames_completed + incomplete_frames != expected_frames ): raise RuntimeError("atomic and incomplete frame counts disagree") if base_objects_completed != ( atomic_composite_frames_completed + base_only_frames ): raise RuntimeError("BASE completion accounting disagrees") if roi_objects_completed != ( atomic_composite_frames_completed + roi_only_frames ): raise RuntimeError("ROI completion accounting disagrees") unavailable_packets = lost_packets + crc_rejected_packets accepted_packets = received_packets - crc_rejected_packets observed_loss_probability = ( unavailable_packets / transmitted_packets ) if model == "burst": assert scenario is not None scenario_name = scenario.name good_to_bad = scenario.good_to_bad_probability bad_to_good = scenario.bad_to_good_probability expected_loss = BURST_STATIONARY_LOSS_PROBABILITY expected_mean_burst = scenario.expected_mean_burst elif model == "independent_loss": scenario_name = "" good_to_bad = 0.0 bad_to_good = 0.0 expected_loss = parameter_value expected_mean_burst = ( 0.0 if parameter_value == 0.0 else 1.0 / (1.0 - parameter_value) ) else: scenario_name = "" good_to_bad = 0.0 bad_to_good = 0.0 expected_loss = 0.0 expected_mean_burst = 0.0 total_simulated_duration = ( metadata.duration_seconds * repetitions ) return SimulationResult( model=model, parameter_name=parameter_name, parameter_value=parameter_value, scenario=scenario_name, payload_size=profile.payload_size, monte_carlo_repetitions=repetitions, seed=seed, good_to_bad_probability=good_to_bad, bad_to_good_probability=bad_to_good, expected_loss_probability=expected_loss, expected_mean_loss_burst=expected_mean_burst, observed_loss_probability=observed_loss_probability, transmitted_packets=transmitted_packets, received_packets=received_packets, lost_packets=lost_packets, crc_rejected_packets=crc_rejected_packets, packet_delivery_rate=accepted_packets / transmitted_packets, base_objects_completed=base_objects_completed, roi_objects_completed=roi_objects_completed, atomic_composite_frames_completed=( atomic_composite_frames_completed ), base_only_frames=base_only_frames, roi_only_frames=roi_only_frames, incomplete_frames=incomplete_frames, composite_success_rate=( atomic_composite_frames_completed / expected_frames ), effective_delivered_jpeg_bitrate_kbps=( delivered_atomic_jpeg_bytes * 8.0 / total_simulated_duration / 1000.0 ), mean_lost_fragments_per_frame=float( np.mean(lost_fragments_per_frame) ), p95_lost_fragments_per_frame=float( np.percentile(lost_fragments_per_frame, 95) ), mean_loss_burst_length=( float(np.mean(loss_burst_lengths)) if loss_burst_lengths else 0.0 ), max_loss_burst_length=( max(loss_burst_lengths) if loss_burst_lengths else 0 ), loss_burst_distribution=format_burst_distribution( loss_burst_lengths ), ) def run_monte_carlo( profiles: dict[int, PreparedProfile], metadata: VideoMetadata, ) -> list[SimulationResult]: """Run all 45 channel/payload combinations.""" results = [] for parameter_index, loss_probability in enumerate( PACKET_LOSS_PROBABILITIES ): seed = INDEPENDENT_SEED_BASE + parameter_index for payload_size in PAYLOAD_SIZES: results.append( simulate_condition( profiles[payload_size], metadata, model="independent_loss", parameter_name="packet_loss_probability", parameter_value=loss_probability, seed=seed, repetitions=MONTE_CARLO_REPETITIONS, ) ) for parameter_index, ber in enumerate(BER_VALUES): seed = BER_SEED_BASE + parameter_index for payload_size in PAYLOAD_SIZES: results.append( simulate_condition( profiles[payload_size], metadata, model="ber", parameter_name="bit_error_rate", parameter_value=ber, seed=seed, repetitions=MONTE_CARLO_REPETITIONS, ) ) for scenario_index, scenario in enumerate(BURST_SCENARIOS): seed = BURST_SEED_BASE + scenario_index for payload_size in PAYLOAD_SIZES: results.append( simulate_condition( profiles[payload_size], metadata, model="burst", parameter_name="gilbert_elliott_scenario", parameter_value=float(scenario_index), seed=seed, repetitions=MONTE_CARLO_REPETITIONS, scenario=scenario, ) ) return results def run_functional_tests( composites: list[EncodedComposite], profiles: dict[int, PreparedProfile], metadata: VideoMetadata, results: list[SimulationResult], ) -> list[FunctionalTestResult]: """Verify zero impairment, CRC, isolation, atomicity, and repeatability.""" tests: list[tuple[str, Callable[[], str]]] = [] def zero_impairment_is_perfect() -> str: zero_rows = [ result for result in results if ( result.model in {"independent_loss", "ber"} and result.parameter_value == 0.0 ) ] if len(zero_rows) != len(PAYLOAD_SIZES) * 2: raise AssertionError("zero-impairment rows are missing") for row in zero_rows: if ( row.packet_delivery_rate != 1.0 or row.composite_success_rate != 1.0 or row.incomplete_frames != 0 ): raise AssertionError( f"zero impairment failed for {row.model}/" f"{row.payload_size}" ) return "all payloads delivered 100% packets and composite frames" def crc_rejects_corruption() -> str: packet = profiles[512].packets[0].wire_packet corrupted = bytearray(packet) corrupted[-1] ^= 0x01 try: CompositeReassembler().ingest(bytes(corrupted)) except PacketCRCError: return "payload bit flip was rejected by packet CRC32" raise AssertionError("corrupted packet was not rejected by CRC") def adjacent_frames_do_not_mix() -> str: first_profile_frames = profiles[512].frames[:2] interleaved = [] first_packets = first_profile_frames[0].packets second_packets = first_profile_frames[1].packets for index in range(max(len(first_packets), len(second_packets))): if index < len(second_packets): interleaved.append(second_packets[index]) if index < len(first_packets): interleaved.append(first_packets[index]) receiver = CompositeReassembler() completed = {} for packet in interleaved: frame = receiver.ingest(packet) if frame is not None: completed[frame.composite_frame_id] = frame expected = {composites[0].composite_frame_id, composites[1].composite_frame_id} if set(completed) != expected: raise AssertionError("adjacent frame IDs were mixed or lost") for composite in composites[:2]: frame = completed[composite.composite_frame_id] if ( frame.base_jpeg != composite.base_jpeg or frame.roi_jpeg != composite.roi_jpeg ): raise AssertionError("neighboring JPEG objects were mixed") return "two interleaved neighboring frames remained independent" def incomplete_frame_is_not_published() -> str: frame = profiles[512].frames[0] receiver = CompositeReassembler() completed_count = 0 for packet in frame.packets[:-1]: if receiver.ingest(packet) is not None: completed_count += 1 if completed_count != 0: raise AssertionError("incomplete composite frame was published") if not receiver.object_is_complete( frame.composite_frame_id, ObjectType.BASE ): raise AssertionError("complete BASE object was not retained") return "missing ROI fragment prevented atomic publication" def identical_seed_is_reproducible() -> str: short_profile = PreparedProfile( payload_size=512, frames=profiles[512].frames[:2], packets=tuple( packet for packet in profiles[512].packets if packet.composite_frame_id < 2 ), ) first = simulate_condition( short_profile, metadata, model="independent_loss", parameter_name="packet_loss_probability", parameter_value=0.02, seed=MASTER_SEED + 9999, repetitions=5, ) second = simulate_condition( short_profile, metadata, model="independent_loss", parameter_name="packet_loss_probability", parameter_value=0.02, seed=MASTER_SEED + 9999, repetitions=5, ) if first != second: raise AssertionError("same seed produced different aggregates") return "identical seed produced byte-for-byte equal result fields" tests.extend( [ ("zero_impairment_100_percent", zero_impairment_is_perfect), ("packet_crc_rejects_bit_error", crc_rejects_corruption), ("adjacent_frame_isolation", adjacent_frames_do_not_mix), ("atomic_incomplete_frame", incomplete_frame_is_not_published), ("fixed_seed_reproducibility", identical_seed_is_reproducible), ] ) test_results = [] for name, test in tests: try: detail = test() except Exception as error: test_results.append( FunctionalTestResult(name, False, str(error)) ) else: test_results.append( FunctionalTestResult(name, True, detail) ) failed = [result for result in test_results if not result.passed] if failed: details = "; ".join( f"{result.name}: {result.detail}" for result in failed ) raise RuntimeError(f"Lab029 functional checks failed: {details}") return test_results def result_lookup( results: list[SimulationResult], model: str, parameter_value: float | None = None, scenario: str | None = None, ) -> dict[int, SimulationResult]: matches = [ result for result in results if ( result.model == model and ( parameter_value is None or result.parameter_value == parameter_value ) and (scenario is None or result.scenario == scenario) ) ] return {result.payload_size: result for result in matches} def save_csv(results: list[SimulationResult]) -> None: OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file: writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS) writer.writeheader() for result in results: raw_row = asdict(result) row = {} for field_name in CSV_FIELDS: value = raw_row[field_name] row[field_name] = ( f"{value:.12g}" if isinstance(value, float) else value ) writer.writerow(row) def save_independent_plot(results: list[SimulationResult]) -> None: figure, axis = plt.subplots(figsize=(9, 5.5)) loss_percentages = [ probability * 100.0 for probability in PACKET_LOSS_PROBABILITIES ] for payload_size in PAYLOAD_SIZES: success = [ result_lookup( results, "independent_loss", probability )[payload_size].composite_success_rate * 100.0 for probability in PACKET_LOSS_PROBABILITIES ] axis.plot( loss_percentages, success, marker="o", linewidth=2, label=f"payload {payload_size} B", ) axis.set_xlabel("Independent packet loss probability, %") axis.set_ylabel("Atomic composite success rate, %") axis.set_title("Lab029 independent packet loss") axis.grid(True, alpha=0.3) axis.legend() figure.tight_layout() figure.savefig(INDEPENDENT_PLOT_PATH, dpi=160) plt.close(figure) def save_ber_plot(results: list[SimulationResult]) -> None: figure, axis = plt.subplots(figsize=(9, 5.5)) x_values = [1e-8 if ber == 0.0 else ber for ber in BER_VALUES] for payload_size in PAYLOAD_SIZES: success = [ result_lookup(results, "ber", ber)[ payload_size ].composite_success_rate * 100.0 for ber in BER_VALUES ] axis.plot( x_values, success, marker="o", linewidth=2, label=f"payload {payload_size} B", ) axis.set_xscale("log") axis.set_xticks(x_values) axis.set_xticklabels( ["0", "1e-7", "3e-7", "1e-6", "3e-6", "1e-5"] ) axis.set_xlabel("Bit error rate (zero shown at 1e-8 position)") axis.set_ylabel("Atomic composite success rate, %") axis.set_title("Lab029 independent bit errors") axis.grid(True, which="both", alpha=0.3) axis.legend() figure.tight_layout() figure.savefig(BER_PLOT_PATH, dpi=160) plt.close(figure) def save_burst_plot(results: list[SimulationResult]) -> None: figure, axis = plt.subplots(figsize=(9, 5.5)) x_positions = np.arange(len(BURST_SCENARIOS)) for payload_size in PAYLOAD_SIZES: success = [ result_lookup( results, "burst", scenario=scenario.name )[payload_size].composite_success_rate * 100.0 for scenario in BURST_SCENARIOS ] axis.plot( x_positions, success, marker="o", linewidth=2, label=f"payload {payload_size} B", ) axis.set_xticks(x_positions) axis.set_xticklabels( [ f"{scenario.name}\nmean {scenario.expected_mean_burst:g}" for scenario in BURST_SCENARIOS ] ) axis.set_xlabel("Gilbert-Elliott loss scenario") axis.set_ylabel("Atomic composite success rate, %") axis.set_title( "Lab029 burst loss at 2% stationary packet loss" ) axis.grid(True, alpha=0.3) axis.legend() figure.tight_layout() figure.savefig(BURST_PLOT_PATH, dpi=160) plt.close(figure) def save_payload_comparison_plot( results: list[SimulationResult], ) -> None: conditions = [ ( "Loss 1%", result_lookup(results, "independent_loss", 0.01), ), ("BER 1e-6", result_lookup(results, "ber", 1e-6)), ( "Burst medium", result_lookup(results, "burst", scenario="medium"), ), ] x_positions = np.arange(len(conditions)) bar_width = 0.24 figure, axis = plt.subplots(figsize=(9, 5.5)) for payload_index, payload_size in enumerate(PAYLOAD_SIZES): values = [ condition_results[payload_size].composite_success_rate * 100.0 for _, condition_results in conditions ] axis.bar( x_positions + (payload_index - 1) * bar_width, values, width=bar_width, label=f"payload {payload_size} B", ) axis.set_xticks(x_positions) axis.set_xticklabels([name for name, _ in conditions]) axis.set_ylabel("Atomic composite success rate, %") axis.set_title("Lab029 payload comparison") axis.grid(True, axis="y", alpha=0.3) axis.legend() figure.tight_layout() figure.savefig(PAYLOAD_COMPARISON_PLOT_PATH, dpi=160) plt.close(figure) def save_plots(results: list[SimulationResult]) -> None: save_independent_plot(results) save_ber_plot(results) save_burst_plot(results) save_payload_comparison_plot(results) def success_matrix_lines( results: list[SimulationResult], model: str, parameters: tuple[float, ...], parameter_formatter: Callable[[float], str], ) -> list[str]: lines = [ "parameter | payload 256 | payload 512 | payload 1024", "---------:|------------:|------------:|-------------:", ] for parameter in parameters: by_payload = result_lookup(results, model, parameter) lines.append( f"{parameter_formatter(parameter)} | " f"{by_payload[256].composite_success_rate * 100.0:.3f}% | " f"{by_payload[512].composite_success_rate * 100.0:.3f}% | " f"{by_payload[1024].composite_success_rate * 100.0:.3f}%" ) return lines def burst_table_lines( results: list[SimulationResult], ) -> list[str]: lines = [ ( "scenario | payload | expected loss | observed loss | " "mean/max burst | composite success | burst distribution" ), ( "--------:|--------:|--------------:|--------------:|" "---------------:|------------------:|:------------------" ), ] for scenario in BURST_SCENARIOS: by_payload = result_lookup( results, "burst", scenario=scenario.name ) for payload_size in PAYLOAD_SIZES: result = by_payload[payload_size] lines.append( f"{scenario.name} | {payload_size} | " f"{result.expected_loss_probability * 100.0:.3f}% | " f"{result.observed_loss_probability * 100.0:.3f}% | " f"{result.mean_loss_burst_length:.3f}/" f"{result.max_loss_burst_length} | " f"{result.composite_success_rate * 100.0:.3f}% | " f"{result.loss_burst_distribution}" ) return lines def comparison_table_lines( results: list[SimulationResult], ) -> list[str]: loss = result_lookup(results, "independent_loss", 0.01) ber = result_lookup(results, "ber", 1e-6) burst = result_lookup(results, "burst", scenario="medium") lines = [ ( "payload | loss 1% success | BER 1e-6 success | " "medium burst success | Lab028 wire kbit/s" ), ( "-------:|----------------:|-----------------:|" "---------------------:|--------------------:" ), ] lab028_wire_bitrate = { 256: 178.233323, 512: 168.396019, 1024: 163.662279, } for payload_size in PAYLOAD_SIZES: lines.append( f"{payload_size} | " f"{loss[payload_size].composite_success_rate * 100.0:.3f}% | " f"{ber[payload_size].composite_success_rate * 100.0:.3f}% | " f"{burst[payload_size].composite_success_rate * 100.0:.3f}% | " f"{lab028_wire_bitrate[payload_size]:.3f}" ) return lines def write_report( metadata: VideoMetadata, composites: list[EncodedComposite], profiles: dict[int, PreparedProfile], results: list[SimulationResult], functional_tests: list[FunctionalTestResult], ) -> None: lines = [ "Lab029. Прохождение видеопакетов через канал с потерями и ошибками", "", "Исходные данные и неизменный транспорт Lab028", f"- Видео: {SOURCE_VIDEO_PATH}", ( f"- Источник: {metadata.width}x{metadata.height}, " f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, " f"{metadata.duration_seconds:.6f} с." ), ( f"- Реальных синхронных JPEG-пар: {len(composites)}; " "BASE 240x135 grayscale Q23, ROI 320x180 grayscale Q33, " "3 composite fps." ), ( "- Использован protocol/video_packet.py без изменения: " "32-byte header, packet CRC32, object CRC32, отдельные BASE/ROI " "и атомарная выдача CompositeReassembler." ), ( "- Payload: " + ", ".join(str(value) for value in PAYLOAD_SIZES) + " байт." ), ( "- Пакетов в одном проходе: " + ", ".join( f"{payload} B={len(profiles[payload].packets)}" for payload in PAYLOAD_SIZES ) + "." ), "", "Методика Monte Carlo", f"- Повторов на каждую строку: {MONTE_CARLO_REPETITIONS}.", ( f"- Master seed: {MASTER_SEED}; independent seeds " f"{INDEPENDENT_SEED_BASE}...{INDEPENDENT_SEED_BASE + 5}; " f"BER seeds {BER_SEED_BASE}...{BER_SEED_BASE + 5}; " f"burst seeds {BURST_SEED_BASE}...{BURST_SEED_BASE + 2}." ), ( "- Для одинакового параметра один seed повторно используется " "для payload 256/512/1024, чтобы сравнение было коррелированным " "и воспроизводимым." ), ( "- packet_delivery_rate = (received - CRC rejected) / " "transmitted." ), ( "- effective delivered JPEG bitrate учитывает BASE+ROI bytes " "только атомарно выданных составных кадров и полное " "симулированное время." ), ( "- Потерянный фрагмент — физически потерянный пакет или пакет, " "отклонённый проверкой транспорта." ), ( "- FEC, ARQ, повторные передачи и интерливинг не применяются." ), "", "Модели канала", ( "- Independent loss: каждый пакет теряется независимо с " "заданной вероятностью." ), ( "- BER: число независимых битовых ошибок выбирается binomial " "по полной длине пакета в битах, включая 32-byte header; " "выбранные биты физически инвертируются перед reassembler." ), ( "- Gilbert-Elliott: Good доставляет все пакеты, Bad теряет все. " "Начальное состояние выбирается из стационарного распределения." ), ( "- В Lab029 длительность состояния Bad и серии потерь задаётся " "количеством пакетов, а не физическим временем." ), ( "- Серия из одинакового количества пакетов имеет разную " "физическую длительность для payload 256, 512 и 1024 байта, " "поскольку различается время передачи пакета." ), ( "- Доля успешно восстановленных кадров не показывает " "длительность непрерывного отсутствия нового изображения." ), ( "- Поэтому burst-loss результаты Lab029 не являются " "окончательным сравнением размеров payload; для временного " "сравнения предназначена Lab029B." ), ] for scenario in BURST_SCENARIOS: lines.append( f" - {scenario.name}: {scenario.description}; " f"P(G->B)={scenario.good_to_bad_probability:.9f}, " f"P(B->G)={scenario.bad_to_good_probability:.9f}, " f"stationary loss={BURST_STATIONARY_LOSS_PROBABILITY:.3%}, " f"expected mean burst={scenario.expected_mean_burst:.1f}." ) lines.extend(["", "Функциональные проверки"]) lines.extend( f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}" for test in functional_tests ) lines.extend( [ "", "1. Independent packet loss — atomic composite success", *success_matrix_lines( results, "independent_loss", PACKET_LOSS_PROBABILITIES, lambda value: f"{value * 100.0:.1f}%", ), "", "2. BER — atomic composite success", *success_matrix_lines( results, "ber", BER_VALUES, lambda value: "0" if value == 0.0 else f"{value:.0e}", ), "", "3. Burst-loss", *burst_table_lines(results), "", "4. Сравнение payload 256/512/1024", *comparison_table_lines(results), "", "Интерпретация", ( "- При фиксированной вероятности потери пакета крупный " "payload уменьшает число обязательных фрагментов. Поскольку " "потеря любого фрагмента делает объект неполным, меньшее " "число пакетов обычно повышает шанс полного кадра." ), ( "- При фиксированном BER длинный отдельный пакет чаще " "повреждается: P(error)=1-(1-BER)^(packet_bits). Одновременно " "крупный payload уменьшает число 32-byte headers и общую " "длину wire-потока, поэтому итоговый composite success " "определяется обоими эффектами." ), ( "- Атомарная сборка требует одновременной готовности BASE и " "ROI. Даже полностью собранный BASE не даёт полезного " "составного кадра без ROI, поэтому composite success ниже " "успеха каждого объекта отдельно." ), ( "- Burst-loss особенно опасен без интерливинга: соседние " "фрагменты и иногда соседние кадры теряются одной серией, " "а CRC только обнаруживает ошибку и не восстанавливает " "данные." ), ( "- Payload 256 B даёт наименьшую длину отдельного пакета и " "наименьшую вероятность BER-повреждения одного пакета, но " "требует больше всего фрагментов и headers." ), ( "- Payload 512 B остаётся умеренным рабочим кандидатом между " "packet count и длиной отдельного пакета." ), ( "- Payload 1024 B даёт минимум пакетов и header overhead в " "этих моделях, но каждый пакет длиннее и при его потере " "пропадает более крупный фрагмент JPEG." ), ( "- Окончательный packet payload автоматически не выбирается; " "для решения нужны Lab029 и последующие данные реального " "радиоканала, FEC/ARQ и задержек." ), "", "Артефакты", f"- CSV: {CSV_PATH}", f"- Independent loss plot: {INDEPENDENT_PLOT_PATH}", f"- BER plot: {BER_PLOT_PATH}", f"- Burst plot: {BURST_PLOT_PATH}", f"- Payload comparison: {PAYLOAD_COMPARISON_PLOT_PATH}", ( "- JPEG, пакеты и бинарные дампы на диск не сохранялись." ), "", ] ) REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") def validate_results(results: list[SimulationResult]) -> None: if len(results) != 45: raise RuntimeError(f"expected 45 result rows, got {len(results)}") keys = { ( result.model, result.parameter_value, result.scenario, result.payload_size, ) for result in results } if len(keys) != len(results): raise RuntimeError("result rows are not unique") for result in results: if not 0.0 <= result.packet_delivery_rate <= 1.0: raise RuntimeError("packet delivery rate is outside 0...1") if not 0.0 <= result.composite_success_rate <= 1.0: raise RuntimeError("composite success rate is outside 0...1") if ( result.received_packets + result.lost_packets != result.transmitted_packets ): raise RuntimeError("packet receive/loss accounting disagrees") def validate_output_files() -> None: for path in ( CSV_PATH, REPORT_PATH, INDEPENDENT_PLOT_PATH, BER_PLOT_PATH, BURST_PLOT_PATH, PAYLOAD_COMPARISON_PLOT_PATH, ): if not path.exists() or path.stat().st_size <= 0: raise RuntimeError(f"missing or empty output: {path}") def main() -> None: print("Lab029: loading real Lab028 BASE/ROI JPEG profile...") metadata, composites = load_video_profile(SOURCE_VIDEO_PATH) profiles = prepare_profiles(composites) print( f" source={metadata.width}x{metadata.height}, " f"frames={metadata.frame_count}, composites={len(composites)}" ) for payload_size in PAYLOAD_SIZES: print( f" payload={payload_size}: " f"{len(profiles[payload_size].packets)} packets/pass" ) print( f"Running 45 Monte Carlo conditions, " f"{MONTE_CARLO_REPETITIONS} repetitions each..." ) results = run_monte_carlo(profiles, metadata) validate_results(results) print("Running Lab029 functional checks...") functional_tests = run_functional_tests( composites, profiles, metadata, results ) for test in functional_tests: print(f" PASS {test.name}: {test.detail}") OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) save_csv(results) save_plots(results) write_report( metadata, composites, profiles, results, functional_tests, ) validate_output_files() print("Representative composite success rates:") for payload_size in PAYLOAD_SIZES: loss_result = result_lookup( results, "independent_loss", 0.01 )[payload_size] ber_result = result_lookup( results, "ber", 1e-6 )[payload_size] burst_result = result_lookup( results, "burst", scenario="medium" )[payload_size] print( f" payload={payload_size}: " f"loss1%={loss_result.composite_success_rate:.6f}, " f"BER1e-6={ber_result.composite_success_rate:.6f}, " f"burst-medium={burst_result.composite_success_rate:.6f}" ) print(f"CSV: {CSV_PATH}") print(f"Report: {REPORT_PATH}") print("Lab029 completed successfully.") if __name__ == "__main__": main()