Files
SDR-Rover/tests/lab030_packet_erasure_fec.py
2026-07-29 11:40:37 +03:00

1712 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Lab030. Systematic packet-erasure FEC for the synchronous video stream.
Real Lab028 packets with 512-byte payload are grouped consecutively in
blocks of k=8. Modes without FEC and with r=1,2,4 parity packets are compared
over the time-based Lab029B channel at 300 kbit/s. Blocks may cross composite
frame boundaries. Systematic packets precede parity packets; no interleaving,
ARQ, or retransmission is used.
"""
from __future__ import annotations
from bisect import bisect_right
import csv
from dataclasses import asdict, dataclass
from itertools import combinations
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 (
GF_PRIMITIVE_POLYNOMIAL,
OUTER_HEADER_FORMAT,
OUTER_HEADER_SIZE,
DecodedFECBlock,
InsufficientSymbolsError,
OuterPacketCRCError,
decode_fec_block,
decode_outer_symbol,
encode_fec_block,
gf_add,
gf_div,
gf_inverse,
gf_mul,
)
from protocol.video_packet import (
CompositeReassembler,
ObjectType,
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,
TimeInterval,
generate_bad_intervals,
percentile,
positive_runs,
)
OUTPUT_DIRECTORY = Path("data/processed/lab030")
CSV_PATH = OUTPUT_DIRECTORY / "lab030_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab030_report.txt"
COMPOSITE_SUCCESS_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab030_composite_success.png"
)
NO_IMAGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab030_no_image_duration.png"
STREAM_RATE_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab030_stream_rate_overhead.png"
)
DELAY_QUEUE_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab030_delay_queue.png"
)
MODE_COMPARISON_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab030_mode_comparison.png"
)
INNER_PAYLOAD_SIZE = 512
SOURCE_BLOCK_SIZE = 8
MONTE_CARLO_REPETITIONS = 200
MASTER_SEED = 300_300
SEED_BASE = MASTER_SEED
TIME_EPSILON_SECONDS = 1e-12
@dataclass(frozen=True)
class FECMode:
name: str
parity_count: int
label: str
FEC_MODES = (
FECMode("none", 0, "Без FEC"),
FECMode("8+1", 1, "8+1"),
FECMode("8+2", 2, "8+2"),
FECMode("8+4", 4, "8+4"),
)
CSV_FIELDS = [
"mode",
"source_block_size",
"nominal_parity_count",
"mean_bad_duration_ms",
"mean_good_duration_ms",
"target_bad_time_fraction",
"actual_bad_time_fraction",
"monte_carlo_repetitions",
"seed",
"source_jpeg_bitrate_kbps",
"inner_packet_stream_bitrate_kbps",
"outer_fec_stream_bitrate_kbps",
"service_and_parity_percent",
"control_stream_bitrate_kbps",
"schedule_duration_seconds",
"mean_queue_length_packets",
"max_queue_length_packets",
"mean_publication_delay_seconds",
"p95_publication_delay_seconds",
"max_publication_delay_seconds",
"transmitted_source_packets",
"transmitted_parity_packets",
"lost_source_packets",
"lost_parity_packets",
"recovered_source_packets",
"fec_recovered_blocks",
"fec_unrecoverable_blocks",
"fec_affected_block_recovery_rate",
"base_objects_completed",
"roi_objects_completed",
"atomic_composite_frames_completed",
"composite_success_rate",
"base_only_frames",
"roi_only_frames",
"incomplete_frames",
"mean_no_new_image_duration_seconds",
"p95_no_new_image_duration_seconds",
"max_no_new_image_duration_seconds",
"mean_consecutive_incomplete_frames",
"p95_consecutive_incomplete_frames",
"max_consecutive_incomplete_frames",
"effective_delivered_video_bitrate_kbps",
]
@dataclass(frozen=True)
class SourcePacket:
global_index: int
composite_frame_id: int
generation_time_seconds: float
inner_packet: bytes
@dataclass(frozen=True)
class FECBlockPlan:
block_id: int
source_global_indices: tuple[int, ...]
source_count: int
parity_count: int
symbol_size: int
@dataclass(frozen=True)
class TransmissionUnit:
sequence_index: int
generation_time_seconds: float
wire_packet: bytes
is_parity: bool
block_id: int
symbol_index: int
source_global_index: int | None
@dataclass(frozen=True)
class ScheduledUnit:
unit: TransmissionUnit
start_seconds: float
end_seconds: float
@dataclass(frozen=True)
class ModeSchedule:
mode: FECMode
source_packets: tuple[SourcePacket, ...]
blocks: tuple[FECBlockPlan, ...]
units: tuple[ScheduledUnit, ...]
source_duration_seconds: float
duration_seconds: float
total_jpeg_bytes: int
total_inner_bytes: int
total_transmitted_bytes: int
mean_queue_length_packets: float
max_queue_length_packets: int
@dataclass
class BlockReceiveState:
plan: FECBlockPlan
received_outer_packets: list[bytes]
delivered_source_indices: set[int]
lost_source_indices: set[int]
decoded: bool = False
recovery_failed: bool = False
recovered_source_packets: int = 0
@dataclass(frozen=True)
class RepetitionResult:
lost_source_packets: int
lost_parity_packets: int
recovered_source_packets: int
fec_recovered_blocks: int
fec_unrecoverable_blocks: int
base_objects_completed: int
roi_objects_completed: int
atomic_composite_frames_completed: int
base_only_frames: int
roi_only_frames: int
incomplete_frames: int
delivered_jpeg_bytes: int
publication_delays: tuple[float, ...]
no_new_image_durations: tuple[float, ...]
incomplete_frame_runs: tuple[int, ...]
bad_time_seconds: float
@dataclass(frozen=True)
class SimulationResult:
mode: str
source_block_size: int
nominal_parity_count: 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
mean_queue_length_packets: float
max_queue_length_packets: int
mean_publication_delay_seconds: float
p95_publication_delay_seconds: float
max_publication_delay_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
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
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
effective_delivered_video_bitrate_kbps: float
@dataclass(frozen=True)
class FunctionalTestResult:
name: str
passed: bool
detail: str
def parity_for_last_block(
source_count: int,
nominal_parity_count: int,
) -> int:
"""Preserve the nominal parity ratio in a final partial block."""
if nominal_parity_count == 0:
return 0
return max(
1,
int(
np.ceil(
source_count
* nominal_parity_count
/ SOURCE_BLOCK_SIZE
)
),
)
def prepare_source_packets(
profile: PreparedProfile,
) -> tuple[SourcePacket, ...]:
return tuple(
SourcePacket(
global_index=index,
composite_frame_id=prepared.composite_frame_id,
generation_time_seconds=(
prepared.composite_frame_id / COMPOSITE_FPS
),
inner_packet=prepared.wire_packet,
)
for index, prepared in enumerate(profile.packets)
)
def build_mode_units(
source_packets: tuple[SourcePacket, ...],
mode: FECMode,
) -> tuple[
tuple[TransmissionUnit, ...],
tuple[FECBlockPlan, ...],
]:
"""Build sequential systematic/parity transmission 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 // SOURCE_BLOCK_SIZE,
symbol_index=index % SOURCE_BLOCK_SIZE,
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), SOURCE_BLOCK_SIZE)
):
block_sources = source_packets[
start:start + SOURCE_BLOCK_SIZE
]
source_count = len(block_sources)
parity_count = (
mode.parity_count
if source_count == SOURCE_BLOCK_SIZE
else parity_for_last_block(
source_count, mode.parity_count
)
)
inner_packets = tuple(
packet.inner_packet for packet in block_sources
)
outer_packets = encode_fec_block(
inner_packets, block_id, parity_count
)
parsed_outer = [
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_outer[0].symbol_size,
)
)
for local_index, (wire_packet, parsed) in enumerate(
zip(outer_packets, parsed_outer)
):
is_parity = parsed.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=parsed.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 schedule_units(
units: tuple[TransmissionUnit, ...],
) -> tuple[
tuple[ScheduledUnit, ...],
float,
float,
int,
]:
"""Run the transmitter as one FIFO queue at 300 kbit/s."""
scheduled = []
cursor = 0.0
for unit in units:
start = max(cursor, unit.generation_time_seconds)
end = (
start
+ len(unit.wire_packet)
* 8.0
/ CONTROL_STREAM_BITRATE_BPS
)
scheduled.append(ScheduledUnit(unit, start, end))
cursor = end
queue_depth_samples = []
for arriving in units:
generation_time = arriving.generation_time_seconds
queue_depth_samples.append(
sum(
1
for scheduled_unit in scheduled
if (
scheduled_unit.unit.generation_time_seconds
<= generation_time + TIME_EPSILON_SECONDS
and scheduled_unit.end_seconds
> generation_time + TIME_EPSILON_SECONDS
)
)
)
return (
tuple(scheduled),
scheduled[-1].end_seconds,
float(np.mean(queue_depth_samples)),
max(queue_depth_samples),
)
def build_mode_schedules(
metadata: VideoMetadata,
composites: list[EncodedComposite],
profile: PreparedProfile,
) -> dict[str, ModeSchedule]:
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 FEC_MODES:
units, blocks = build_mode_units(source_packets, mode)
scheduled, duration, mean_queue, max_queue = schedule_units(
units
)
schedules[mode.name] = ModeSchedule(
mode=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,
)
return schedules
def overlap_loss_flags(
schedule: ModeSchedule,
intervals: tuple[TimeInterval, ...],
) -> np.ndarray:
flags = np.zeros(len(schedule.units), dtype=np.bool_)
interval_index = 0
for unit_index, scheduled in enumerate(schedule.units):
while (
interval_index < len(intervals)
and intervals[interval_index].end_seconds
<= scheduled.start_seconds + TIME_EPSILON_SECONDS
):
interval_index += 1
if interval_index >= len(intervals):
break
interval = intervals[interval_index]
if (
interval.start_seconds
< scheduled.end_seconds - TIME_EPSILON_SECONDS
and interval.end_seconds
> scheduled.start_seconds + TIME_EPSILON_SECONDS
):
flags[unit_index] = True
return flags
def frame_outage_metrics(
frame_count: int,
completion_times: dict[int, float],
schedule_end: float,
) -> tuple[
tuple[float, ...],
tuple[int, ...],
tuple[float, ...],
]:
complete = [
frame_id in completion_times for frame_id in range(frame_count)
]
incomplete_runs = positive_runs([not flag for flag in complete])
no_image = []
index = 0
while index < frame_count:
if complete[index]:
index += 1
continue
run_start = index
while index < frame_count and not complete[index]:
index += 1
start_time = (
completion_times[run_start - 1]
if run_start > 0
else 0.0
)
end_time = (
completion_times[index]
if index < frame_count
else schedule_end
)
no_image.append(max(0.0, end_time - start_time))
publication_delays = tuple(
completion_time - frame_id / COMPOSITE_FPS
for frame_id, completion_time in completion_times.items()
)
return tuple(no_image), incomplete_runs, publication_delays
def simulate_baseline(
schedule: ModeSchedule,
loss_flags: np.ndarray,
frame_count: int,
) -> RepetitionResult:
receiver = CompositeReassembler()
completion_times = {}
lost_source = 0
delivered_jpeg_bytes = 0
for lost, scheduled in zip(loss_flags, schedule.units):
if bool(lost):
lost_source += 1
continue
completed = receiver.ingest(scheduled.unit.wire_packet)
if completed is not None:
completion_times[completed.composite_frame_id] = (
scheduled.end_seconds
)
delivered_jpeg_bytes += (
len(completed.base_jpeg) + len(completed.roi_jpeg)
)
return finish_repetition(
schedule,
receiver,
completion_times,
frame_count,
lost_source,
0,
0,
0,
0,
delivered_jpeg_bytes,
)
def deliver_inner_packet(
receiver: CompositeReassembler,
inner_packet: bytes,
delivery_time: float,
completion_times: dict[int, float],
) -> int:
completed = receiver.ingest(inner_packet)
if completed is None:
return 0
completion_times[completed.composite_frame_id] = delivery_time
return len(completed.base_jpeg) + len(completed.roi_jpeg)
def simulate_fec(
schedule: ModeSchedule,
loss_flags: np.ndarray,
frame_count: int,
) -> RepetitionResult:
receiver = CompositeReassembler()
completion_times = {}
plans = {plan.block_id: plan for plan in schedule.blocks}
states = {
block_id: BlockReceiveState(
plan=plan,
received_outer_packets=[],
delivered_source_indices=set(),
lost_source_indices=set(),
)
for block_id, plan in plans.items()
}
lost_source = 0
lost_parity = 0
delivered_jpeg_bytes = 0
for lost, scheduled in zip(loss_flags, schedule.units):
unit = scheduled.unit
state = states[unit.block_id]
if bool(lost):
if unit.is_parity:
lost_parity += 1
else:
lost_source += 1
assert unit.source_global_index is not None
state.lost_source_indices.add(
unit.source_global_index
)
continue
state.received_outer_packets.append(unit.wire_packet)
parsed = decode_outer_symbol(unit.wire_packet)
if not parsed.is_parity:
global_index = state.plan.source_global_indices[
parsed.symbol_index
]
if global_index not in state.delivered_source_indices:
state.delivered_source_indices.add(global_index)
delivered_jpeg_bytes += deliver_inner_packet(
receiver,
parsed.data,
scheduled.end_seconds,
completion_times,
)
if (
not state.decoded
and len(state.received_outer_packets)
>= state.plan.source_count
):
decoded = decode_fec_block(
tuple(state.received_outer_packets)
)
state.decoded = True
for local_index in decoded.recovered_indices:
global_index = state.plan.source_global_indices[
local_index
]
if global_index in state.delivered_source_indices:
continue
state.delivered_source_indices.add(global_index)
state.recovered_source_packets += 1
delivered_jpeg_bytes += deliver_inner_packet(
receiver,
decoded.source_packets[local_index],
scheduled.end_seconds,
completion_times,
)
recovered_packets = sum(
state.recovered_source_packets for state in states.values()
)
recovered_blocks = sum(
1
for state in states.values()
if (
state.lost_source_indices
and state.lost_source_indices
<= state.delivered_source_indices
)
)
failed_blocks = sum(
1
for state in states.values()
if (
state.lost_source_indices
and not (
state.lost_source_indices
<= state.delivered_source_indices
)
)
)
return finish_repetition(
schedule,
receiver,
completion_times,
frame_count,
lost_source,
lost_parity,
recovered_packets,
recovered_blocks,
failed_blocks,
delivered_jpeg_bytes,
)
def finish_repetition(
schedule: ModeSchedule,
receiver: CompositeReassembler,
completion_times: dict[int, float],
frame_count: int,
lost_source: int,
lost_parity: int,
recovered_packets: int,
recovered_blocks: int,
failed_blocks: int,
delivered_jpeg_bytes: int,
) -> RepetitionResult:
base_completed = 0
roi_completed = 0
base_only = 0
roi_only = 0
for frame_id in range(frame_count):
atomic = frame_id in completion_times
base = (
atomic
or receiver.object_is_complete(frame_id, ObjectType.BASE)
)
roi = (
atomic
or receiver.object_is_complete(frame_id, ObjectType.ROI)
)
base_completed += int(base)
roi_completed += int(roi)
base_only += int(base and not roi)
roi_only += int(roi and not base)
no_image, incomplete_runs, delays = frame_outage_metrics(
frame_count, completion_times, schedule.duration_seconds
)
return RepetitionResult(
lost_source_packets=lost_source,
lost_parity_packets=lost_parity,
recovered_source_packets=recovered_packets,
fec_recovered_blocks=recovered_blocks,
fec_unrecoverable_blocks=failed_blocks,
base_objects_completed=base_completed,
roi_objects_completed=roi_completed,
atomic_composite_frames_completed=len(completion_times),
base_only_frames=base_only,
roi_only_frames=roi_only,
incomplete_frames=frame_count - len(completion_times),
delivered_jpeg_bytes=delivered_jpeg_bytes,
publication_delays=delays,
no_new_image_durations=no_image,
incomplete_frame_runs=incomplete_runs,
bad_time_seconds=0.0,
)
def simulate_condition(
schedule: ModeSchedule,
frame_count: int,
mean_bad_duration_seconds: float,
seed: int,
repetitions: int,
) -> SimulationResult:
rng = np.random.default_rng(seed)
repetition_results = []
bad_time_seconds = 0.0
for _ in range(repetitions):
intervals = generate_bad_intervals(
schedule.duration_seconds,
mean_bad_duration_seconds,
rng,
)
bad_time_seconds += sum(
interval.duration_seconds for interval in intervals
)
loss_flags = overlap_loss_flags(schedule, intervals)
repetition_results.append(
simulate_baseline(schedule, loss_flags, frame_count)
if schedule.mode.parity_count == 0
else simulate_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(schedule.source_packets)
parity_packets_per_pass = sum(
1 for unit in schedule.units if unit.unit.is_parity
)
source_jpeg_rate = (
schedule.total_jpeg_bytes
* 8.0
/ schedule.source_duration_seconds
/ 1000.0
)
inner_rate = (
schedule.total_inner_bytes
* 8.0
/ schedule.source_duration_seconds
/ 1000.0
)
outer_rate = (
schedule.total_transmitted_bytes
* 8.0
/ schedule.source_duration_seconds
/ 1000.0
)
delivered_bytes = total("delivered_jpeg_bytes")
return SimulationResult(
mode=schedule.mode.name,
source_block_size=SOURCE_BLOCK_SIZE,
nominal_parity_count=schedule.mode.parity_count,
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
/ (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=(
(
schedule.total_transmitted_bytes
- schedule.total_jpeg_bytes
)
/ schedule.total_transmitted_bytes
* 100.0
),
control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS,
schedule_duration_seconds=schedule.duration_seconds,
mean_queue_length_packets=(
schedule.mean_queue_length_packets
),
max_queue_length_packets=schedule.max_queue_length_packets,
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
),
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
),
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"),
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
),
effective_delivered_video_bitrate_kbps=(
delivered_bytes
* 8.0
/ (schedule.duration_seconds * repetitions)
/ 1000.0
),
)
def run_monte_carlo(
schedules: dict[str, ModeSchedule],
frame_count: int,
) -> list[SimulationResult]:
results = []
for duration_index, duration in enumerate(
MEAN_BAD_DURATIONS_SECONDS
):
seed = SEED_BASE + duration_index
for mode in FEC_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, ModeSchedule],
results: list[SimulationResult],
) -> list[FunctionalTestResult]:
tests: list[tuple[str, Callable[[], str]]] = []
sample_inner = tuple(
packet.inner_packet
for packet in schedules["none"].source_packets[:8]
)
def gf_arithmetic() -> str:
for value in range(1, 256):
inverse = gf_inverse(value)
if gf_mul(value, inverse) != 1:
raise AssertionError(f"inverse failed for {value}")
if gf_div(value, value) != 1:
raise AssertionError(f"division failed for {value}")
for left in range(0, 256, 17):
for right in range(0, 256, 19):
for third in range(0, 256, 31):
if gf_mul(left, gf_add(right, third)) != gf_add(
gf_mul(left, right), gf_mul(left, third)
):
raise AssertionError("distributivity failed")
return "inverse, division, and distributivity passed"
def encode_decode_without_loss() -> str:
outer = encode_fec_block(sample_inner, 1, 4)
decoded = decode_fec_block(outer)
if decoded.source_packets != sample_inner:
raise AssertionError("lossless FEC round trip changed bytes")
return "systematic 8+4 block is byte-exact without loss"
def recover_any_r_losses() -> str:
checked = 0
for parity_count in (1, 2, 4):
outer = encode_fec_block(
sample_inner, parity_count, parity_count
)
for missing in combinations(
range(len(outer)), 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_inner:
raise AssertionError(
f"failed r={parity_count}, missing={missing}"
)
checked += 1
return f"all {checked} exact-r erasure combinations recovered"
def r_plus_one_is_not_guaranteed() -> str:
outer = encode_fec_block(sample_inner, 7, 2)
available = outer[3:]
try:
decode_fec_block(available)
except InsufficientSymbolsError:
return "8+2 correctly rejected seven available symbols"
raise AssertionError("r+1 erasures unexpectedly guaranteed")
def recovered_inner_is_exact_and_crc_valid() -> str:
outer = encode_fec_block(sample_inner, 9, 4)
available = tuple(
packet
for index, packet in enumerate(outer)
if index not in {0, 2, 5, 9}
)
decoded = decode_fec_block(available)
for original, restored in zip(
sample_inner, decoded.source_packets
):
if restored != original:
raise AssertionError("restored inner bytes differ")
decode_inner_packet(restored)
return "restored Lab028 packets are byte-exact and CRC-valid"
def outer_crc_detects_corruption() -> str:
outer = bytearray(
encode_fec_block(sample_inner, 10, 2)[0]
)
outer[-1] ^= 0x01
try:
decode_outer_symbol(bytes(outer))
except OuterPacketCRCError:
return "outer payload bit flip rejected by outer CRC32"
raise AssertionError("outer CRC did not reject corruption")
def zero_bad_restores_all_frames() -> str:
frame_count = len(composites)
for mode in FEC_MODES:
schedule = schedules[mode.name]
loss_flags = np.zeros(
len(schedule.units), dtype=np.bool_
)
repetition = (
simulate_baseline(schedule, loss_flags, frame_count)
if mode.parity_count == 0
else simulate_fec(schedule, loss_flags, frame_count)
)
if (
repetition.atomic_composite_frames_completed
!= frame_count
):
raise AssertionError(
f"zero-Bad failed for {mode.name}"
)
return "all modes restored all 63 frames without Bad"
def fixed_seed_is_reproducible() -> str:
first = simulate_condition(
schedules["8+2"], len(composites), 0.05, 399_399, 3
)
second = simulate_condition(
schedules["8+2"], len(composites), 0.05, 399_399, 3
)
if first != second:
raise AssertionError("same seed changed aggregate results")
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"].source_packets
if packet.composite_frame_id == 0
]
published = 0
for packet in first_frame[:-1]:
published += int(receiver.ingest(packet) is not None)
if published:
raise AssertionError("incomplete composite was published")
return "missing ROI fragment prevented atomic publication"
def queue_includes_parity() -> str:
baseline = schedules["none"]
protected = schedules["8+4"]
parity_units = sum(
unit.unit.is_parity for unit in protected.units
)
if parity_units <= 0:
raise AssertionError("8+4 schedule has no parity")
if len(protected.units) != (
len(baseline.source_packets) + parity_units
):
raise AssertionError("parity is absent from FIFO units")
if (
protected.total_transmitted_bytes
<= baseline.total_transmitted_bytes
):
raise AssertionError("parity did not increase wire bytes")
if protected.duration_seconds < baseline.duration_seconds:
raise AssertionError("parity shortened transmitter schedule")
return (
f"{parity_units} parity packets included in FIFO timing "
"and queue metrics"
)
tests.extend(
[
("gf256_arithmetic", gf_arithmetic),
("fec_without_loss", encode_decode_without_loss),
("recover_any_r_erasures", recover_any_r_losses),
("r_plus_one_not_guaranteed", r_plus_one_is_not_guaranteed),
(
"restored_inner_packet_and_crc",
recovered_inner_is_exact_and_crc_valid,
),
("outer_crc_detection", outer_crc_detects_corruption),
("zero_bad_100_percent", zero_bad_restores_all_frames),
("fixed_seed_reproducibility", fixed_seed_is_reproducible),
(
"atomic_incomplete_composite",
incomplete_composite_is_not_published,
),
("queue_counts_parity", queue_includes_parity),
]
)
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 = [test for test in test_results if not test.passed]
if failed:
raise RuntimeError(
"Lab030 functional checks failed: "
+ "; ".join(
f"{test.name}: {test.detail}" for test in failed
)
)
return test_results
def validate_results(results: list[SimulationResult]) -> None:
if len(results) != 16:
raise RuntimeError(f"expected 16 rows, got {len(results)}")
keys = {
(result.mode, result.mean_bad_duration_ms)
for result in results
}
if len(keys) != 16:
raise RuntimeError("Lab030 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")
def save_csv(results: list[SimulationResult]) -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
writer.writeheader()
for result in results:
raw = asdict(result)
writer.writerow(
{
field: (
f"{raw[field]:.12g}"
if isinstance(raw[field], float)
else raw[field]
)
for field in CSV_FIELDS
}
)
def save_line_plot(
results: list[SimulationResult],
getter: Callable[[SimulationResult], float],
ylabel: str,
title: str,
path: Path,
) -> None:
x_values = [
duration * 1000.0
for duration in MEAN_BAD_DURATIONS_SECONDS
]
figure, axis = plt.subplots(figsize=(9, 5.5))
for mode in FEC_MODES:
values = [
getter(result_lookup(results, duration_ms)[mode.name])
for duration_ms in x_values
]
axis.plot(
x_values,
values,
marker="o",
linewidth=2,
label=mode.label,
)
axis.set_xscale("log")
axis.set_xticks(x_values)
axis.set_xticklabels([f"{value:g}" for value in x_values])
axis.set_xlabel("Средняя длительность Bad, мс")
axis.set_ylabel(ylabel)
axis.set_title(title)
axis.grid(True, which="both", alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(path, dpi=160)
plt.close(figure)
def save_plots(results: list[SimulationResult]) -> None:
save_line_plot(
results,
lambda result: result.composite_success_rate * 100.0,
"Полностью восстановленные составные кадры, %",
"Lab030. Эффективность пакетного FEC",
COMPOSITE_SUCCESS_PLOT_PATH,
)
save_line_plot(
results,
lambda result: result.p95_no_new_image_duration_seconds,
"P95 отсутствия нового изображения, с",
"Lab030. Прерывания видеопотока",
NO_IMAGE_PLOT_PATH,
)
representative = result_lookup(results, 200.0)
x = np.arange(len(FEC_MODES))
rates = [
representative[mode.name].outer_fec_stream_bitrate_kbps
for mode in FEC_MODES
]
overhead = [
representative[mode.name].service_and_parity_percent
for mode in FEC_MODES
]
figure, rate_axis = plt.subplots(figsize=(9, 5.5))
overhead_axis = rate_axis.twinx()
rate_bars = rate_axis.bar(
x - 0.18, rates, 0.36,
color="tab:blue", label="Wire rate",
)
overhead_bars = overhead_axis.bar(
x + 0.18, overhead, 0.36,
color="tab:orange", label="Service + parity",
)
rate_axis.axhline(
CONTROL_STREAM_BITRATE_KBPS,
color="tab:red",
linestyle="--",
label="300 kbit/s",
)
rate_axis.set_xticks(x)
rate_axis.set_xticklabels([mode.label for mode in FEC_MODES])
rate_axis.set_ylabel("Предлагаемая скорость, кбит/с")
overhead_axis.set_ylabel("Служебные и parity-данные, %")
rate_axis.set_title("Lab030. Скорость и избыточность")
rate_axis.grid(True, axis="y", alpha=0.3)
rate_axis.legend(
[rate_bars, overhead_bars, rate_axis.lines[0]],
["Wire rate", "Service + parity", "300 kbit/s"],
loc="best",
)
figure.tight_layout()
figure.savefig(STREAM_RATE_PLOT_PATH, dpi=160)
plt.close(figure)
delays = [
representative[mode.name].p95_publication_delay_seconds
for mode in FEC_MODES
]
queues = [
representative[mode.name].max_queue_length_packets
for mode in FEC_MODES
]
figure, delay_axis = plt.subplots(figsize=(9, 5.5))
queue_axis = delay_axis.twinx()
delay_bars = delay_axis.bar(
x - 0.18, delays, 0.36,
color="tab:blue", label="P95 delay",
)
queue_bars = queue_axis.bar(
x + 0.18, queues, 0.36,
color="tab:orange", label="Max queue",
)
delay_axis.set_xticks(x)
delay_axis.set_xticklabels([mode.label for mode in FEC_MODES])
delay_axis.set_ylabel("P95 задержки публикации, с")
queue_axis.set_ylabel("Максимальная очередь, пакетов")
delay_axis.set_title(
"Lab030. Задержка и очередь при Bad 200 мс"
)
delay_axis.grid(True, axis="y", alpha=0.3)
delay_axis.legend(
[delay_bars, queue_bars],
["P95 publication delay", "Max queue"],
loc="best",
)
figure.tight_layout()
figure.savefig(DELAY_QUEUE_PLOT_PATH, dpi=160)
plt.close(figure)
success = [
representative[mode.name].composite_success_rate * 100.0
for mode in FEC_MODES
]
recovery = [
representative[
mode.name
].fec_affected_block_recovery_rate
* 100.0
for mode in FEC_MODES
]
figure, axis = plt.subplots(figsize=(9, 5.5))
axis.bar(
x - 0.18, success, 0.36, label="Composite success"
)
axis.bar(
x + 0.18, recovery, 0.36, label="Affected FEC blocks recovered"
)
axis.set_xticks(x)
axis.set_xticklabels([mode.label for mode in FEC_MODES])
axis.set_ylabel("Доля, %")
axis.set_title("Lab030. Сравнение режимов при Bad 200 мс")
axis.grid(True, axis="y", alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(MODE_COMPARISON_PLOT_PATH, dpi=160)
plt.close(figure)
def mode_rate_table(
schedules: dict[str, ModeSchedule],
) -> list[str]:
lines = [
(
"mode | source packets | parity packets | last block k+r | "
"JPEG kbit/s | inner kbit/s | outer kbit/s | overhead | "
"queue mean/max | timeline s"
),
(
"----:|---------------:|---------------:|---------------:|"
"------------:|-------------:|-------------:|---------:|"
"---------------:|----------:"
),
]
for mode in FEC_MODES:
schedule = schedules[mode.name]
parity_packets = sum(
unit.unit.is_parity for unit in schedule.units
)
last_block = (
f"{schedule.blocks[-1].source_count}+"
f"{schedule.blocks[-1].parity_count}"
if schedule.blocks
else "none"
)
jpeg_rate = (
schedule.total_jpeg_bytes
* 8
/ schedule.source_duration_seconds
/ 1000
)
inner_rate = (
schedule.total_inner_bytes
* 8
/ schedule.source_duration_seconds
/ 1000
)
outer_rate = (
schedule.total_transmitted_bytes
* 8
/ schedule.source_duration_seconds
/ 1000
)
overhead = (
(
schedule.total_transmitted_bytes
- schedule.total_jpeg_bytes
)
/ schedule.total_transmitted_bytes
* 100
)
lines.append(
f"{mode.label} | {len(schedule.source_packets)} | "
f"{parity_packets} | {last_block} | "
f"{jpeg_rate:.3f} | {inner_rate:.3f} | "
f"{outer_rate:.3f} | {overhead:.3f}% | "
f"{schedule.mean_queue_length_packets:.3f}/"
f"{schedule.max_queue_length_packets} | "
f"{schedule.duration_seconds:.6f}"
)
return lines
def result_table(results: list[SimulationResult]) -> list[str]:
lines = [
(
"Bad ms | mode | Bad actual | lost src/parity | recovered pkt | "
"blocks recovered/failed | block recovery | composite | "
"BASE-only/ROI-only | no image mean/p95/max s | "
"delay mean/p95/max s | video kbit/s"
),
(
"------:|-----:|-----------:|----------------:|--------------:|"
"------------------------:|---------------:|----------:|"
"------------------:|-------------------------:|"
"----------------------:|------------:"
),
]
for duration in MEAN_BAD_DURATIONS_SECONDS:
by_mode = result_lookup(results, duration * 1000.0)
for mode in FEC_MODES:
result = by_mode[mode.name]
lines.append(
f"{duration * 1000.0:.0f} | {mode.label} | "
f"{result.actual_bad_time_fraction * 100:.3f}% | "
f"{result.lost_source_packets}/"
f"{result.lost_parity_packets} | "
f"{result.recovered_source_packets} | "
f"{result.fec_recovered_blocks}/"
f"{result.fec_unrecoverable_blocks} | "
f"{result.fec_affected_block_recovery_rate * 100:.3f}% | "
f"{result.composite_success_rate * 100:.3f}% | "
f"{result.base_only_frames}/{result.roi_only_frames} | "
f"{result.mean_no_new_image_duration_seconds:.3f}/"
f"{result.p95_no_new_image_duration_seconds:.3f}/"
f"{result.max_no_new_image_duration_seconds:.3f} | "
f"{result.mean_publication_delay_seconds:.3f}/"
f"{result.p95_publication_delay_seconds:.3f}/"
f"{result.max_publication_delay_seconds:.3f} | "
f"{result.effective_delivered_video_bitrate_kbps:.3f}"
)
return lines
def write_report(
metadata: VideoMetadata,
composites: list[EncodedComposite],
schedules: dict[str, ModeSchedule],
results: list[SimulationResult],
tests: list[FunctionalTestResult],
) -> None:
lines = [
"Lab030. Пакетное избыточное кодирование стираний",
"",
"Исходный профиль и неизменный внутренний транспорт",
f"- Видео: {SOURCE_VIDEO_PATH}",
(
f"- {len(composites)} реальных пар BASE/ROI, 3 fps; "
"BASE 240x135 grayscale JPEG Q23, "
"ROI 320x180 grayscale JPEG Q33."
),
(
"- Внутренний Lab028 packet не изменён: payload 512 байт, "
"32-byte header, packet CRC32 и object CRC32."
),
"",
"GF(256) и блочный код",
(
f"- Примитивный полином: 0x{GF_PRIMITIVE_POLYNOMIAL:X} "
"(x^8+x^4+x^3+x^2+1)."
),
(
"- Матрица Вандермонда n×k умножается на обратную верхнюю "
"k×k матрицу и становится систематической."
),
(
"- Любые k доступных строк систематической генераторной "
"матрицы восстанавливают k исходных символов."
),
(
"- Нулевое дополнение используется только в математике; "
"восстановленный внутренний пакет обрезается по payload_length "
"Lab028 и проходит собственный CRC."
),
"",
"Внешний пакет FEC",
f"- struct: {OUTER_HEADER_FORMAT}",
(
f"- Размер: {OUTER_HEADER_SIZE} байта, network byte order, "
"padding отсутствует."
),
(
"- Layout: magic[4]@0, version:u8@4, flags:u8@5, "
"header_size:u16@6, block_id:u32@8, symbol_index:u16@12, "
"k:u16@14, r:u16@16, symbol_size:u16@18, "
"data_length:u16@20, reserved16:u16@22, "
"reserved32:u32@24, outer_crc32:u32@28."
),
(
"- flags bit0: parity. CRC32 вычисляется по заголовку с "
"нулевым outer_crc32 и данным внешнего символа."
),
"",
"Формирование блоков и передача",
(
"- Последовательный поток внутренних пакетов BASE→ROI делится "
"на k=8; блок может пересекать границу кадра."
),
(
"- Сначала передаются systematic symbols, затем parity symbols "
"этого блока; перемежение отсутствует."
),
(
"- Последний блок содержит 6 исходных пакетов. Для него "
"r_last=max(1, ceil(k_last*r/8)): режимы 8+1, 8+2, 8+4 "
"получают соответственно 1, 2 и 3 parity."
),
(
"- Режим Без FEC передаёт исходные Lab028 packets без внешней "
"обёртки; остальные режимы используют внешний заголовок."
),
(
"- FIFO учитывает время генерации кадра, все внешние пакеты, "
"фактический размер и скорость 300 кбит/с. Очередь не "
"сбрасывается на границах кадров."
),
"",
"Скорость, избыточность и очередь",
*mode_rate_table(schedules),
"",
"Временная модель",
(
"- Экспоненциальные Good/Bad интервалы Lab029B, Bad≈2%, "
"mean Bad 10/50/200/1000 мс, 200 повторов, fixed seeds "
f"{SEED_BASE}...{SEED_BASE + 3}."
),
(
"- Пакет теряется при любом пересечении его передачи с Bad."
),
(
"- FEC исправляет стирания; CRC обнаруживает повреждения, но "
"битовые ошибки отдельно не добавляются."
),
"",
"Функциональные проверки",
]
lines.extend(
f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}"
for test in tests
)
lines.extend(
[
"",
"Результаты Monte Carlo",
*result_table(results),
"",
"Допущения и ограничения",
(
"- Не реализованы перемежение, ARQ, повторные передачи, "
"команды управления, телеметрия и реальный SDR."
),
(
"- Любое частичное пересечение Bad уничтожает весь внешний "
"или базовый внутренний пакет."
),
(
"- Принятые systematic packets немедленно поступают "
"reassembler; восстановленные стирания становятся доступны "
"при получении k символов блока."
),
(
"- Publication delay измеряется от frame_id/3 до атомарной "
"выдачи BASE+ROI."
),
(
"- Queue length измеряется в моменты генерации пакетов и "
"включает ожидающие и обслуживаемый пакет."
),
(
"- Окончательный режим FEC автоматически не выбирается."
),
"",
"Артефакты",
f"- CSV: {CSV_PATH}",
f"- Composite success: {COMPOSITE_SUCCESS_PLOT_PATH}",
f"- No-image duration: {NO_IMAGE_PLOT_PATH}",
f"- Stream rate/overhead: {STREAM_RATE_PLOT_PATH}",
f"- Delay/queue: {DELAY_QUEUE_PLOT_PATH}",
f"- Mode comparison: {MODE_COMPARISON_PLOT_PATH}",
(
"- JPEG, внутренние/внешние packets и бинарные дампы "
"не сохранялись."
),
"",
]
)
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
def validate_outputs() -> None:
for path in (
CSV_PATH,
REPORT_PATH,
COMPOSITE_SUCCESS_PLOT_PATH,
NO_IMAGE_PLOT_PATH,
STREAM_RATE_PLOT_PATH,
DELAY_QUEUE_PLOT_PATH,
MODE_COMPARISON_PLOT_PATH,
):
if not path.exists() or path.stat().st_size <= 0:
raise RuntimeError(f"missing or empty output: {path}")
def main() -> None:
print("Lab030: loading real 512-byte-payload Lab028 stream...")
metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
profile = prepare_profiles(composites)[INNER_PAYLOAD_SIZE]
schedules = build_mode_schedules(
metadata, composites, profile
)
for mode in FEC_MODES:
schedule = schedules[mode.name]
print(
f" {mode.label}: units={len(schedule.units)}, "
f"offered="
f"{schedule.total_transmitted_bytes * 8 / metadata.duration_seconds / 1000:.3f} "
f"kbit/s, timeline={schedule.duration_seconds:.6f} s"
)
print(
f"Running 16 conditions, "
f"{MONTE_CARLO_REPETITIONS} repetitions each..."
)
results = run_monte_carlo(schedules, len(composites))
validate_results(results)
print("Running Lab030 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_csv(results)
save_plots(results)
write_report(
metadata, composites, schedules, results, tests
)
validate_outputs()
print("Representative Bad=200 ms results:")
representative = result_lookup(results, 200.0)
for mode in FEC_MODES:
result = representative[mode.name]
print(
f" {mode.label}: composite="
f"{result.composite_success_rate:.6f}, "
f"blocks={result.fec_recovered_blocks}/"
f"{result.fec_unrecoverable_blocks}, "
f"delay_p95={result.p95_publication_delay_seconds:.6f} s"
)
print(f"CSV: {CSV_PATH}")
print(f"Report: {REPORT_PATH}")
print("Lab030 completed successfully.")
if __name__ == "__main__":
main()