1577 lines
55 KiB
Python
1577 lines
55 KiB
Python
"""
|
||
Lab031. Packet interleaving between packet-erasure FEC blocks.
|
||
|
||
The Lab028 512-byte-payload transport, Lab030 outer packet format and
|
||
GF(256) implementation, and Lab029B continuous-time channel are reused
|
||
unchanged. Complete consecutive FEC blocks are buffered in groups and sent
|
||
by symbol index across the group. Seven fixed modes compare no FEC, 8+2 at
|
||
depths 1/2/4/8, and 8+4 at depths 1/4.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
from dataclasses import asdict, dataclass, replace
|
||
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.fec_interleaver import (
|
||
deinterleave_symbols,
|
||
interleave_blocks,
|
||
interleave_group,
|
||
)
|
||
from protocol.packet_erasure_fec import (
|
||
decode_fec_block,
|
||
decode_outer_symbol,
|
||
encode_fec_block,
|
||
)
|
||
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,
|
||
overlap_loss_flags,
|
||
prepare_source_packets,
|
||
schedule_units,
|
||
simulate_baseline,
|
||
simulate_fec,
|
||
)
|
||
|
||
|
||
OUTPUT_DIRECTORY = Path("data/processed/lab031")
|
||
CSV_PATH = OUTPUT_DIRECTORY / "lab031_results.csv"
|
||
REPORT_PATH = OUTPUT_DIRECTORY / "lab031_report.txt"
|
||
COMPOSITE_SUCCESS_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab031_composite_success.png"
|
||
)
|
||
NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab031_no_image_duration.png"
|
||
PUBLICATION_DELAY_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab031_publication_delay.png"
|
||
)
|
||
BLOCK_RECOVERY_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab031_block_recovery.png"
|
||
)
|
||
DEPTH_TRADEOFF_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab031_depth_tradeoff.png"
|
||
)
|
||
MODE_COMPARISON_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab031_mode_comparison.png"
|
||
)
|
||
|
||
INNER_PAYLOAD_SIZE = 512
|
||
SOURCE_BLOCK_SIZE = 8
|
||
MONTE_CARLO_REPETITIONS = 200
|
||
MASTER_SEED = 310_310
|
||
SEED_BASE = MASTER_SEED
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class InterleavingMode:
|
||
name: str
|
||
parity_count: int
|
||
depth: int
|
||
label: str
|
||
|
||
|
||
MODES = (
|
||
InterleavingMode("none", 0, 1, "Без FEC"),
|
||
InterleavingMode("8+2_D1", 2, 1, "8+2, D=1"),
|
||
InterleavingMode("8+2_D2", 2, 2, "8+2, D=2"),
|
||
InterleavingMode("8+2_D4", 2, 4, "8+2, D=4"),
|
||
InterleavingMode("8+2_D8", 2, 8, "8+2, D=8"),
|
||
InterleavingMode("8+4_D1", 4, 1, "8+4, D=1"),
|
||
InterleavingMode("8+4_D4", 4, 4, "8+4, D=4"),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class InterleavingGroupPlan:
|
||
group_index: int
|
||
block_ids: tuple[int, ...]
|
||
ready_seconds: tuple[float, ...]
|
||
release_seconds: float
|
||
unit_count: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class InterleavedSchedule:
|
||
mode: InterleavingMode
|
||
fec_schedule: ModeSchedule
|
||
groups: tuple[InterleavingGroupPlan, ...]
|
||
group_wait_delays_seconds: tuple[float, ...]
|
||
mean_group_wait_delay_seconds: float
|
||
p95_group_wait_delay_seconds: float
|
||
max_group_wait_delay_seconds: float
|
||
mean_buffered_blocks: float
|
||
max_buffered_blocks: int
|
||
mean_intrablock_symbol_gap_seconds: float
|
||
mean_intrablock_symbol_span_seconds: float
|
||
max_intrablock_symbol_span_seconds: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SimulationResult:
|
||
mode: str
|
||
label: str
|
||
source_block_size: int
|
||
nominal_parity_count: int
|
||
interleaving_depth: int
|
||
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
|
||
service_and_parity_percent: float
|
||
control_stream_bitrate_kbps: float
|
||
schedule_duration_seconds: float
|
||
interleaving_group_count: int
|
||
final_group_block_count: int
|
||
mean_group_wait_delay_seconds: float
|
||
p95_group_wait_delay_seconds: float
|
||
max_group_wait_delay_seconds: float
|
||
mean_queue_length_packets: float
|
||
max_queue_length_packets: int
|
||
mean_buffered_blocks: float
|
||
max_buffered_blocks: int
|
||
mean_intrablock_symbol_gap_seconds: float
|
||
mean_intrablock_symbol_span_seconds: float
|
||
max_intrablock_symbol_span_seconds: float
|
||
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
|
||
roi_objects_completed: int
|
||
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 _block_units(
|
||
units: tuple[TransmissionUnit, ...],
|
||
) -> tuple[tuple[TransmissionUnit, ...], ...]:
|
||
grouped: dict[int, list[TransmissionUnit]] = {}
|
||
order = []
|
||
for unit in units:
|
||
if unit.block_id not in grouped:
|
||
order.append(unit.block_id)
|
||
grouped[unit.block_id] = []
|
||
grouped[unit.block_id].append(unit)
|
||
return tuple(tuple(grouped[block_id]) for block_id in order)
|
||
|
||
|
||
def _symbol_timing(
|
||
schedule: ModeSchedule,
|
||
) -> tuple[float, float, float]:
|
||
endings: dict[int, list[tuple[int, float]]] = {}
|
||
for scheduled in schedule.units:
|
||
unit = scheduled.unit
|
||
endings.setdefault(unit.block_id, []).append(
|
||
(unit.symbol_index, scheduled.end_seconds)
|
||
)
|
||
gaps = []
|
||
spans = []
|
||
for values in endings.values():
|
||
ordered = [
|
||
end for _, end in sorted(values)
|
||
]
|
||
gaps.extend(
|
||
right - left
|
||
for left, right in zip(ordered, ordered[1:])
|
||
)
|
||
if len(ordered) > 1:
|
||
spans.append(ordered[-1] - ordered[0])
|
||
return (
|
||
float(np.mean(gaps)) if gaps else 0.0,
|
||
float(np.mean(spans)) if spans else 0.0,
|
||
max(spans) if spans else 0.0,
|
||
)
|
||
|
||
|
||
def _make_mode_schedule(
|
||
metadata: VideoMetadata,
|
||
composites: list[EncodedComposite],
|
||
source_packets: tuple[SourcePacket, ...],
|
||
mode: InterleavingMode,
|
||
) -> InterleavedSchedule:
|
||
lab030_mode = FECMode(mode.name, mode.parity_count, mode.label)
|
||
sequential_units, blocks = build_mode_units(
|
||
source_packets, lab030_mode
|
||
)
|
||
|
||
groups = []
|
||
waits = []
|
||
buffer_samples = []
|
||
if mode.parity_count == 0:
|
||
reordered_units = sequential_units
|
||
else:
|
||
plans_by_id = {plan.block_id: plan for plan in blocks}
|
||
units_by_block = _block_units(sequential_units)
|
||
reordered = []
|
||
sequence_index = 0
|
||
for group_index, start in enumerate(
|
||
range(0, len(units_by_block), mode.depth)
|
||
):
|
||
group_units = units_by_block[start:start + mode.depth]
|
||
block_ids = tuple(
|
||
units[0].block_id for units in group_units
|
||
)
|
||
ready_times = tuple(
|
||
max(
|
||
source_packets[index].generation_time_seconds
|
||
for index in plans_by_id[
|
||
block_id
|
||
].source_global_indices
|
||
)
|
||
for block_id in block_ids
|
||
)
|
||
complete_group = len(group_units) == mode.depth
|
||
release_time = max(ready_times)
|
||
if mode.depth > 1 and not complete_group:
|
||
release_time = max(
|
||
release_time, metadata.duration_seconds
|
||
)
|
||
waits.extend(
|
||
release_time - ready for ready in ready_times
|
||
)
|
||
buffer_samples.extend(
|
||
range(1, len(group_units) + 1)
|
||
)
|
||
|
||
wire_blocks = tuple(
|
||
tuple(unit.wire_packet for unit in units)
|
||
for units in group_units
|
||
)
|
||
interleaved = interleave_group(wire_blocks)
|
||
lookup = {
|
||
(unit.block_id, unit.symbol_index): unit
|
||
for units in group_units
|
||
for unit in units
|
||
}
|
||
for wire_packet in interleaved:
|
||
parsed = decode_outer_symbol(wire_packet)
|
||
original = lookup[
|
||
(parsed.block_id, parsed.symbol_index)
|
||
]
|
||
generation_time = (
|
||
original.generation_time_seconds
|
||
if mode.depth == 1
|
||
else release_time
|
||
)
|
||
reordered.append(
|
||
replace(
|
||
original,
|
||
sequence_index=sequence_index,
|
||
generation_time_seconds=generation_time,
|
||
)
|
||
)
|
||
sequence_index += 1
|
||
groups.append(
|
||
InterleavingGroupPlan(
|
||
group_index=group_index,
|
||
block_ids=block_ids,
|
||
ready_seconds=ready_times,
|
||
release_seconds=release_time,
|
||
unit_count=len(interleaved),
|
||
)
|
||
)
|
||
reordered_units = tuple(reordered)
|
||
|
||
scheduled, duration, mean_queue, max_queue = schedule_units(
|
||
reordered_units
|
||
)
|
||
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
|
||
)
|
||
fec_schedule = ModeSchedule(
|
||
mode=lab030_mode,
|
||
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,
|
||
)
|
||
if mode.parity_count:
|
||
symbol_gap, symbol_span, max_symbol_span = _symbol_timing(
|
||
fec_schedule
|
||
)
|
||
else:
|
||
symbol_gap, symbol_span, max_symbol_span = 0.0, 0.0, 0.0
|
||
return InterleavedSchedule(
|
||
mode=mode,
|
||
fec_schedule=fec_schedule,
|
||
groups=tuple(groups),
|
||
group_wait_delays_seconds=tuple(waits),
|
||
mean_group_wait_delay_seconds=(
|
||
float(np.mean(waits)) if waits else 0.0
|
||
),
|
||
p95_group_wait_delay_seconds=percentile(waits, 95),
|
||
max_group_wait_delay_seconds=max(waits) if waits else 0.0,
|
||
mean_buffered_blocks=(
|
||
float(np.mean(buffer_samples))
|
||
if buffer_samples
|
||
else 0.0
|
||
),
|
||
max_buffered_blocks=(
|
||
max(buffer_samples) if buffer_samples else 0
|
||
),
|
||
mean_intrablock_symbol_gap_seconds=symbol_gap,
|
||
mean_intrablock_symbol_span_seconds=symbol_span,
|
||
max_intrablock_symbol_span_seconds=max_symbol_span,
|
||
)
|
||
|
||
|
||
def build_schedules(
|
||
metadata: VideoMetadata,
|
||
composites: list[EncodedComposite],
|
||
profile: PreparedProfile,
|
||
) -> dict[str, InterleavedSchedule]:
|
||
source_packets = prepare_source_packets(profile)
|
||
return {
|
||
mode.name: _make_mode_schedule(
|
||
metadata, composites, source_packets, mode
|
||
)
|
||
for mode in MODES
|
||
}
|
||
|
||
|
||
def _aggregate_condition(
|
||
schedule: InterleavedSchedule,
|
||
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
|
||
source_packets_per_pass = len(fec_schedule.source_packets)
|
||
parity_packets_per_pass = sum(
|
||
int(unit.unit.is_parity) for unit in fec_schedule.units
|
||
)
|
||
total_fec_blocks = len(fec_schedule.blocks) * repetitions
|
||
delivered_bytes = total("delivered_jpeg_bytes")
|
||
source_jpeg_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
|
||
)
|
||
return SimulationResult(
|
||
mode=schedule.mode.name,
|
||
label=schedule.mode.label,
|
||
source_block_size=SOURCE_BLOCK_SIZE,
|
||
nominal_parity_count=schedule.mode.parity_count,
|
||
interleaving_depth=schedule.mode.depth,
|
||
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_jpeg_rate,
|
||
inner_packet_stream_bitrate_kbps=inner_rate,
|
||
outer_fec_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,
|
||
interleaving_group_count=len(schedule.groups),
|
||
final_group_block_count=(
|
||
len(schedule.groups[-1].block_ids)
|
||
if schedule.groups
|
||
else 0
|
||
),
|
||
mean_group_wait_delay_seconds=(
|
||
schedule.mean_group_wait_delay_seconds
|
||
),
|
||
p95_group_wait_delay_seconds=(
|
||
schedule.p95_group_wait_delay_seconds
|
||
),
|
||
max_group_wait_delay_seconds=(
|
||
schedule.max_group_wait_delay_seconds
|
||
),
|
||
mean_queue_length_packets=(
|
||
fec_schedule.mean_queue_length_packets
|
||
),
|
||
max_queue_length_packets=(
|
||
fec_schedule.max_queue_length_packets
|
||
),
|
||
mean_buffered_blocks=schedule.mean_buffered_blocks,
|
||
max_buffered_blocks=schedule.max_buffered_blocks,
|
||
mean_intrablock_symbol_gap_seconds=(
|
||
schedule.mean_intrablock_symbol_gap_seconds
|
||
),
|
||
mean_intrablock_symbol_span_seconds=(
|
||
schedule.mean_intrablock_symbol_span_seconds
|
||
),
|
||
max_intrablock_symbol_span_seconds=(
|
||
schedule.max_intrablock_symbol_span_seconds
|
||
),
|
||
transmitted_source_packets=(
|
||
source_packets_per_pass * 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"),
|
||
roi_objects_completed=total("roi_objects_completed"),
|
||
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=(
|
||
delivered_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, InterleavedSchedule],
|
||
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(
|
||
_aggregate_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, InterleavedSchedule],
|
||
) -> list[FunctionalTestResult]:
|
||
tests: list[tuple[str, Callable[[], str]]] = []
|
||
protected_modes = tuple(
|
||
mode for mode in MODES if mode.parity_count
|
||
)
|
||
sequential_82, _ = build_mode_units(
|
||
schedules["8+2_D1"].fec_schedule.source_packets,
|
||
FECMode("lab030_8+2", 2, "8+2"),
|
||
)
|
||
|
||
def d1_matches_lab030_order() -> str:
|
||
actual = tuple(
|
||
unit.unit.wire_packet
|
||
for unit in schedules["8+2_D1"].fec_schedule.units
|
||
)
|
||
expected = tuple(
|
||
unit.wire_packet for unit in sequential_82
|
||
)
|
||
if actual != expected:
|
||
raise AssertionError("D=1 changed Lab030 wire order")
|
||
return "D=1 wire order is byte-identical to Lab030"
|
||
|
||
def permutation_preserves_multiset() -> str:
|
||
schedule = schedules["8+2_D8"]
|
||
original, _ = build_mode_units(
|
||
schedule.fec_schedule.source_packets,
|
||
FECMode("reference", 2, "8+2"),
|
||
)
|
||
before = sorted(unit.wire_packet for unit in original)
|
||
after = sorted(
|
||
unit.unit.wire_packet
|
||
for unit in schedule.fec_schedule.units
|
||
)
|
||
if before != after:
|
||
raise AssertionError("interleaving added or removed packets")
|
||
return f"all {len(before)} packets preserved"
|
||
|
||
def every_packet_once() -> str:
|
||
for mode in protected_modes:
|
||
packets = [
|
||
unit.unit.wire_packet
|
||
for unit in schedules[mode.name].fec_schedule.units
|
||
]
|
||
if len(packets) != len(set(packets)):
|
||
raise AssertionError(
|
||
f"{mode.name} contains a duplicate packet"
|
||
)
|
||
return "every protected-mode packet appears exactly once"
|
||
|
||
def identifiers_are_preserved() -> str:
|
||
schedule = schedules["8+2_D4"].fec_schedule
|
||
for unit in schedule.units:
|
||
parsed = decode_outer_symbol(unit.unit.wire_packet)
|
||
if (
|
||
parsed.block_id != unit.unit.block_id
|
||
or parsed.symbol_index != unit.unit.symbol_index
|
||
):
|
||
raise AssertionError("outer identifiers changed")
|
||
return "block_id and symbol_index survived permutation"
|
||
|
||
def inverse_distribution_works() -> str:
|
||
checked = []
|
||
source_packets = schedules[
|
||
"8+2_D1"
|
||
].fec_schedule.source_packets[:64]
|
||
for depth in (2, 4, 8):
|
||
wire_blocks = tuple(
|
||
encode_fec_block(
|
||
tuple(
|
||
packet.inner_packet
|
||
for packet in source_packets[
|
||
start:start + SOURCE_BLOCK_SIZE
|
||
]
|
||
),
|
||
start // SOURCE_BLOCK_SIZE,
|
||
2,
|
||
)
|
||
for start in range(
|
||
0, len(source_packets), SOURCE_BLOCK_SIZE
|
||
)
|
||
)
|
||
interleaved = interleave_blocks(wire_blocks, depth)
|
||
restored = deinterleave_symbols(interleaved)
|
||
if restored != wire_blocks:
|
||
raise AssertionError(
|
||
f"inverse distribution failed for D={depth}"
|
||
)
|
||
checked.append(depth)
|
||
return f"inverse grouping passed for D={checked}"
|
||
|
||
def decoded_packets_are_exact() -> str:
|
||
source_packets = schedules[
|
||
"8+2_D1"
|
||
].fec_schedule.source_packets[:32]
|
||
wire_blocks = tuple(
|
||
encode_fec_block(
|
||
tuple(
|
||
packet.inner_packet
|
||
for packet in source_packets[
|
||
start:start + SOURCE_BLOCK_SIZE
|
||
]
|
||
),
|
||
start // SOURCE_BLOCK_SIZE,
|
||
2,
|
||
)
|
||
for start in range(0, 32, SOURCE_BLOCK_SIZE)
|
||
)
|
||
interleaved = interleave_blocks(wire_blocks, 4)
|
||
received = tuple(
|
||
packet
|
||
for packet in interleaved
|
||
if decode_outer_symbol(packet).symbol_index not in {1, 7}
|
||
)
|
||
restored_blocks = deinterleave_symbols(received)
|
||
restored_packets = []
|
||
for block in restored_blocks:
|
||
decoded = decode_fec_block(block)
|
||
restored_packets.extend(decoded.source_packets)
|
||
expected = [
|
||
packet.inner_packet for packet in source_packets
|
||
]
|
||
if restored_packets != expected:
|
||
raise AssertionError("decoded inner bytes differ")
|
||
return "decoded inner packets are byte-exact"
|
||
|
||
def both_crc_layers_pass() -> str:
|
||
schedule = schedules["8+4_D4"].fec_schedule
|
||
first_block = [
|
||
unit.unit.wire_packet
|
||
for unit in schedule.units
|
||
if unit.unit.block_id == 0
|
||
and unit.unit.symbol_index not in {0, 2, 8, 10}
|
||
]
|
||
for outer in first_block:
|
||
decode_outer_symbol(outer)
|
||
decoded = decode_fec_block(tuple(first_block))
|
||
for inner in decoded.source_packets:
|
||
decode_inner_packet(inner)
|
||
return "outer and recovered Lab028 CRC32 checks passed"
|
||
|
||
def zero_bad_restores_every_frame() -> str:
|
||
for mode in MODES:
|
||
schedule = schedules[mode.name].fec_schedule
|
||
flags = np.zeros(len(schedule.units), dtype=np.bool_)
|
||
result = (
|
||
simulate_baseline(schedule, flags, len(composites))
|
||
if mode.parity_count == 0
|
||
else simulate_fec(schedule, flags, len(composites))
|
||
)
|
||
if (
|
||
result.atomic_composite_frames_completed
|
||
!= len(composites)
|
||
):
|
||
raise AssertionError(
|
||
f"zero-Bad failed for {mode.name}"
|
||
)
|
||
return "all seven modes restored 100% of frames without Bad"
|
||
|
||
def fixed_seed_is_reproducible() -> str:
|
||
first = _aggregate_condition(
|
||
schedules["8+2_D4"],
|
||
len(composites),
|
||
0.05,
|
||
319_319,
|
||
3,
|
||
)
|
||
second = _aggregate_condition(
|
||
schedules["8+2_D4"],
|
||
len(composites),
|
||
0.05,
|
||
319_319,
|
||
3,
|
||
)
|
||
if first != second:
|
||
raise AssertionError("same seed changed the result")
|
||
return "identical seed produced identical result fields"
|
||
|
||
def incomplete_composite_is_not_published() -> str:
|
||
receiver = CompositeReassembler()
|
||
first_frame = [
|
||
packet.inner_packet
|
||
for packet in schedules[
|
||
"none"
|
||
].fec_schedule.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 depth_increases_symbol_distance() -> str:
|
||
gaps = [
|
||
schedules[f"8+2_D{depth}"].mean_intrablock_symbol_gap_seconds
|
||
for depth in (1, 2, 4, 8)
|
||
]
|
||
if any(
|
||
right <= left
|
||
for left, right in zip(gaps, gaps[1:])
|
||
):
|
||
raise AssertionError(
|
||
f"symbol gaps are not strictly increasing: {gaps}"
|
||
)
|
||
return "D=1/2/4/8 strictly increase intrablock symbol spacing"
|
||
|
||
def group_wait_is_in_total_delay() -> str:
|
||
d1 = schedules["8+2_D1"]
|
||
d4 = schedules["8+2_D4"]
|
||
flags1 = np.zeros(
|
||
len(d1.fec_schedule.units), dtype=np.bool_
|
||
)
|
||
flags4 = np.zeros(
|
||
len(d4.fec_schedule.units), dtype=np.bool_
|
||
)
|
||
result1 = simulate_fec(
|
||
d1.fec_schedule, flags1, len(composites)
|
||
)
|
||
result4 = simulate_fec(
|
||
d4.fec_schedule, flags4, len(composites)
|
||
)
|
||
mean1 = float(np.mean(result1.publication_delays))
|
||
mean4 = float(np.mean(result4.publication_delays))
|
||
if d4.mean_group_wait_delay_seconds <= 0.0:
|
||
raise AssertionError("D=4 has no group wait")
|
||
if mean4 <= mean1:
|
||
raise AssertionError(
|
||
"group wait did not increase publication delay"
|
||
)
|
||
return (
|
||
f"D=4 group wait {d4.mean_group_wait_delay_seconds:.6f} s "
|
||
f"is reflected in publication delay"
|
||
)
|
||
|
||
def final_partial_group_is_transmitted() -> str:
|
||
schedule = schedules["8+2_D8"]
|
||
if not schedule.groups:
|
||
raise AssertionError("no interleaving groups")
|
||
final = schedule.groups[-1]
|
||
if len(final.block_ids) >= schedule.mode.depth:
|
||
raise AssertionError("test stream has no partial final group")
|
||
packets = [
|
||
unit.unit.wire_packet
|
||
for unit in schedule.fec_schedule.units
|
||
if unit.unit.block_id in final.block_ids
|
||
]
|
||
if len(packets) != final.unit_count:
|
||
raise AssertionError("partial final group lost packets")
|
||
if (
|
||
final.release_seconds
|
||
< schedule.fec_schedule.source_duration_seconds
|
||
):
|
||
raise AssertionError("partial group was released before stream end")
|
||
return (
|
||
f"final {len(final.block_ids)}-block group emitted "
|
||
f"{len(packets)} packets at end of stream"
|
||
)
|
||
|
||
tests.extend(
|
||
[
|
||
("d1_matches_lab030", d1_matches_lab030_order),
|
||
("permutation_preserves_packets", permutation_preserves_multiset),
|
||
("each_packet_once", every_packet_once),
|
||
("identifiers_preserved", identifiers_are_preserved),
|
||
("inverse_distribution", inverse_distribution_works),
|
||
("decoded_inner_byte_exact", decoded_packets_are_exact),
|
||
("inner_outer_crc", both_crc_layers_pass),
|
||
("zero_bad_100_percent", zero_bad_restores_every_frame),
|
||
("fixed_seed_reproducibility", fixed_seed_is_reproducible),
|
||
(
|
||
"atomic_incomplete_composite",
|
||
incomplete_composite_is_not_published,
|
||
),
|
||
(
|
||
"depth_increases_symbol_distance",
|
||
depth_increases_symbol_distance,
|
||
),
|
||
(
|
||
"group_wait_in_publication_delay",
|
||
group_wait_is_in_total_delay,
|
||
),
|
||
(
|
||
"final_partial_group",
|
||
final_partial_group_is_transmitted,
|
||
),
|
||
]
|
||
)
|
||
results = []
|
||
for name, test in tests:
|
||
try:
|
||
detail = test()
|
||
except Exception as error:
|
||
results.append(FunctionalTestResult(name, False, str(error)))
|
||
else:
|
||
results.append(FunctionalTestResult(name, True, detail))
|
||
failed = [result for result in results if not result.passed]
|
||
if failed:
|
||
raise RuntimeError(
|
||
"Lab031 functional checks failed: "
|
||
+ "; ".join(
|
||
f"{result.name}: {result.detail}"
|
||
for result in failed
|
||
)
|
||
)
|
||
return 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} result rows, got {len(results)}"
|
||
)
|
||
keys = {
|
||
(result.mode, result.mean_bad_duration_ms)
|
||
for result in results
|
||
}
|
||
if len(keys) != expected:
|
||
raise RuntimeError("Lab031 result rows are not unique")
|
||
if any(
|
||
not 0.0 <= result.composite_success_rate <= 1.0
|
||
for result in results
|
||
):
|
||
raise RuntimeError("composite success is outside 0...1")
|
||
if any(
|
||
not 0.0 <= result.fec_unrecoverable_block_rate <= 1.0
|
||
for result in results
|
||
):
|
||
raise RuntimeError("unrecoverable block rate is outside 0...1")
|
||
|
||
|
||
def save_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 _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_metric_plot(
|
||
results: list[SimulationResult],
|
||
field: str,
|
||
ylabel: str,
|
||
title: str,
|
||
path: Path,
|
||
) -> None:
|
||
figure, axis = plt.subplots(figsize=(10.8, 6.4))
|
||
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 save_plots(results: list[SimulationResult]) -> None:
|
||
_save_metric_plot(
|
||
results,
|
||
"composite_success_rate",
|
||
"Доля восстановленных составных кадров",
|
||
"Lab031. Атомарное восстановление BASE + ROI",
|
||
COMPOSITE_SUCCESS_PLOT_PATH,
|
||
)
|
||
_save_metric_plot(
|
||
results,
|
||
"p95_no_new_image_duration_seconds",
|
||
"P95 отсутствия нового изображения, с",
|
||
"Lab031. Длительность удержания последнего изображения",
|
||
NO_IMAGE_PLOT_PATH,
|
||
)
|
||
_save_metric_plot(
|
||
results,
|
||
"p95_publication_delay_seconds",
|
||
"P95 задержки публикации, с",
|
||
"Lab031. Задержка публикации составного кадра",
|
||
PUBLICATION_DELAY_PLOT_PATH,
|
||
)
|
||
|
||
figure, axis = plt.subplots(figsize=(10.8, 6.4))
|
||
for mode in MODES:
|
||
if not mode.parity_count:
|
||
continue
|
||
rows = _rows_for_mode(results, mode.name)
|
||
axis.plot(
|
||
[row.mean_bad_duration_ms for row in rows],
|
||
[row.fec_all_block_success_rate for row in rows],
|
||
marker="o",
|
||
linewidth=1.8,
|
||
label=mode.label,
|
||
)
|
||
axis.set_xscale("log")
|
||
axis.set_xlabel("Средняя длительность Bad, мс")
|
||
axis.set_ylabel("Доля восстановимых FEC-блоков")
|
||
axis.set_title("Lab031. Восстановление FEC-блоков")
|
||
axis.grid(True, which="both", alpha=0.28)
|
||
axis.legend(ncol=2, fontsize=8)
|
||
figure.tight_layout()
|
||
figure.savefig(BLOCK_RECOVERY_PLOT_PATH, dpi=160)
|
||
plt.close(figure)
|
||
|
||
representative = result_lookup(results, 200.0)
|
||
depth_rows = [
|
||
representative[f"8+2_D{depth}"]
|
||
for depth in (1, 2, 4, 8)
|
||
]
|
||
figure, success_axis = plt.subplots(figsize=(10.2, 6.2))
|
||
delay_axis = success_axis.twinx()
|
||
depths = [row.interleaving_depth for row in depth_rows]
|
||
success_axis.plot(
|
||
depths,
|
||
[row.composite_success_rate for row in depth_rows],
|
||
color="#1565c0",
|
||
marker="o",
|
||
linewidth=2.2,
|
||
label="Составные кадры",
|
||
)
|
||
delay_axis.plot(
|
||
depths,
|
||
[row.p95_publication_delay_seconds for row in depth_rows],
|
||
color="#c62828",
|
||
marker="s",
|
||
linewidth=2.0,
|
||
label="P95 публикации",
|
||
)
|
||
delay_axis.plot(
|
||
depths,
|
||
[row.mean_group_wait_delay_seconds for row in depth_rows],
|
||
color="#ef6c00",
|
||
marker="^",
|
||
linestyle="--",
|
||
linewidth=1.8,
|
||
label="Среднее ожидание группы",
|
||
)
|
||
success_axis.set_xticks(depths)
|
||
success_axis.set_xlabel("Глубина перемежения D")
|
||
success_axis.set_ylabel(
|
||
"Доля восстановленных составных кадров",
|
||
color="#1565c0",
|
||
)
|
||
delay_axis.set_ylabel("Задержка, с", color="#c62828")
|
||
success_axis.set_title(
|
||
"Lab031. Устойчивость и задержка 8+2 при Bad 200 мс"
|
||
)
|
||
success_axis.grid(True, alpha=0.28)
|
||
lines = success_axis.lines + delay_axis.lines
|
||
success_axis.legend(
|
||
lines,
|
||
[line.get_label() for line in lines],
|
||
loc="best",
|
||
)
|
||
figure.tight_layout()
|
||
figure.savefig(DEPTH_TRADEOFF_PLOT_PATH, dpi=160)
|
||
plt.close(figure)
|
||
|
||
rows = [representative[mode.name] for mode in MODES]
|
||
positions = np.arange(len(rows))
|
||
figure, success_axis = plt.subplots(figsize=(12.0, 6.5))
|
||
delay_axis = success_axis.twinx()
|
||
success_axis.bar(
|
||
positions - 0.2,
|
||
[row.composite_success_rate for row in rows],
|
||
width=0.4,
|
||
color="#2e7d32",
|
||
label="Составные кадры",
|
||
)
|
||
delay_axis.bar(
|
||
positions + 0.2,
|
||
[row.p95_publication_delay_seconds for row in rows],
|
||
width=0.4,
|
||
color="#6a1b9a",
|
||
alpha=0.72,
|
||
label="P95 публикации",
|
||
)
|
||
success_axis.set_xticks(
|
||
positions,
|
||
[mode.label for mode in MODES],
|
||
rotation=24,
|
||
ha="right",
|
||
)
|
||
success_axis.set_ylabel("Доля восстановленных кадров")
|
||
delay_axis.set_ylabel("P95 задержки публикации, с")
|
||
success_axis.set_title(
|
||
"Lab031. Итоговое сравнение режимов при Bad 200 мс"
|
||
)
|
||
success_axis.grid(True, axis="y", alpha=0.25)
|
||
lines = [
|
||
success_axis.patches[0],
|
||
delay_axis.patches[0],
|
||
]
|
||
success_axis.legend(
|
||
lines,
|
||
["Составные кадры", "P95 публикации"],
|
||
loc="upper left",
|
||
)
|
||
figure.tight_layout()
|
||
figure.savefig(MODE_COMPARISON_PLOT_PATH, dpi=160)
|
||
plt.close(figure)
|
||
|
||
|
||
def _mode_configuration_table(
|
||
schedules: dict[str, InterleavedSchedule],
|
||
) -> list[str]:
|
||
lines = [
|
||
(
|
||
"mode | k+r | D | groups | final group | source/parity "
|
||
"packets | outer kbit/s | overhead | wait mean/max s | "
|
||
"buffer mean/max | queue mean/max | symbol gap/span ms"
|
||
),
|
||
(
|
||
"----:|----:|--:|-------:|------------:|----------------------:"
|
||
"|-------------:|---------:|----------------:|----------------:"
|
||
"|---------------:|-------------------:"
|
||
),
|
||
]
|
||
for mode in MODES:
|
||
schedule = schedules[mode.name]
|
||
fec = schedule.fec_schedule
|
||
source_count = len(fec.source_packets)
|
||
parity_count = sum(
|
||
int(unit.unit.is_parity) for unit in fec.units
|
||
)
|
||
outer_rate = (
|
||
fec.total_transmitted_bytes
|
||
* 8.0
|
||
/ fec.source_duration_seconds
|
||
/ 1000.0
|
||
)
|
||
overhead = (
|
||
(
|
||
fec.total_transmitted_bytes - fec.total_jpeg_bytes
|
||
)
|
||
/ fec.total_transmitted_bytes
|
||
* 100.0
|
||
)
|
||
block_code = (
|
||
"none"
|
||
if not mode.parity_count
|
||
else f"8+{mode.parity_count}"
|
||
)
|
||
lines.append(
|
||
f"{mode.label} | {block_code} | {mode.depth} | "
|
||
f"{len(schedule.groups)} | "
|
||
f"{len(schedule.groups[-1].block_ids) if schedule.groups else 0} "
|
||
f"| {source_count}/{parity_count} | {outer_rate:.3f} | "
|
||
f"{overhead:.3f}% | "
|
||
f"{schedule.mean_group_wait_delay_seconds:.6f}/"
|
||
f"{schedule.max_group_wait_delay_seconds:.6f} | "
|
||
f"{schedule.mean_buffered_blocks:.3f}/"
|
||
f"{schedule.max_buffered_blocks} | "
|
||
f"{fec.mean_queue_length_packets:.3f}/"
|
||
f"{fec.max_queue_length_packets} | "
|
||
f"{schedule.mean_intrablock_symbol_gap_seconds * 1000:.3f}/"
|
||
f"{schedule.mean_intrablock_symbol_span_seconds * 1000:.3f}"
|
||
)
|
||
return lines
|
||
|
||
|
||
def _representative_table(
|
||
results: list[SimulationResult],
|
||
) -> list[str]:
|
||
rows = result_lookup(results, 200.0)
|
||
lines = [
|
||
(
|
||
"mode | composite | no-image mean/p95/max s | publication "
|
||
"mean/p95/max s | affected blocks | all blocks | "
|
||
"unrecoverable | delivered kbit/s | incomplete run "
|
||
"mean/p95/max"
|
||
),
|
||
(
|
||
"----:|----------:|------------------------:|-------------------------:"
|
||
"|----------------:|-----------:|--------------:|----------------:"
|
||
"|------------------------:"
|
||
),
|
||
]
|
||
for mode in MODES:
|
||
row = rows[mode.name]
|
||
lines.append(
|
||
f"{mode.label} | {row.composite_success_rate:.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_publication_delay_seconds:.6f}/"
|
||
f"{row.p95_publication_delay_seconds:.6f}/"
|
||
f"{row.max_publication_delay_seconds:.6f} | "
|
||
f"{row.fec_affected_block_recovery_rate:.6f} | "
|
||
f"{row.fec_all_block_success_rate:.6f} | "
|
||
f"{row.fec_unrecoverable_block_rate:.6f} | "
|
||
f"{row.effective_delivered_video_bitrate_kbps:.3f} | "
|
||
f"{row.mean_consecutive_incomplete_frames:.3f}/"
|
||
f"{row.p95_consecutive_incomplete_frames:.3f}/"
|
||
f"{row.max_consecutive_incomplete_frames}"
|
||
)
|
||
return lines
|
||
|
||
|
||
def _outperformance_lines(
|
||
results: list[SimulationResult],
|
||
) -> list[str]:
|
||
lines = []
|
||
for duration in (
|
||
value * 1000.0 for value in MEAN_BAD_DURATIONS_SECONDS
|
||
):
|
||
rows = result_lookup(results, duration)
|
||
control = rows["8+4_D1"]
|
||
for name in ("8+2_D2", "8+2_D4", "8+2_D8"):
|
||
candidate = rows[name]
|
||
if (
|
||
candidate.composite_success_rate
|
||
> control.composite_success_rate
|
||
):
|
||
lines.append(
|
||
f"- Bad {duration:.0f} мс, {candidate.label}: "
|
||
f"composite {candidate.composite_success_rate:.6f} "
|
||
f"> {control.composite_success_rate:.6f}; "
|
||
f"P95 no-image "
|
||
f"{candidate.p95_no_new_image_duration_seconds:.6f} "
|
||
f"с против "
|
||
f"{control.p95_no_new_image_duration_seconds:.6f} с; "
|
||
f"P95 публикации "
|
||
f"{candidate.p95_publication_delay_seconds:.6f} "
|
||
f"с против "
|
||
f"{control.p95_publication_delay_seconds:.6f} с."
|
||
)
|
||
if not lines:
|
||
lines.append(
|
||
"- В исследованных условиях 8+2 с перемежением не превзошёл "
|
||
"8+4, D=1 по доле составных кадров."
|
||
)
|
||
return lines
|
||
|
||
|
||
def write_report(
|
||
metadata: VideoMetadata,
|
||
composites: list[EncodedComposite],
|
||
schedules: dict[str, InterleavedSchedule],
|
||
results: list[SimulationResult],
|
||
tests: list[FunctionalTestResult],
|
||
) -> None:
|
||
lines = [
|
||
"Lab031. Перемежение пакетов между FEC-блоками",
|
||
"",
|
||
"Исходные данные и неизменные слои",
|
||
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: payload 512 байт, "
|
||
"packet CRC32, object CRC32, атомарная публикация BASE+ROI."
|
||
),
|
||
(
|
||
"- Внешний формат и GF(256) Lab030 используются без "
|
||
"изменений; k=8, parity r=2 или r=4."
|
||
),
|
||
"",
|
||
"Точное правило перестановки",
|
||
(
|
||
"- Последовательные готовые FEC-блоки делятся на группы "
|
||
"не более D блоков."
|
||
),
|
||
(
|
||
"- Для symbol_index=0,1,2,... передаётся символ с этим "
|
||
"индексом блока 0, затем блока 1, ... блока D-1."
|
||
),
|
||
(
|
||
"- Если блок короче, отсутствующий symbol_index пропускается. "
|
||
"Внутри блока сохраняется порядок systematic, затем parity."
|
||
),
|
||
(
|
||
"- Перестановка не изменяет байты, block_id или symbol_index "
|
||
"и не добавляет пакеты."
|
||
),
|
||
"",
|
||
"Формирование и буферизация групп",
|
||
(
|
||
"- Полная группа D>1 выпускается после готовности D-го "
|
||
"последовательного блока; все её пакеты получают это время "
|
||
"доступности перед FIFO-передатчиком."
|
||
),
|
||
(
|
||
"- Неполная последняя группа выпускается в момент окончания "
|
||
"видеопотока; синтетические блоки и символы не добавляются."
|
||
),
|
||
(
|
||
"- D=1 сохраняет порядок и времена доступности Lab030 и "
|
||
"имеет нулевую добавочную задержку ожидания группы."
|
||
),
|
||
(
|
||
"- Размер группы формируется в порядке возрастания block_id; "
|
||
"полные группы имеют D блоков, последняя — остаток."
|
||
),
|
||
(
|
||
"- Средний буфер — среднее заполнение после каждого прихода "
|
||
"готового блока (1..размер группы); максимум включает момент "
|
||
"непосредственно перед выпуском группы."
|
||
),
|
||
"",
|
||
"Семь фиксированных режимов и статические метрики",
|
||
*_mode_configuration_table(schedules),
|
||
"",
|
||
"Временная модель и статистика",
|
||
(
|
||
"- Непрерывные чередующиеся экспоненциальные Good/Bad "
|
||
"интервалы Lab029B, средняя доля Bad около 2%."
|
||
),
|
||
(
|
||
"- Средние Bad: 10, 50, 200, 1000 мс; 200 повторов на "
|
||
f"условие; seeds {SEED_BASE}...{SEED_BASE + 3}."
|
||
),
|
||
(
|
||
"- Пакет теряется при любом пересечении интервала его "
|
||
"передачи с Bad; скорость FIFO 300 кбит/с."
|
||
),
|
||
(
|
||
"- Учитываются фактическая длина каждого внутреннего и "
|
||
"внешнего пакета, ожидание группы и накопление очереди."
|
||
),
|
||
(
|
||
"- Приёмник группирует внешние символы по block_id, "
|
||
"декодирует после любых k принятых символов и передаёт "
|
||
"восстановленные внутренние пакеты в CompositeReassembler."
|
||
),
|
||
(
|
||
"- Соседние composite_frame_id не смешиваются; неполный "
|
||
"BASE+ROI кадр не публикуется."
|
||
),
|
||
"",
|
||
"Результаты при средней Bad 200 мс",
|
||
*_representative_table(results),
|
||
"",
|
||
"Когда 8+2 с перемежением превосходит 8+4 без него",
|
||
(
|
||
"- Критерий ниже: строго более высокая доля атомарно "
|
||
"восстановленных составных кадров; задержки приведены рядом "
|
||
"как цена перемежения."
|
||
),
|
||
*_outperformance_lines(results),
|
||
"",
|
||
"Функциональные проверки",
|
||
*[
|
||
f"- {'PASS' if test.passed else 'FAIL'} "
|
||
f"{test.name}: {test.detail}"
|
||
for test in tests
|
||
],
|
||
"",
|
||
"Допущения модели",
|
||
(
|
||
"- Видеопрофиль конечный и детерминированный; JPEG "
|
||
"кодируются в памяти один раз перед Monte Carlo."
|
||
),
|
||
(
|
||
"- Устройство знает конец потока и только тогда выпускает "
|
||
"неполную последнюю группу D>1."
|
||
),
|
||
(
|
||
"- Время готовности FEC-блока равно максимальному времени "
|
||
"генерации его исходных пакетов; вычислительная задержка "
|
||
"GF(256) считается нулевой."
|
||
),
|
||
(
|
||
"- D=1 определён как точный режим Lab030; поэтому он не "
|
||
"ждёт завершения блока перед отправкой уже готовых "
|
||
"systematic symbols."
|
||
),
|
||
(
|
||
"- Передатчик и канал общие FIFO; параллельные радиоканалы, "
|
||
"ARQ, повторные передачи, команды, телеметрия, модуляция и "
|
||
"реальный SDR не моделируются."
|
||
),
|
||
(
|
||
"- Потеря является стиранием полного внешнего пакета; "
|
||
"отдельные битовые ошибки не добавляются."
|
||
),
|
||
(
|
||
"- Publication delay считается от frame_id/3 до конца "
|
||
"пакета, завершившего атомарную сборку; no-image включает "
|
||
"время до следующего опубликованного кадра или конца "
|
||
"расписания."
|
||
),
|
||
(
|
||
"- Intrablock symbol gap измеряется между временами конца "
|
||
"последовательных symbol_index; span — от первого до "
|
||
"последнего символа блока."
|
||
),
|
||
"",
|
||
"Артефакты",
|
||
f"- CSV: {CSV_PATH}",
|
||
f"- Отчёт: {REPORT_PATH}",
|
||
f"- Доля восстановленных кадров: {COMPOSITE_SUCCESS_PLOT_PATH}",
|
||
f"- Отсутствие нового изображения: {NO_IMAGE_PLOT_PATH}",
|
||
f"- Задержка публикации: {PUBLICATION_DELAY_PLOT_PATH}",
|
||
f"- Доля восстановленных блоков: {BLOCK_RECOVERY_PLOT_PATH}",
|
||
f"- Устойчивость/задержка от D: {DEPTH_TRADEOFF_PLOT_PATH}",
|
||
f"- Итоговое сравнение: {MODE_COMPARISON_PLOT_PATH}",
|
||
"- JPEG, внешние пакеты и бинарные дампы на диск не сохранялись.",
|
||
"",
|
||
]
|
||
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
|
||
def validate_outputs() -> None:
|
||
expected = (
|
||
CSV_PATH,
|
||
REPORT_PATH,
|
||
COMPOSITE_SUCCESS_PLOT_PATH,
|
||
NO_IMAGE_PLOT_PATH,
|
||
PUBLICATION_DELAY_PLOT_PATH,
|
||
BLOCK_RECOVERY_PLOT_PATH,
|
||
DEPTH_TRADEOFF_PLOT_PATH,
|
||
MODE_COMPARISON_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) != 28:
|
||
raise RuntimeError(
|
||
f"Lab031 CSV must contain 28 rows, got {len(rows)}"
|
||
)
|
||
if len(rows[0]) != len(CSV_FIELDS):
|
||
raise RuntimeError("Lab031 CSV column count changed")
|
||
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("Lab031: 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
|
||
print(
|
||
f" {mode.label}: units={len(fec.units)}, "
|
||
f"groups={len(schedule.groups)}, "
|
||
f"wait={schedule.mean_group_wait_delay_seconds:.6f} s, "
|
||
f"gap={schedule.mean_intrablock_symbol_gap_seconds:.6f} s, "
|
||
f"timeline={fec.duration_seconds:.6f} s"
|
||
)
|
||
|
||
print("Running Lab031 functional checks...")
|
||
tests = run_functional_tests(composites, schedules)
|
||
for test in tests:
|
||
print(f" PASS {test.name}: {test.detail}")
|
||
|
||
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)
|
||
|
||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||
save_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}: composite="
|
||
f"{result.composite_success_rate:.6f}, "
|
||
f"block={result.fec_all_block_success_rate:.6f}, "
|
||
f"delay_p95={result.p95_publication_delay_seconds:.6f} s"
|
||
)
|
||
print(f"CSV: {CSV_PATH}")
|
||
print(f"Report: {REPORT_PATH}")
|
||
print("Lab031 completed successfully.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|