Files
SDR-Rover/experiments/lab031_fec_interleaving.py
LittleSam129 c486039053 Split experiments from tests
The tests/ directory held 50 laboratory programs and no tests. They model
channels, run hundreds of repetitions and write CSV, PNG and reports;
calling that a test suite blocked introducing a real one, because any
pytest run would have collected the labs and re-executed every
experiment.

- move all 50 lab programs to experiments/ with git mv, preserving history
- rewrite the 38 cross-imports between labs from tests.labNNN to
  experiments.labNNN
- leave tests/ empty for actual fast checks of protocol/
- point quick_gate and the hook at the new layout and add experiments/ to
  the syntax sweep
- update the paths quoted in the Lab042 specification and the verifier
  agent definition

This also defuses the import-time work finding without touching 41 files:
the labs still create directories and write files on import, but nothing
imports them now except the gate, which does so deliberately.

Gate passes: syntax clean, protocol imports, 15 lab modules import, 2
functional suites run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:34:58 +03:00

1577 lines
55 KiB
Python
Raw Permalink 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.
"""
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 experiments.lab028_video_packetization import (
COMPOSITE_FPS,
SOURCE_VIDEO_PATH,
EncodedComposite,
VideoMetadata,
load_video_profile,
)
from experiments.lab029_packet_channel_simulation import (
PreparedProfile,
prepare_profiles,
)
from experiments.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 experiments.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()