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>
1239 lines
52 KiB
Python
1239 lines
52 KiB
Python
"""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()
|