Files
SDR-Rover/tests/lab029_packet_channel_simulation.py
2026-07-27 15:29:39 +03:00

1354 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 tests.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()