Files
SDR-Rover/tests/lab032_fec_parameter_sweep.py
2026-07-29 12:47:10 +03:00

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