"""Lab037: complete lossy link with command repetition. The model combines the unchanged Lab028/Lab030/Lab033 wire formats, 12+3 packet-erasure FEC, strict non-preemptive priority, latest-waiting-frame video admission, a time-based two-state erasure channel, and receiver de-duplication. Only aggregate CSV/TXT/PNG artifacts are retained. """ from __future__ import annotations import csv from dataclasses import asdict, dataclass, replace from pathlib import Path from typing import Iterable import matplotlib import numpy as np matplotlib.use("Agg") import matplotlib.pyplot as plt from protocol.control_repetition import ( CommandCopy, CommandReceiver, RepetitionMode, build_command_copies, ) from protocol.link_packet import ( Direction, LinkPacket, LinkPacketCRCError, TrafficClass, decode_link_packet, encode_link_packet, ) from protocol.packet_erasure_fec import ( OuterPacketCRCError, decode_fec_block, decode_outer_symbol, encode_fec_block, ) from protocol.video_packet import decode_packet as decode_inner_packet from protocol.video_frame_scheduler import VideoFrameGroup from protocol.video_age_policy import AgePolicyPacket from experiments.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite from experiments.lab033_priority_channel_scheduler import ( STREAM_CONTROL, STREAM_EMERGENCY, STREAM_TELEMETRY, STREAM_VIDEO, build_workload as build_lab033_workload, deterministic_payload, ) from experiments.lab034_stale_video_drop import parity_for_partial OUTPUT_DIRECTORY = Path("data/processed/lab037") SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab037_summary.csv" COMMAND_CSV_PATH = OUTPUT_DIRECTORY / "lab037_command_metrics.csv" VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab037_video_metrics.csv" EMERGENCY_CSV_PATH = OUTPUT_DIRECTORY / "lab037_emergency_metrics.csv" REPORT_PATH = OUTPUT_DIRECTORY / "lab037_report.txt" COMMAND_GAP_PLOT = OUTPUT_DIRECTORY / "lab037_command_gap.png" EMERGENCY_PLOT = OUTPUT_DIRECTORY / "lab037_emergency_timeliness.png" COMMAND_AGE_PLOT = OUTPUT_DIRECTORY / "lab037_command_age.png" IMAGE_AGE_PLOT = OUTPUT_DIRECTORY / "lab037_image_age.png" VIDEO_PLOT = OUTPUT_DIRECTORY / "lab037_published_video.png" LOAD_QUEUE_PLOT = OUTPUT_DIRECTORY / "lab037_load_queue.png" COMPARISON_PLOT = OUTPUT_DIRECTORY / "lab037_protection_comparison.png" PLOT_PATHS = ( COMMAND_GAP_PLOT, EMERGENCY_PLOT, COMMAND_AGE_PLOT, IMAGE_AGE_PLOT, VIDEO_PLOT, LOAD_QUEUE_PLOT, COMPARISON_PLOT, ) DURATION_SECONDS = 120.0 FRAME_COUNT = int(DURATION_SECONDS * COMPOSITE_FPS) VIDEO_PAYLOAD_BYTES = 512 SOURCE_BLOCK_SIZE = 12 PARITY_COUNT = 3 CONTROL_PERIOD_US = 50_000 TELEMETRY_PERIOD_US = 100_000 EMERGENCY_TIMES_US = (30_000_000, 60_000_000, 90_000_000) CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0) MEAN_BAD_DURATIONS_MS = (10.0, 50.0, 200.0, 1000.0) MODES = tuple(RepetitionMode) REPETITIONS = 100 BAD_TIME_FRACTION = 0.02 MASTER_SEED = 0x37A11 CONTROL_REPEAT_DELAY_MS = 22.5 EPSILON = 1e-12 MODE_NAMES = { RepetitionMode.NONE: "none", RepetitionMode.EMERGENCY_ONLY: "emergency_only", RepetitionMode.EMERGENCY_AND_CONTROL: "emergency_and_control", } @dataclass(frozen=True) class TxUnit: packet: LinkPacket wire_packet: bytes available_time_us: int arrival_order: int copy_index: int = 0 frame_id: int | None = None block_id: int | None = None symbol_index: int | None = None source_count: int = 0 is_source: bool = False @property def available_seconds(self) -> float: return self.available_time_us / 1_000_000.0 @property def wire_size_bytes(self) -> int: return len(self.wire_packet) @property def is_repeat(self) -> bool: return self.copy_index > 0 @dataclass(frozen=True) class Frame: frame_id: int generation_time_us: int packets: tuple[TxUnit, ...] jpeg_bytes: int @property def generation_seconds(self) -> float: return self.generation_time_us / 1_000_000.0 @dataclass(frozen=True) class Workload: frames: tuple[Frame, ...] controls: tuple[LinkPacket, ...] telemetry: tuple[TxUnit, ...] emergencies: tuple[LinkPacket, ...] source_profile_frames: int crc_packets_checked: int crc_blocks_checked: int @dataclass(frozen=True) class Sent: unit: TxUnit start_seconds: float end_seconds: float @dataclass(frozen=True) class Removed: unit: TxUnit time_seconds: float reason: str @dataclass(frozen=True) class Schedule: rate_kbps: float mode: RepetitionMode transmitted: tuple[Sent, ...] removed: tuple[Removed, ...] dropped_frame_ids: tuple[int, ...] started_frame_ids: tuple[int, ...] completed_frame_ids: tuple[int, ...] cancelled_repeats: int replaced_state: int mean_queue_packets: float max_queue_packets: int mean_queue_bytes: float max_queue_bytes: int max_waiting_frames: int drain_end_seconds: float queue_release_seconds: float @dataclass(frozen=True) class SummaryMetrics: channel_kbps: float mean_bad_duration_ms: float protection_mode: str repetitions: int emergency_load_kbps: float control_load_kbps: float telemetry_load_kbps: float video_load_kbps: float total_offered_load_kbps: float channel_utilization_percent: float mean_queue_packets: float max_queue_packets: int mean_queue_bytes: float max_queue_bytes: int max_waiting_video_frames: int repeat_command_bytes: int lost_packets_mean: float lost_packet_bytes_mean: float cancelled_control_repeats: int replaced_state_packets: int drain_end_seconds: float queue_release_seconds: float telemetry_delivered_fraction: float telemetry_mean_age_ms: float telemetry_last_message_age_ms: float telemetry_max_gap_ms: float telemetry_gaps_over_500ms_mean: float @dataclass(frozen=True) class CommandMetrics: channel_kbps: float mean_bad_duration_ms: float protection_mode: str original_commands: int repeat_copies_created: int delivered_unique_states_mean: float states_with_all_copies_lost_mean: float undelivered_state_fraction: float mean_age_ms: float p95_age_ms: float max_age_ms: float max_gap_without_fresh_command_ms: float gaps_over_100ms_mean: float maximum_consecutive_undelivered_states: int cancelled_pending_repeats: int suppressed_duplicates_mean: float repetition_overhead_kbps: float @dataclass(frozen=True) class VideoMetrics: channel_kbps: float mean_bad_duration_ms: float protection_mode: str created_frames: int scheduled_complete_frames: int dropped_before_start_frames: int published_frames_mean: float published_fraction: float update_fps: float mean_image_age_ms: float p95_image_age_ms: float max_image_age_ms: float mean_no_new_image_ms: float max_no_new_image_ms: float fec_recovered_blocks_mean: float fec_unrecoverable_blocks_mean: float fec_recovered_affected_fraction: float delivered_useful_video_kbps: float @dataclass(frozen=True) class EmergencyMetrics: channel_kbps: float mean_bad_duration_ms: float protection_mode: str event_time_seconds: float delivered_fraction: float mean_first_delivery_ms: float p95_first_delivery_ms: float deadline_50ms_fraction: float lost_copies_mean: float duplicate_copies_mean: float @dataclass(frozen=True) class FunctionalTestResult: name: str passed: bool detail: str def percentile(values: Iterable[float], q: float) -> float: values = tuple(values) return float(np.percentile(values, q)) if values else 0.0 def _link( traffic_class: TrafficClass, direction: Direction, stream_id: int, sequence: int, generation_us: int, deadline_ms: int, payload: bytes, ) -> LinkPacket: return LinkPacket( traffic_class=traffic_class, direction=direction, stream_id=stream_id, sequence_number=sequence, generation_time_us=generation_us, deadline_ms=deadline_ms, payload=payload, ) def build_workload() -> Workload: """Build 120 seconds of fresh 512-byte, frame-aligned 12+3 video.""" lab033 = build_lab033_workload() originals = lab033.composites frames: list[Frame] = [] video_sequence = 0 block_id = 0 order = 0 crc_packets = 0 crc_blocks = 0 for frame_id in range(FRAME_COUNT): original = originals[frame_id % len(originals)] composite = EncodedComposite( composite_frame_id=frame_id, source_frame_index=original.source_frame_index, base_jpeg=original.base_jpeg, roi_jpeg=original.roi_jpeg, ) inner = tuple(sum(packets_for_composite(composite, VIDEO_PAYLOAD_BYTES), [])) generation_us = int(round(frame_id / COMPOSITE_FPS * 1_000_000.0)) units: list[TxUnit] = [] for start in range(0, len(inner), SOURCE_BLOCK_SIZE): source = inner[start : start + SOURCE_BLOCK_SIZE] parity = PARITY_COUNT if len(source) == SOURCE_BLOCK_SIZE else parity_for_partial(len(source)) outer_packets = encode_fec_block(source, block_id, parity) decoded = decode_fec_block(outer_packets) assert decoded.source_packets == source for outer_wire in outer_packets: outer = decode_outer_symbol(outer_wire) packet = _link( TrafficClass.VIDEO, Direction.ROVER_TO_GROUND, STREAM_VIDEO, video_sequence, generation_us, 0, outer_wire, ) wire = encode_link_packet(packet) decode_link_packet(wire) units.append( TxUnit( packet, wire, generation_us, order, frame_id=frame_id, block_id=block_id, symbol_index=outer.symbol_index, source_count=outer.source_count, is_source=not outer.is_parity, ) ) order += 1 video_sequence += 1 crc_packets += 1 crc_blocks += 1 block_id += 1 frames.append( Frame( frame_id, generation_us, tuple(units), len(composite.base_jpeg) + len(composite.roi_jpeg), ) ) controls = tuple( _link( TrafficClass.CONTROL, Direction.GROUND_TO_ROVER, STREAM_CONTROL, sequence, generation_us, 100, deterministic_payload(b"CONTROL", sequence, 32), ) for sequence, generation_us in enumerate(range(0, int(DURATION_SECONDS * 1_000_000), CONTROL_PERIOD_US)) ) emergencies = tuple( _link( TrafficClass.EMERGENCY, Direction.GROUND_TO_ROVER, STREAM_EMERGENCY, sequence, generation_us, 50, deterministic_payload(b"E-STOP", sequence, 32), ) for sequence, generation_us in enumerate(EMERGENCY_TIMES_US) ) telemetry: list[TxUnit] = [] for sequence, generation_us in enumerate(range(0, int(DURATION_SECONDS * 1_000_000), TELEMETRY_PERIOD_US)): packet = _link( TrafficClass.TELEMETRY, Direction.ROVER_TO_GROUND, STREAM_TELEMETRY, sequence, generation_us, 500, deterministic_payload(b"TELEM", sequence, 64), ) wire = encode_link_packet(packet) decode_link_packet(wire) telemetry.append(TxUnit(packet, wire, generation_us, order)) order += 1 crc_packets += 1 for packet in controls + emergencies: decode_link_packet(encode_link_packet(packet)) crc_packets += 1 return Workload( tuple(frames), controls, tuple(telemetry), emergencies, len(originals), crc_packets, crc_blocks, ) def _command_units(workload: Workload, mode: RepetitionMode) -> tuple[TxUnit, ...]: copies = build_command_copies( workload.controls + workload.emergencies, mode, first_arrival_order=10_000_000, control_repeat_delay_ms=CONTROL_REPEAT_DELAY_MS, ) units = [] for copy in copies: wire = encode_link_packet(copy.packet) units.append( TxUnit( copy.packet, wire, copy.available_time_us, copy.arrival_order, copy.copy_index, ) ) return tuple(units) def _queue_statistics(transmitted: list[Sent], removed: list[Removed]) -> tuple[float, int, float, int]: events: dict[float, list[int]] = {} packet_area = byte_area = 0.0 for sent in transmitted: start = sent.unit.available_seconds end = min(sent.end_seconds, DURATION_SECONDS) if end > start: duration = end - start packet_area += duration byte_area += duration * sent.unit.wire_size_bytes events.setdefault(start, [0, 0]) events.setdefault(end, [0, 0]) events[start][0] += 1 events[start][1] += sent.unit.wire_size_bytes events[end][0] -= 1 events[end][1] -= sent.unit.wire_size_bytes for item in removed: start = item.unit.available_seconds end = min(item.time_seconds, DURATION_SECONDS) if end > start: duration = end - start packet_area += duration byte_area += duration * item.unit.wire_size_bytes events.setdefault(start, [0, 0]) events.setdefault(end, [0, 0]) events[start][0] += 1 events[start][1] += item.unit.wire_size_bytes events[end][0] -= 1 events[end][1] -= item.unit.wire_size_bytes current = maximum = current_bytes = maximum_bytes = 0 for moment in sorted(events): current += events[moment][0] current_bytes += events[moment][1] maximum = max(maximum, current) maximum_bytes = max(maximum_bytes, current_bytes) return packet_area / DURATION_SECONDS, maximum, byte_area / DURATION_SECONDS, maximum_bytes def schedule(workload: Workload, rate_kbps: float, mode: RepetitionMode) -> Schedule: high = sorted( list(_command_units(workload, mode)) + list(workload.telemetry), key=lambda item: (item.available_time_us, int(item.packet.traffic_class), item.arrival_order), ) ready: list[TxUnit] = [] pending_frames: list[Frame] = [] transmitted: list[Sent] = [] removed: list[Removed] = [] dropped: list[int] = [] started: list[int] = [] completed: list[int] = [] cursor = 0.0 high_index = frame_index = active_index = 0 active: Frame | None = None latest_control_seen = -1 cancelled_repeats = replaced_state = max_waiting = 0 def admit(now: float) -> None: nonlocal high_index, frame_index, latest_control_seen, cancelled_repeats, replaced_state, max_waiting while high_index < len(high) and high[high_index].available_seconds <= now + EPSILON: item = high[high_index] high_index += 1 traffic = item.packet.traffic_class if traffic is TrafficClass.CONTROL: if item.copy_index == 0: latest_control_seen = max(latest_control_seen, item.packet.sequence_number) retained = [] for old in ready: if old.packet.traffic_class is TrafficClass.CONTROL and old.packet.sequence_number < item.packet.sequence_number: reason = "cancelled_repeat" if old.is_repeat else "replaced_state" removed.append(Removed(old, item.available_seconds, reason)) cancelled_repeats += int(old.is_repeat) replaced_state += int(not old.is_repeat) else: retained.append(old) ready[:] = retained elif item.packet.sequence_number < latest_control_seen: removed.append(Removed(item, item.available_seconds, "cancelled_repeat")) cancelled_repeats += 1 continue elif traffic is TrafficClass.TELEMETRY: retained = [] for old in ready: if old.packet.traffic_class is TrafficClass.TELEMETRY: removed.append(Removed(old, item.available_seconds, "replaced_telemetry")) else: retained.append(old) ready[:] = retained ready.append(item) while frame_index < len(workload.frames) and workload.frames[frame_index].generation_seconds <= now + EPSILON: frame = workload.frames[frame_index] frame_index += 1 for old in pending_frames: dropped.append(old.frame_id) removed.extend(Removed(unit, frame.generation_seconds, "newest_frame") for unit in old.packets) pending_frames[:] = [frame] max_waiting = max(max_waiting, len(pending_frames)) while high_index < len(high) or frame_index < len(workload.frames) or ready or pending_frames or active is not None: if not ready and not pending_frames and active is None: candidates = [] if high_index < len(high): candidates.append(high[high_index].available_seconds) if frame_index < len(workload.frames): candidates.append(workload.frames[frame_index].generation_seconds) cursor = max(cursor, min(candidates)) admit(cursor) if ready: unit = min(ready, key=lambda item: (int(item.packet.traffic_class), item.arrival_order)) ready.remove(unit) else: if active is None and pending_frames: active = pending_frames.pop(0) active_index = 0 started.append(active.frame_id) if active is None: continue unit = active.packets[active_index] start = max(cursor, unit.available_seconds) end = start + unit.wire_size_bytes * 8.0 / (rate_kbps * 1000.0) transmitted.append(Sent(unit, start, end)) admit(end) cursor = end if unit.packet.traffic_class is TrafficClass.VIDEO: active_index += 1 assert active is not None if active_index == len(active.packets): completed.append(active.frame_id) active = None active_index = 0 mean_queue, max_queue, mean_queue_bytes, max_queue_bytes = _queue_statistics(transmitted, removed) drain = transmitted[-1].end_seconds if transmitted else 0.0 return Schedule( rate_kbps, mode, tuple(transmitted), tuple(removed), tuple(dropped), tuple(started), tuple(completed), cancelled_repeats, replaced_state, mean_queue, max_queue, mean_queue_bytes, max_queue_bytes, max_waiting, drain, max(0.0, drain - DURATION_SECONDS), ) def bad_intervals(end_seconds: float, mean_bad_ms: float, seed: int) -> tuple[np.ndarray, np.ndarray]: rng = np.random.default_rng(seed) mean_bad = mean_bad_ms / 1000.0 mean_good = mean_bad * (1.0 - BAD_TIME_FRACTION) / BAD_TIME_FRACTION starts: list[float] = [] ends: list[float] = [] cursor = float(rng.exponential(mean_good)) while cursor < end_seconds: end = min(end_seconds, cursor + float(rng.exponential(mean_bad))) starts.append(cursor) ends.append(end) cursor = end + float(rng.exponential(mean_good)) return np.asarray(starts), np.asarray(ends) def loss_flags(schedule_: Schedule, starts: np.ndarray, ends: np.ndarray) -> np.ndarray: sent_starts = np.fromiter((item.start_seconds for item in schedule_.transmitted), dtype=float) sent_ends = np.fromiter((item.end_seconds for item in schedule_.transmitted), dtype=float) if not len(starts): return np.zeros(len(sent_starts), dtype=bool) indices = np.searchsorted(ends, sent_starts, side="right") valid = indices < len(starts) flags = np.zeros(len(sent_starts), dtype=bool) flags[valid] = starts[indices[valid]] < sent_ends[valid] - EPSILON return flags def positive_run_max(flags: Iterable[bool]) -> int: best = current = 0 for flag in flags: current = current + 1 if flag else 0 best = max(best, current) return best def _write_csv(path: Path, row_type: type, rows: Iterable[object]) -> None: rows = tuple(rows) with path.open("w", newline="", encoding="utf-8") as file: writer = csv.DictWriter(file, fieldnames=tuple(row_type.__dataclass_fields__)) writer.writeheader() writer.writerows(asdict(row) for row in rows) def simulate_condition( workload: Workload, schedule_: Schedule, mean_bad_ms: float, rate_index: int, duration_index: int, ) -> tuple[SummaryMetrics, CommandMetrics, VideoMetrics, tuple[EmergencyMetrics, ...]]: sent = schedule_.transmitted units = tuple(item.unit for item in sent) ends = np.asarray([item.end_seconds for item in sent]) sizes = np.asarray([item.unit.wire_size_bytes for item in sent], dtype=np.int64) control_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.CONTROL] telemetry_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.TELEMETRY] emergency_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.EMERGENCY] video_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.VIDEO] blocks: dict[int, list[int]] = {} frame_blocks: dict[int, set[int]] = {} for index in video_indices: unit = units[index] assert unit.block_id is not None and unit.frame_id is not None blocks.setdefault(unit.block_id, []).append(index) frame_blocks.setdefault(unit.frame_id, set()).add(unit.block_id) command_ages: list[float] = [] image_ages: list[float] = [] control_gaps: list[float] = [] image_gaps: list[float] = [] delivered_states = all_lost = duplicate_total = gaps_over_100 = 0 max_missing_run = 0 published_total = recovered_total = unrecoverable_total = 0 useful_video_bytes = lost_bytes = lost_packets_total = 0 telemetry_delivered = telemetry_gaps_over_500 = 0 telemetry_ages: list[float] = [] telemetry_last_ages: list[float] = [] telemetry_gaps_all: list[float] = [] emergency_delays: list[list[float]] = [[] for _ in EMERGENCY_TIMES_US] emergency_lost_copies = [0] * len(EMERGENCY_TIMES_US) emergency_duplicates = [0] * len(EMERGENCY_TIMES_US) for repetition in range(REPETITIONS): seed = MASTER_SEED + rate_index * 100_000 + duration_index * 1_000 + repetition bad_starts, bad_ends = bad_intervals(schedule_.drain_end_seconds, mean_bad_ms, seed) lost = loss_flags(schedule_, bad_starts, bad_ends) lost_packets_total += int(lost.sum()) lost_bytes += int(sizes[lost].sum()) receiver = CommandReceiver() received_control_times: list[float] = [] delivered_sequences: set[int] = set() for index in control_indices: if lost[index]: continue unit = units[index] if receiver.accept(unit.packet): delivered_sequences.add(unit.packet.sequence_number) received_control_times.append(ends[index]) command_ages.append((ends[index] - unit.packet.generation_time_us / 1_000_000.0) * 1000.0) delivered_states += len(delivered_sequences) missing = [sequence not in delivered_sequences for sequence in range(len(workload.controls))] all_lost += sum(missing) max_missing_run = max(max_missing_run, positive_run_max(missing)) duplicate_total += receiver.suppressed gaps = np.diff(np.asarray([0.0] + received_control_times + [DURATION_SECONDS])) * 1000.0 control_gaps.extend(gaps.tolist()) gaps_over_100 += int(np.count_nonzero(gaps > 100.0 + EPSILON)) telemetry_times: list[float] = [] telemetry_sequences: list[int] = [] for index in telemetry_indices: if not lost[index]: unit = units[index] telemetry_delivered += 1 telemetry_times.append(ends[index]) telemetry_sequences.append(unit.packet.sequence_number) telemetry_ages.append((ends[index] - unit.available_seconds) * 1000.0) telemetry_gaps = np.diff(np.asarray([0.0] + telemetry_times + [DURATION_SECONDS])) * 1000.0 telemetry_gaps_all.extend(telemetry_gaps.tolist()) telemetry_gaps_over_500 += int(np.count_nonzero(telemetry_gaps > 500.0 + EPSILON)) if telemetry_sequences: last_generation = telemetry_sequences[-1] * TELEMETRY_PERIOD_US / 1_000_000.0 telemetry_last_ages.append((DURATION_SECONDS - last_generation) * 1000.0) else: telemetry_last_ages.append(DURATION_SECONDS * 1000.0) by_emergency: dict[int, list[int]] = {i: [] for i in range(len(EMERGENCY_TIMES_US))} for index in emergency_indices: by_emergency[units[index].packet.sequence_number].append(index) for event, indices in by_emergency.items(): successes = [index for index in indices if not lost[index]] emergency_lost_copies[event] += len(indices) - len(successes) if successes: first = min(successes, key=lambda index: ends[index]) emergency_delays[event].append( (ends[first] - EMERGENCY_TIMES_US[event] / 1_000_000.0) * 1000.0 ) emergency_duplicates[event] += len(successes) - 1 block_ok: dict[int, bool] = {} block_time: dict[int, float] = {} for block_id, indices in blocks.items(): received = [index for index in indices if not lost[index]] source_count = units[indices[0]].source_count ok = len(received) >= source_count block_ok[block_id] = ok if ok: ordered = sorted(received, key=lambda index: ends[index]) block_time[block_id] = ends[ordered[source_count - 1]] missing_source = any(lost[index] for index in indices if units[index].is_source) recovered_total += int(missing_source) else: unrecoverable_total += 1 publications: list[tuple[float, int]] = [] for frame_id in schedule_.completed_frame_ids: ids = frame_blocks.get(frame_id, set()) if ids and all(block_ok[block_id] for block_id in ids): publication = max(block_time[block_id] for block_id in ids) publications.append((publication, frame_id)) image_ages.append((publication - workload.frames[frame_id].generation_seconds) * 1000.0) useful_video_bytes += workload.frames[frame_id].jpeg_bytes publications.sort() published_total += len(publications) gaps = np.diff(np.asarray([0.0] + [time for time, _ in publications] + [DURATION_SECONDS])) * 1000.0 image_gaps.extend(gaps.tolist()) mode_name = MODE_NAMES[schedule_.mode] command_units = [unit for unit in units if unit.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.EMERGENCY)] repeat_units = [unit for unit in command_units if unit.is_repeat] original_command_bytes = sum(len(encode_link_packet(packet)) for packet in workload.controls) control_bytes = sum(unit.wire_size_bytes for unit in command_units if unit.packet.traffic_class is TrafficClass.CONTROL) emergency_bytes = sum(unit.wire_size_bytes for unit in command_units if unit.packet.traffic_class is TrafficClass.EMERGENCY) telemetry_bytes = sum(unit.wire_size_bytes for unit in units if unit.packet.traffic_class is TrafficClass.TELEMETRY) video_bytes = sum(unit.wire_size_bytes for unit in units if unit.packet.traffic_class is TrafficClass.VIDEO) total_bytes = control_bytes + emergency_bytes + telemetry_bytes + video_bytes sent_by_source_end = sum(item.unit.wire_size_bytes for item in sent if item.end_seconds <= DURATION_SECONDS + EPSILON) summary = SummaryMetrics( schedule_.rate_kbps, mean_bad_ms, mode_name, REPETITIONS, emergency_bytes * 8.0 / DURATION_SECONDS / 1000.0, control_bytes * 8.0 / DURATION_SECONDS / 1000.0, telemetry_bytes * 8.0 / DURATION_SECONDS / 1000.0, video_bytes * 8.0 / DURATION_SECONDS / 1000.0, total_bytes * 8.0 / DURATION_SECONDS / 1000.0, sent_by_source_end * 8.0 / (schedule_.rate_kbps * 1000.0 * DURATION_SECONDS) * 100.0, schedule_.mean_queue_packets, schedule_.max_queue_packets, schedule_.mean_queue_bytes, schedule_.max_queue_bytes, schedule_.max_waiting_frames, sum(unit.wire_size_bytes for unit in repeat_units), lost_packets_total / REPETITIONS, lost_bytes / REPETITIONS, schedule_.cancelled_repeats, schedule_.replaced_state, schedule_.drain_end_seconds, schedule_.queue_release_seconds, telemetry_delivered / (REPETITIONS * len(workload.telemetry)), float(np.mean(telemetry_ages)) if telemetry_ages else 0.0, float(np.mean(telemetry_last_ages)), max(telemetry_gaps_all, default=0.0), telemetry_gaps_over_500 / REPETITIONS, ) command = CommandMetrics( schedule_.rate_kbps, mean_bad_ms, mode_name, len(workload.controls), sum(1 for unit in command_units if unit.packet.traffic_class is TrafficClass.CONTROL and unit.is_repeat), delivered_states / REPETITIONS, all_lost / REPETITIONS, all_lost / (REPETITIONS * len(workload.controls)), float(np.mean(command_ages)) if command_ages else 0.0, percentile(command_ages, 95), max(command_ages, default=0.0), max(control_gaps, default=0.0), gaps_over_100 / REPETITIONS, max_missing_run, schedule_.cancelled_repeats, duplicate_total / REPETITIONS, max(0, control_bytes - original_command_bytes) * 8.0 / DURATION_SECONDS / 1000.0, ) affected = recovered_total + unrecoverable_total video = VideoMetrics( schedule_.rate_kbps, mean_bad_ms, mode_name, FRAME_COUNT, len(schedule_.completed_frame_ids), len(schedule_.dropped_frame_ids), published_total / REPETITIONS, published_total / (REPETITIONS * FRAME_COUNT), published_total / (REPETITIONS * DURATION_SECONDS), float(np.mean(image_ages)) if image_ages else 0.0, percentile(image_ages, 95), max(image_ages, default=0.0), float(np.mean(image_gaps)) if image_gaps else 0.0, max(image_gaps, default=0.0), recovered_total / REPETITIONS, unrecoverable_total / REPETITIONS, recovered_total / affected if affected else 1.0, useful_video_bytes * 8.0 / (REPETITIONS * DURATION_SECONDS * 1000.0), ) emergency_rows = [] copies_per_event = 1 if schedule_.mode is RepetitionMode.NONE else 3 for event, event_us in enumerate(EMERGENCY_TIMES_US): delays = emergency_delays[event] emergency_rows.append( EmergencyMetrics( schedule_.rate_kbps, mean_bad_ms, mode_name, event_us / 1_000_000.0, len(delays) / REPETITIONS, float(np.mean(delays)) if delays else 0.0, percentile(delays, 95), sum(delay <= 50.0 + EPSILON for delay in delays) / REPETITIONS, emergency_lost_copies[event] / REPETITIONS, emergency_duplicates[event] / REPETITIONS, ) ) assert emergency_lost_copies[event] <= REPETITIONS * copies_per_event return summary, command, video, tuple(emergency_rows) def run_experiment(workload: Workload) -> tuple[ tuple[SummaryMetrics, ...], tuple[CommandMetrics, ...], tuple[VideoMetrics, ...], tuple[EmergencyMetrics, ...], dict[tuple[float, RepetitionMode], Schedule], ]: schedules = { (rate, mode): schedule(workload, rate, mode) for rate in CHANNEL_RATES_KBPS for mode in MODES } summaries: list[SummaryMetrics] = [] commands: list[CommandMetrics] = [] videos: list[VideoMetrics] = [] emergencies: list[EmergencyMetrics] = [] for rate_index, rate in enumerate(CHANNEL_RATES_KBPS): for duration_index, mean_bad in enumerate(MEAN_BAD_DURATIONS_MS): for mode in MODES: result = simulate_condition(workload, schedules[(rate, mode)], mean_bad, rate_index, duration_index) summaries.append(result[0]) commands.append(result[1]) videos.append(result[2]) emergencies.extend(result[3]) print(f"rate={rate:.0f} bad={mean_bad:.0f} mode={MODE_NAMES[mode]}") return tuple(summaries), tuple(commands), tuple(videos), tuple(emergencies), schedules def run_functional_tests(workload: Workload, schedules: dict[tuple[float, RepetitionMode], Schedule]) -> tuple[FunctionalTestResult, ...]: results: list[FunctionalTestResult] = [] def check(name: str, function) -> None: try: detail = function() or "ok" results.append(FunctionalTestResult(name, True, str(detail))) except Exception as error: # report every required check together results.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}")) def no_error_matches_lab036() -> str: item = schedules[(300.0, RepetitionMode.NONE)] assert len(item.started_frame_ids) == len(item.completed_frame_ids) assert item.max_waiting_frames <= 1 assert item.drain_end_seconds <= DURATION_SECONDS + 1.0 / COMPOSITE_FPS return "Lab036 latest-only/non-preemptive invariants retained" def corrupt_link_crc() -> str: wire = bytearray(encode_link_packet(workload.controls[0])) wire[-1] ^= 1 try: decode_link_packet(bytes(wire)) except LinkPacketCRCError: return "common packet rejected" raise AssertionError("corrupt link packet accepted") def corrupt_outer_crc() -> str: wire = bytearray(workload.frames[0].packets[0].packet.payload) wire[-1] ^= 1 try: decode_outer_symbol(bytes(wire)) except OuterPacketCRCError: return "outer video packet rejected" raise AssertionError("corrupt outer packet accepted") def fec_recovers() -> str: block = [unit.packet.payload for unit in workload.frames[0].packets if unit.block_id == workload.frames[0].packets[0].block_id] decoded = decode_fec_block(tuple(block[3:])) assert len(decoded.recovered_indices) == 3 for inner in decoded.source_packets: decode_inner_packet(inner) return "three erased source symbols restored" def monotonic_receiver() -> str: receiver = CommandReceiver() assert receiver.accept(workload.controls[1]) assert not receiver.accept(workload.controls[0]) assert not receiver.accept(workload.controls[1]) return "old/equal sequence suppressed" def copies_same_identity() -> str: copies = build_command_copies((workload.emergencies[0],), RepetitionMode.EMERGENCY_ONLY) assert len({encode_link_packet(copy.packet) for copy in copies}) == 1 return "three copies encode identically" def late_old_does_not_rollback() -> str: receiver = CommandReceiver() assert receiver.accept(workload.controls[10]) assert not receiver.accept(workload.controls[9]) assert receiver.last_sequence(STREAM_CONTROL) == 10 return "newer state retained" def only_unstarted_cancelled() -> str: for item in schedules.values(): transmitted_orders = {sent.unit.arrival_order for sent in item.transmitted} removed_orders = {removed.unit.arrival_order for removed in item.removed} assert transmitted_orders.isdisjoint(removed_orders) return "transmitted and removed sets are disjoint" def emergencies_not_removed() -> str: assert all( removed.unit.packet.traffic_class is not TrafficClass.EMERGENCY for item in schedules.values() for removed in item.removed ) return "no emergency removal" def nonpreemptive() -> str: for item in schedules.values(): assert all(a.end_seconds <= b.start_seconds + EPSILON for a, b in zip(item.transmitted, item.transmitted[1:])) return "serialization intervals do not overlap" def repeats_accounted() -> str: mode3 = schedules[(300.0, RepetitionMode.EMERGENCY_AND_CONTROL)] assert any(sent.unit.is_repeat for sent in mode3.transmitted) assert mode3.cancelled_repeats >= 0 return "repeat copies present in service/removal accounting" def latest_video_bounded() -> str: assert all(item.max_waiting_frames <= 1 for item in schedules.values()) return "at most one unstarted frame" def incomplete_not_published() -> str: item = schedules[(300.0, RepetitionMode.NONE)] starts = np.asarray([0.0]) ends = np.asarray([item.drain_end_seconds]) flags = loss_flags(item, starts, ends) assert flags.all() return "all-lost schedule yields no decodable block" def priority_between_video() -> str: found = False for item in schedules.values(): sent = item.transmitted for left, middle, right in zip(sent, sent[1:], sent[2:]): if left.unit.frame_id is not None and middle.unit.frame_id is None and right.unit.frame_id == left.unit.frame_id: found = True break assert found return "high priority traffic served between video packets" def reproducible() -> str: a = bad_intervals(120.0, 200.0, MASTER_SEED) b = bad_intervals(120.0, 200.0, MASTER_SEED) assert np.array_equal(a[0], b[0]) and np.array_equal(a[1], b[1]) return "identical seed gives identical intervals" checks = ( ("01_no_error_matches_lab036", no_error_matches_lab036), ("02_common_crc_rejects_corruption", corrupt_link_crc), ("03_outer_crc_rejects_corruption", corrupt_outer_crc), ("04_fec_restores_allowed_losses", fec_recovers), ("05_receiver_requires_newer_sequence", monotonic_receiver), ("06_copies_share_command_identity", copies_same_identity), ("07_late_old_copy_no_rollback", late_old_does_not_rollback), ("08_only_unstarted_copy_cancelled", only_unstarted_cancelled), ("09_emergency_never_removed", emergencies_not_removed), ("10_current_packet_nonpreemptive", nonpreemptive), ("11_repeats_in_queue_and_load", repeats_accounted), ("12_latest_video_queue_bounded", latest_video_bounded), ("13_incomplete_frame_not_published", incomplete_not_published), ("14_priority_between_video_packets", priority_between_video), ("15_fixed_seed_reproducibility", reproducible), ) for name, function in checks: check(name, function) return tuple(results) def save_plots( summaries: tuple[SummaryMetrics, ...], commands: tuple[CommandMetrics, ...], videos: tuple[VideoMetrics, ...], emergencies: tuple[EmergencyMetrics, ...], ) -> None: colors = {"none": "#777777", "emergency_only": "#e67e22", "emergency_and_control": "#2878b5"} def lines(rows, value, ylabel, title, path): figure, axis = plt.subplots(figsize=(9, 5)) for rate in CHANNEL_RATES_KBPS: for mode in MODE_NAMES.values(): selected = [row for row in rows if row.channel_kbps == rate and row.protection_mode == mode] axis.plot( [row.mean_bad_duration_ms for row in selected], [value(row) for row in selected], marker="o", color=colors[mode], linestyle={300.0: "-", 260.0: "--", 230.0: ":"}[rate], label=f"{rate:.0f}, {mode}", ) axis.set_xscale("log") axis.set_xlabel("Mean Bad duration, ms") axis.set_ylabel(ylabel) axis.set_title(title) axis.grid(True, alpha=0.3) axis.legend(fontsize=7, ncol=3) figure.tight_layout() figure.savefig(path, dpi=150) plt.close(figure) lines(commands, lambda row: row.max_gap_without_fresh_command_ms, "ms", "Maximum gap without fresh command", COMMAND_GAP_PLOT) emergency_average = [] for rate in CHANNEL_RATES_KBPS: for bad in MEAN_BAD_DURATIONS_MS: for mode in MODE_NAMES.values(): rows = [row for row in emergencies if row.channel_kbps == rate and row.mean_bad_duration_ms == bad and row.protection_mode == mode] emergency_average.append(replace(rows[0], deadline_50ms_fraction=float(np.mean([row.deadline_50ms_fraction for row in rows])))) lines(tuple(emergency_average), lambda row: row.deadline_50ms_fraction * 100.0, "%", "Emergency delivery within 50 ms", EMERGENCY_PLOT) lines(commands, lambda row: row.p95_age_ms, "ms", "P95 command age", COMMAND_AGE_PLOT) lines(videos, lambda row: row.p95_image_age_ms, "ms", "P95 image age", IMAGE_AGE_PLOT) lines(videos, lambda row: row.published_fraction * 100.0, "%", "Published composite frames", VIDEO_PLOT) figure, axes = plt.subplots(1, 2, figsize=(12, 5)) selected = [row for row in summaries if row.mean_bad_duration_ms == 200.0] labels = [f"{row.channel_kbps:.0f}\n{row.protection_mode}" for row in selected] axes[0].bar(range(len(selected)), [row.total_offered_load_kbps for row in selected], color=[colors[row.protection_mode] for row in selected]) axes[0].set_ylabel("kbit/s") axes[0].set_title("Offered load") axes[1].bar(range(len(selected)), [row.max_queue_packets for row in selected], color=[colors[row.protection_mode] for row in selected]) axes[1].set_ylabel("packets") axes[1].set_title("Maximum queue") for axis in axes: axis.set_xticks(range(len(labels)), labels, rotation=45, ha="right", fontsize=7) axis.grid(True, axis="y", alpha=0.3) figure.tight_layout() figure.savefig(LOAD_QUEUE_PLOT, dpi=150) plt.close(figure) selected = [row for row in commands if row.channel_kbps == 260.0 and row.mean_bad_duration_ms == 200.0] figure, axis1 = plt.subplots(figsize=(8, 5)) x = np.arange(len(selected)) axis1.bar(x, [row.undelivered_state_fraction * 100.0 for row in selected], color=[colors[row.protection_mode] for row in selected]) axis1.set_ylabel("Undelivered states, %") axis2 = axis1.twinx() axis2.plot(x, [row.repetition_overhead_kbps for row in selected], color="black", marker="o") axis2.set_ylabel("Repeat overhead, kbit/s") axis1.set_xticks(x, [row.protection_mode for row in selected]) axis1.set_title("Protection trade-off (260 kbit/s, Bad=200 ms)") figure.tight_layout() figure.savefig(COMPARISON_PLOT, dpi=150) plt.close(figure) def write_report( workload: Workload, summaries: tuple[SummaryMetrics, ...], commands: tuple[CommandMetrics, ...], videos: tuple[VideoMetrics, ...], emergencies: tuple[EmergencyMetrics, ...], tests: tuple[FunctionalTestResult, ...], ) -> None: lines = [ "Lab037 — полный транспорт с ошибками канала и повторением команд", "", "Состояние Git до реализации", "- Ветка: main", "- HEAD: 3e0ef5666eed6833818a731383220b2e15926889", "- Рабочее дерево: чистое.", "- fetch, pull и push не выполнялись.", "", "Параметры модели", f"- Длительность: {DURATION_SECONDS:.0f} с; кадров: {FRAME_COUNT}; частота: {COMPOSITE_FPS:.0f} кадр/с.", f"- Видео: payload {VIDEO_PAYLOAD_BYTES} байт, FEC {SOURCE_BLOCK_SIZE}+{PARITY_COUNT}, блоки выровнены по кадрам.", f"- Команды: 20/с; телеметрия: 10/с; аварийные события: 30, 60, 90 с.", f"- Канал: Bad fraction={BAD_TIME_FRACTION:.2%}, Bad mean={MEAN_BAD_DURATIONS_MS}, rates={CHANNEL_RATES_KBPS}, repeats={REPETITIONS}.", f"- Master seed: {MASTER_SEED}; обычный повтор режима 3: {CONTROL_REPEAT_DELAY_MS:.1f} мс.", f"- Проверено CRC-пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}; профиль: {workload.source_profile_frames} кадров.", "- Копия команды сохраняет тот же stream_id, sequence_number и байты; задержка хранится только в метаданных планировщика.", "", "Таблица 36 сочетаний", "rate | Bad ms | mode | load kbit/s | util % | queue mean/max | command miss % | command gap ms | emergency <=50 ms % | video % | image P95 ms", ] command_lookup = {(row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode): row for row in commands} video_lookup = {(row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode): row for row in videos} for summary in summaries: key = (summary.channel_kbps, summary.mean_bad_duration_ms, summary.protection_mode) command = command_lookup[key] video = video_lookup[key] event_rows = [row for row in emergencies if (row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode) == key] timely = np.mean([row.deadline_50ms_fraction for row in event_rows]) * 100.0 lines.append( f"{summary.channel_kbps:.0f} | {summary.mean_bad_duration_ms:.0f} | {summary.protection_mode} | " f"{summary.total_offered_load_kbps:.3f} | {summary.channel_utilization_percent:.2f} | " f"{summary.mean_queue_packets:.2f}/{summary.max_queue_packets} | " f"{command.undelivered_state_fraction * 100.0:.3f} | {command.max_gap_without_fresh_command_ms:.3f} | " f"{timely:.2f} | {video.published_fraction * 100.0:.2f} | {video.p95_image_age_ms:.3f}" ) best = min(commands, key=lambda row: (row.undelivered_state_fraction, row.repetition_overhead_kbps)) lines.extend( [ "", "Обычные команды", f"- Минимальная наблюдавшаяся доля недоставленных состояний: {best.undelivered_state_fraction:.6f} ({best.protection_mode}).", f"- Наибольший промежуток без свежей команды: {max(row.max_gap_without_fresh_command_ms for row in commands):.3f} мс.", "- Поздние/повторные копии подавляются строго монотонным sequence_number и не откатывают состояние.", "", "Аварийные команды", f"- Доля своевременных доставок по всем строкам: {np.mean([row.deadline_50ms_fraction for row in emergencies]) * 100.0:.3f}%.", f"- Максимальный P95 первой доставки: {max(row.p95_first_delivery_ms for row in emergencies):.3f} мс.", "", "Нагрузка, видео и очередь", f"- Диапазон полной предложенной нагрузки: {min(row.total_offered_load_kbps for row in summaries):.3f}…{max(row.total_offered_load_kbps for row in summaries):.3f} кбит/с.", f"- Максимальная очередь: {max(row.max_queue_packets for row in summaries)} пакетов / {max(row.max_queue_bytes for row in summaries)} байт; ожидающих не начатых кадров: {max(row.max_waiting_video_frames for row in summaries)}.", f"- Доля опубликованных кадров: {min(row.published_fraction for row in videos) * 100.0:.3f}…{max(row.published_fraction for row in videos) * 100.0:.3f}%.", f"- Полезный доставленный видеопоток: {min(row.delivered_useful_video_kbps for row in videos):.3f}…{max(row.delivered_useful_video_kbps for row in videos):.3f} кбит/с.", "- Повторение команд увеличивает предложенную нагрузку и учитывается в общей очереди; свежесть видео ограничивается политикой одного свежего не начатого кадра.", "", "Телеметрия", f"- Доля доставленных уникальных сообщений: {min(row.telemetry_delivered_fraction for row in summaries) * 100.0:.3f}…{max(row.telemetry_delivered_fraction for row in summaries) * 100.0:.3f}%.", f"- Средний возраст последнего сообщения на отметке 120 с: {min(row.telemetry_last_message_age_ms for row in summaries):.3f}…{max(row.telemetry_last_message_age_ms for row in summaries):.3f} мс.", f"- Максимальный промежуток без обновления: {max(row.telemetry_max_gap_ms for row in summaries):.3f} мс; максимум интервалов >500 мс за опыт: {max(row.telemetry_gaps_over_500ms_mean for row in summaries):.3f}.", "", "Функциональные проверки", ] ) lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests) lines.extend( [ "", "Созданные файлы", "- protocol/control_repetition.py", "- experiments/lab037_lossy_full_link.py", *[f"- {path.as_posix()}" for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS)], "", "Итоговый git status", "?? data/processed/lab037/", "?? protocol/control_repetition.py", "?? experiments/lab037_lossy_full_link.py", "", "Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.", "Режим защиты автоматически не выбирается: все три режима представлены отдельно.", ] ) REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") def validate_outputs( summaries: tuple[SummaryMetrics, ...], commands: tuple[CommandMetrics, ...], videos: tuple[VideoMetrics, ...], emergencies: tuple[EmergencyMetrics, ...], tests: tuple[FunctionalTestResult, ...], ) -> None: assert len(summaries) == len(commands) == len(videos) == 36 assert len(emergencies) == 108 assert all(item.passed for item in tests), [item for item in tests if not item.passed] for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS): assert path.is_file() and path.stat().st_size > 0, path for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH): path.read_text(encoding="utf-8") def main() -> None: OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True) workload = build_workload() summaries, commands, videos, emergencies, schedules = run_experiment(workload) tests = run_functional_tests(workload, schedules) _write_csv(SUMMARY_CSV_PATH, SummaryMetrics, summaries) _write_csv(COMMAND_CSV_PATH, CommandMetrics, commands) _write_csv(VIDEO_CSV_PATH, VideoMetrics, videos) _write_csv(EMERGENCY_CSV_PATH, EmergencyMetrics, emergencies) save_plots(summaries, commands, videos, emergencies) write_report(workload, summaries, commands, videos, emergencies, tests) validate_outputs(summaries, commands, videos, emergencies, tests) print(f"Lab037 complete: 36 conditions, {REPETITIONS} repetitions each") for item in tests: print(f"{'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}") if __name__ == "__main__": main()