1787 lines
61 KiB
Python
1787 lines
61 KiB
Python
"""
|
||
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()
|