The tests/ directory held 50 laboratory programs and no tests. They model channels, run hundreds of repetitions and write CSV, PNG and reports; calling that a test suite blocked introducing a real one, because any pytest run would have collected the labs and re-executed every experiment. - move all 50 lab programs to experiments/ with git mv, preserving history - rewrite the 38 cross-imports between labs from tests.labNNN to experiments.labNNN - leave tests/ empty for actual fast checks of protocol/ - point quick_gate and the hook at the new layout and add experiments/ to the syntax sweep - update the paths quoted in the Lab042 specification and the verifier agent definition This also defuses the import-time work finding without touching 41 files: the labs still create directories and write files on import, but nothing imports them now except the gate, which does so deliberately. Gate passes: syntax clean, protocol imports, 15 lab modules import, 2 functional suites run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1354 lines
46 KiB
Python
1354 lines
46 KiB
Python
"""
|
||
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()
|