1362 lines
56 KiB
Python
1362 lines
56 KiB
Python
"""Lab036: long-duration whole-frame scheduling with changing capacity."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
from dataclasses import asdict, dataclass, replace
|
||
import hashlib
|
||
from pathlib import Path
|
||
import subprocess
|
||
from typing import Iterable
|
||
|
||
import cv2
|
||
import matplotlib
|
||
import numpy as np
|
||
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
|
||
from protocol.link_packet import (
|
||
Direction,
|
||
LinkPacket,
|
||
TrafficClass,
|
||
decode_link_packet,
|
||
encode_link_packet,
|
||
)
|
||
from protocol.packet_erasure_fec import (
|
||
decode_fec_block,
|
||
decode_outer_symbol,
|
||
encode_fec_block,
|
||
)
|
||
from protocol.video_age_policy import AgePolicyPacket
|
||
from protocol.video_frame_scheduler import FramePolicy, VideoFrameGroup
|
||
from protocol.video_packet import decode_packet as decode_inner_packet
|
||
from tests.lab028_video_packetization import (
|
||
COMPOSITE_FPS,
|
||
EncodedComposite,
|
||
packets_for_composite,
|
||
)
|
||
from tests.lab033_priority_channel_scheduler import (
|
||
STREAM_CONTROL,
|
||
STREAM_EMERGENCY,
|
||
STREAM_TELEMETRY,
|
||
STREAM_VIDEO,
|
||
build_workload as build_lab033_workload,
|
||
deterministic_payload,
|
||
)
|
||
from tests.lab034_stale_video_drop import parity_for_partial
|
||
|
||
|
||
OUTPUT_DIRECTORY = Path("data/processed/lab036")
|
||
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab036_summary.csv"
|
||
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab036_video_metrics.csv"
|
||
CONTROL_CSV_PATH = OUTPUT_DIRECTORY / "lab036_control_metrics.csv"
|
||
RECOVERY_CSV_PATH = OUTPUT_DIRECTORY / "lab036_recovery_metrics.csv"
|
||
REPORT_PATH = OUTPUT_DIRECTORY / "lab036_report.txt"
|
||
AGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_image_age_timeline.png"
|
||
QUEUE_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_queue_timeline.png"
|
||
MINUTE_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_minute_update_rate.png"
|
||
RECOVERY_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_recovery.png"
|
||
CONTROL_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_control_delay.png"
|
||
OUTCOME_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_frame_outcomes.png"
|
||
GROWTH_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_queue_growth_theory.png"
|
||
COMPARISON_PLOT_PATH = OUTPUT_DIRECTORY / "lab036_policy_comparison.png"
|
||
PLOT_PATHS = (
|
||
AGE_PLOT_PATH,
|
||
QUEUE_PLOT_PATH,
|
||
MINUTE_PLOT_PATH,
|
||
RECOVERY_PLOT_PATH,
|
||
CONTROL_PLOT_PATH,
|
||
OUTCOME_PLOT_PATH,
|
||
GROWTH_PLOT_PATH,
|
||
COMPARISON_PLOT_PATH,
|
||
)
|
||
|
||
LAB035_COMMIT = "22c2eeab8cc794e67378485f49c236a622f814a7"
|
||
DURATION_SECONDS = 600.0
|
||
FRAME_COUNT = int(DURATION_SECONDS * COMPOSITE_FPS)
|
||
VIDEO_PAYLOAD_BYTES = 512
|
||
SOURCE_BLOCK_SIZE = 12
|
||
NOMINAL_PARITY_COUNT = 3
|
||
CONTROL_PERIOD_US = 50_000
|
||
TELEMETRY_PERIOD_US = 100_000
|
||
EMERGENCY_TIMES_US = (60_000_000, 180_000_000, 310_000_000, 480_000_000)
|
||
TIME_EPSILON_SECONDS = 1e-9
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SpeedProfile:
|
||
name: str
|
||
label: str
|
||
kind: str
|
||
constant_kbps: float | None = None
|
||
|
||
def rate_kbps(self, time_seconds: float) -> float:
|
||
if self.kind == "constant":
|
||
assert self.constant_kbps is not None
|
||
return self.constant_kbps
|
||
if self.kind == "step":
|
||
if time_seconds < 120.0:
|
||
return 300.0
|
||
if time_seconds < 300.0:
|
||
return 230.0
|
||
return 300.0
|
||
if self.kind == "variable":
|
||
if time_seconds >= DURATION_SECONDS:
|
||
return 230.0
|
||
phase = time_seconds % 120.0
|
||
return 300.0 if phase < 40.0 else 260.0 if phase < 80.0 else 230.0
|
||
raise ValueError(f"unknown speed profile: {self.kind}")
|
||
|
||
@property
|
||
def mean_rate_kbps(self) -> float:
|
||
if self.kind == "constant":
|
||
assert self.constant_kbps is not None
|
||
return self.constant_kbps
|
||
if self.kind == "step":
|
||
return (120.0 * 300.0 + 180.0 * 230.0 + 300.0 * 300.0) / DURATION_SECONDS
|
||
return (300.0 + 260.0 + 230.0) / 3.0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PolicyDefinition:
|
||
name: str
|
||
label: str
|
||
policy: FramePolicy
|
||
|
||
|
||
SPEED_PROFILES = (
|
||
SpeedProfile("constant_300", "Постоянно 300", "constant", 300.0),
|
||
SpeedProfile("constant_260", "Постоянно 260", "constant", 260.0),
|
||
SpeedProfile("constant_230", "Постоянно 230", "constant", 230.0),
|
||
SpeedProfile("step_300_230_300", "300→230→300", "step"),
|
||
SpeedProfile("variable_300_260_230", "Цикл 300/260/230", "variable"),
|
||
)
|
||
POLICIES = (
|
||
PolicyDefinition("no_drop", "Без удаления", FramePolicy.NO_DROP),
|
||
PolicyDefinition("latest_only", "Самый свежий", FramePolicy.LATEST_ONLY),
|
||
PolicyDefinition("two_waiting", "Два ожидающих", FramePolicy.TWO_WAITING),
|
||
)
|
||
PROFILE_BY_NAME = {item.name: item for item in SPEED_PROFILES}
|
||
POLICY_BY_NAME = {item.name: item for item in POLICIES}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LongWorkload:
|
||
frames: tuple[VideoFrameGroup, ...]
|
||
high_priority: tuple[AgePolicyPacket, ...]
|
||
jpeg_bytes_by_frame: tuple[int, ...]
|
||
original_frame_count: int
|
||
video_wire_bytes: int
|
||
nonvideo_wire_bytes: int
|
||
crc_packets_checked: int
|
||
crc_blocks_checked: int
|
||
repeated_headers_are_fresh: bool
|
||
|
||
@property
|
||
def offered_wire_bytes(self) -> int:
|
||
return self.video_wire_bytes + self.nonvideo_wire_bytes
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DroppedFrame:
|
||
frame: VideoFrameGroup
|
||
time_seconds: float
|
||
reason: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Replacement:
|
||
removed: AgePolicyPacket
|
||
replacement: AgePolicyPacket
|
||
time_seconds: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScheduledPacket:
|
||
item: AgePolicyPacket
|
||
start_seconds: float
|
||
end_seconds: float
|
||
rate_kbps: float
|
||
blocked_by: AgePolicyPacket | None
|
||
blocking_delay_seconds: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LongSchedule:
|
||
profile: SpeedProfile
|
||
policy: PolicyDefinition
|
||
transmitted: tuple[ScheduledPacket, ...]
|
||
dropped: tuple[DroppedFrame, ...]
|
||
replacements: tuple[Replacement, ...]
|
||
started_frame_ids: tuple[int, ...]
|
||
completed_frame_ids: tuple[int, ...]
|
||
publication_times: dict[int, float]
|
||
digest: str
|
||
bit_accounting_error: float
|
||
rate_rule_ok: bool
|
||
frames_do_not_interleave: bool
|
||
high_priority_between_video_packets: bool
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class QueueStatistics:
|
||
mean_packets: float
|
||
max_packets: int
|
||
mean_bytes: float
|
||
max_bytes: int
|
||
mean_waiting_frames: float
|
||
max_waiting_frames: int
|
||
minute_packets: tuple[int, ...]
|
||
minute_bytes: tuple[int, ...]
|
||
timeline_seconds: tuple[float, ...]
|
||
timeline_packets: tuple[int, ...]
|
||
timeline_bytes: tuple[int, ...]
|
||
remaining_packets: int
|
||
remaining_bytes: int
|
||
remaining_frames: int
|
||
drain_seconds: float
|
||
growth_bytes_per_second: float
|
||
growth_bytes_per_minute: float
|
||
excess_queue_clear_time_seconds: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SummaryMetrics:
|
||
scenario: str
|
||
policy: str
|
||
duration_seconds: float
|
||
mean_channel_kbps: float
|
||
offered_load_kbps: float
|
||
speed_reserve_kbps: float
|
||
theoretical_queue_growth_bytes_per_second: float
|
||
theoretical_minimum_skip_fraction: float
|
||
theoretical_sustainable_fps: float
|
||
actual_queue_growth_bytes_per_second: float
|
||
actual_queue_growth_bytes_per_minute: float
|
||
unbounded_queue_growth: bool
|
||
mean_queue_packets: float
|
||
max_queue_packets: int
|
||
mean_queue_bytes: float
|
||
max_queue_bytes: int
|
||
mean_waiting_video_frames: float
|
||
max_waiting_video_frames: int
|
||
queue_packets_each_minute: str
|
||
queue_bytes_each_minute: str
|
||
queue_at_600s_packets: int
|
||
queue_at_600s_bytes: int
|
||
frames_remaining_at_600s: int
|
||
additional_drain_seconds: float
|
||
transmitted_packets: int
|
||
transmitted_bytes: int
|
||
replaced_state_packets: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class VideoMetrics:
|
||
scenario: str
|
||
policy: str
|
||
created_frames: int
|
||
started_frames: int
|
||
published_frames: int
|
||
published_by_600s_frames: int
|
||
dropped_before_start_frames: int
|
||
published_fraction: float
|
||
actual_update_fps: float
|
||
update_fps_each_minute: str
|
||
mean_publication_delay_ms: float
|
||
p95_publication_delay_ms: float
|
||
max_publication_delay_ms: float
|
||
mean_display_age_ms: float
|
||
p95_display_age_ms: float
|
||
max_display_age_ms: float
|
||
display_age_over_500ms_fraction: float
|
||
display_age_over_1000ms_fraction: float
|
||
mean_no_update_duration_ms: float
|
||
p95_no_update_duration_ms: float
|
||
max_no_update_duration_ms: float
|
||
max_consecutive_missing_frames: int
|
||
mean_publication_gap_ms: float
|
||
max_publication_gap_ms: float
|
||
transmitted_video_bytes: int
|
||
wasted_transmitted_video_bytes: int
|
||
delivered_useful_video_kbps: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ControlMetrics:
|
||
scenario: str
|
||
policy: str
|
||
control_mean_delay_ms: float
|
||
control_p95_delay_ms: float
|
||
control_max_delay_ms: float
|
||
control_over_100ms: int
|
||
control_max_receive_gap_ms: float
|
||
emergency_delays_ms: str
|
||
emergency_max_delay_ms: float
|
||
all_emergency_under_50ms: bool
|
||
telemetry_mean_delay_ms: float
|
||
telemetry_p95_delay_ms: float
|
||
telemetry_max_delay_ms: float
|
||
telemetry_over_500ms: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RecoveryMetrics:
|
||
scenario: str
|
||
policy: str
|
||
applicable: bool
|
||
image_age_at_300s_ms: float
|
||
time_to_age_below_1000ms_seconds: float
|
||
time_to_age_below_500ms_seconds: float
|
||
excess_queue_clear_seconds: float
|
||
frames_dropped_after_recovery: int
|
||
old_frames_published_after_recovery: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScenarioResult:
|
||
summary: SummaryMetrics
|
||
video: VideoMetrics
|
||
control: ControlMetrics
|
||
recovery: RecoveryMetrics
|
||
minute_update_fps: tuple[float, ...]
|
||
age_timeline_seconds: tuple[float, ...]
|
||
age_timeline_ms: tuple[float, ...]
|
||
queue_timeline_seconds: tuple[float, ...]
|
||
queue_timeline_bytes: tuple[int, ...]
|
||
digest: str
|
||
accounting_ok: bool
|
||
started_frames_complete: bool
|
||
no_interleave: bool
|
||
priority_between_video: bool
|
||
rate_rule_ok: bool
|
||
bit_accounting_error: 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_packet(
|
||
traffic_class: TrafficClass,
|
||
direction: Direction,
|
||
stream_id: int,
|
||
sequence: int,
|
||
generation_time_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_time_us,
|
||
deadline_ms=deadline_ms,
|
||
payload=payload,
|
||
)
|
||
|
||
|
||
def _validated_link(packet: LinkPacket) -> None:
|
||
decoded = decode_link_packet(encode_link_packet(packet))
|
||
assert decoded.traffic_class is packet.traffic_class
|
||
assert decoded.sequence_number == packet.sequence_number
|
||
assert decoded.generation_time_us == packet.generation_time_us
|
||
assert decoded.payload == packet.payload
|
||
|
||
|
||
def build_long_workload() -> LongWorkload:
|
||
"""Repeat the 63 real JPEG-size pairs while rebuilding every header/CRC."""
|
||
|
||
lab033 = build_lab033_workload()
|
||
originals = tuple(lab033.composites)
|
||
if len(originals) != 63:
|
||
raise AssertionError("Lab036 requires the 63-frame source profile")
|
||
frames: list[VideoFrameGroup] = []
|
||
jpeg_sizes: list[int] = []
|
||
video_sequence = 0
|
||
block_id = 0
|
||
crc_packets = 0
|
||
crc_blocks = 0
|
||
repeated_inner_headers: dict[int, bytes] = {}
|
||
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,
|
||
)
|
||
base_packets, roi_packets = packets_for_composite(composite, VIDEO_PAYLOAD_BYTES)
|
||
inner_packets = tuple(base_packets + roi_packets)
|
||
if frame_id in (0, len(originals)):
|
||
repeated_inner_headers[frame_id] = inner_packets[0]
|
||
generation_us = int(round(frame_id / COMPOSITE_FPS * 1_000_000.0))
|
||
frame_packets: list[AgePolicyPacket] = []
|
||
for start in range(0, len(inner_packets), SOURCE_BLOCK_SIZE):
|
||
chunk = inner_packets[start:start + SOURCE_BLOCK_SIZE]
|
||
parity_count = (
|
||
NOMINAL_PARITY_COUNT
|
||
if len(chunk) == SOURCE_BLOCK_SIZE
|
||
else parity_for_partial(len(chunk))
|
||
)
|
||
outer_packets = encode_fec_block(chunk, block_id, parity_count)
|
||
decoded_block = decode_fec_block(tuple(outer_packets))
|
||
assert decoded_block.source_packets == chunk
|
||
for inner in decoded_block.source_packets:
|
||
assert decode_inner_packet(inner).composite_frame_id == frame_id
|
||
crc_blocks += 1
|
||
for outer_wire in outer_packets:
|
||
decode_outer_symbol(outer_wire)
|
||
packet = _link_packet(
|
||
TrafficClass.VIDEO,
|
||
Direction.ROVER_TO_GROUND,
|
||
STREAM_VIDEO,
|
||
video_sequence,
|
||
generation_us,
|
||
0,
|
||
outer_wire,
|
||
)
|
||
_validated_link(packet)
|
||
frame_packets.append(
|
||
AgePolicyPacket(packet, video_sequence, generation_us, frame_id)
|
||
)
|
||
video_sequence += 1
|
||
crc_packets += 1
|
||
block_id += 1
|
||
frames.append(VideoFrameGroup(frame_id, generation_us, tuple(frame_packets)))
|
||
jpeg_sizes.append(len(original.base_jpeg) + len(original.roi_jpeg))
|
||
|
||
high_packets: list[AgePolicyPacket] = []
|
||
for sequence, generation_us in enumerate(
|
||
range(0, int(DURATION_SECONDS * 1_000_000), CONTROL_PERIOD_US)
|
||
):
|
||
packet = _link_packet(
|
||
TrafficClass.CONTROL, Direction.GROUND_TO_ROVER, STREAM_CONTROL,
|
||
sequence, generation_us, 100,
|
||
deterministic_payload(b"CONTROL", sequence, 32),
|
||
)
|
||
high_packets.append(AgePolicyPacket(packet, -1, generation_us, None))
|
||
for sequence, generation_us in enumerate(
|
||
range(0, int(DURATION_SECONDS * 1_000_000), TELEMETRY_PERIOD_US)
|
||
):
|
||
packet = _link_packet(
|
||
TrafficClass.TELEMETRY, Direction.ROVER_TO_GROUND, STREAM_TELEMETRY,
|
||
sequence, generation_us, 500,
|
||
deterministic_payload(b"TELEM", sequence, 64),
|
||
)
|
||
high_packets.append(AgePolicyPacket(packet, -1, generation_us, None))
|
||
for sequence, generation_us in enumerate(EMERGENCY_TIMES_US):
|
||
packet = _link_packet(
|
||
TrafficClass.EMERGENCY, Direction.GROUND_TO_ROVER, STREAM_EMERGENCY,
|
||
sequence, generation_us, 50,
|
||
deterministic_payload(b"E-STOP", sequence, 32),
|
||
)
|
||
high_packets.append(AgePolicyPacket(packet, -1, generation_us, None))
|
||
high_packets.sort(
|
||
key=lambda item: (
|
||
item.available_time_us,
|
||
int(item.packet.traffic_class),
|
||
item.packet.stream_id,
|
||
item.packet.sequence_number,
|
||
)
|
||
)
|
||
high_packets = [
|
||
replace(item, arrival_order=video_sequence + index)
|
||
for index, item in enumerate(high_packets)
|
||
]
|
||
for item in high_packets:
|
||
_validated_link(item.packet)
|
||
crc_packets += 1
|
||
return LongWorkload(
|
||
frames=tuple(frames),
|
||
high_priority=tuple(high_packets),
|
||
jpeg_bytes_by_frame=tuple(jpeg_sizes),
|
||
original_frame_count=len(originals),
|
||
video_wire_bytes=sum(frame.wire_size_bytes for frame in frames),
|
||
nonvideo_wire_bytes=sum(item.wire_size_bytes for item in high_packets),
|
||
crc_packets_checked=crc_packets,
|
||
crc_blocks_checked=crc_blocks,
|
||
repeated_headers_are_fresh=(
|
||
repeated_inner_headers[0] != repeated_inner_headers[len(originals)]
|
||
),
|
||
)
|
||
|
||
|
||
def schedule_long(
|
||
workload: LongWorkload,
|
||
profile: SpeedProfile,
|
||
policy: PolicyDefinition,
|
||
) -> LongSchedule:
|
||
frame_arrivals = workload.frames
|
||
high_arrivals = workload.high_priority
|
||
ready_high: list[AgePolicyPacket] = []
|
||
pending_frames: list[VideoFrameGroup] = []
|
||
transmitted: list[ScheduledPacket] = []
|
||
dropped: list[DroppedFrame] = []
|
||
replacements: list[Replacement] = []
|
||
started: list[int] = []
|
||
completed: list[int] = []
|
||
publications: dict[int, float] = {}
|
||
blocker_by_order: dict[int, tuple[AgePolicyPacket, float]] = {}
|
||
active: VideoFrameGroup | None = None
|
||
active_index = 0
|
||
cursor = 0.0
|
||
frame_index = 0
|
||
high_index = 0
|
||
|
||
def enqueue_high(item: AgePolicyPacket) -> None:
|
||
if item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY):
|
||
retained = []
|
||
for old in ready_high:
|
||
if (
|
||
old.packet.traffic_class is item.packet.traffic_class
|
||
and old.packet.stream_id == item.packet.stream_id
|
||
):
|
||
replacements.append(Replacement(old, item, item.available_time_seconds))
|
||
else:
|
||
retained.append(old)
|
||
ready_high[:] = retained
|
||
ready_high.append(item)
|
||
|
||
def admit(now: float, current: AgePolicyPacket | None = None, start: float = 0.0) -> None:
|
||
nonlocal frame_index, high_index
|
||
while (
|
||
high_index < len(high_arrivals)
|
||
and high_arrivals[high_index].available_time_seconds <= now + TIME_EPSILON_SECONDS
|
||
):
|
||
item = high_arrivals[high_index]
|
||
high_index += 1
|
||
if current is not None and item.available_time_seconds > start + TIME_EPSILON_SECONDS:
|
||
blocker_by_order[item.arrival_order] = (
|
||
current,
|
||
max(0.0, now - item.available_time_seconds),
|
||
)
|
||
enqueue_high(item)
|
||
while (
|
||
frame_index < len(frame_arrivals)
|
||
and frame_arrivals[frame_index].generation_time_seconds <= now + TIME_EPSILON_SECONDS
|
||
):
|
||
frame = frame_arrivals[frame_index]
|
||
frame_index += 1
|
||
arrival = frame.generation_time_seconds
|
||
if policy.policy is FramePolicy.LATEST_ONLY:
|
||
dropped.extend(
|
||
DroppedFrame(old, arrival, "replaced_by_newest")
|
||
for old in pending_frames
|
||
)
|
||
pending_frames[:] = [frame]
|
||
elif policy.policy is FramePolicy.TWO_WAITING:
|
||
pending_frames.append(frame)
|
||
while len(pending_frames) > 2:
|
||
dropped.append(DroppedFrame(pending_frames.pop(0), arrival, "waiting_limit"))
|
||
else:
|
||
pending_frames.append(frame)
|
||
|
||
while (
|
||
frame_index < len(frame_arrivals)
|
||
or high_index < len(high_arrivals)
|
||
or ready_high
|
||
or pending_frames
|
||
or active is not None
|
||
):
|
||
if not ready_high and not pending_frames and active is None:
|
||
next_times = []
|
||
if frame_index < len(frame_arrivals):
|
||
next_times.append(frame_arrivals[frame_index].generation_time_seconds)
|
||
if high_index < len(high_arrivals):
|
||
next_times.append(high_arrivals[high_index].available_time_seconds)
|
||
cursor = max(cursor, min(next_times))
|
||
admit(cursor)
|
||
selected: AgePolicyPacket | None = None
|
||
if ready_high:
|
||
selected = min(
|
||
ready_high,
|
||
key=lambda item: (int(item.packet.traffic_class), item.arrival_order),
|
||
)
|
||
ready_high.remove(selected)
|
||
else:
|
||
if active is None and pending_frames:
|
||
active = pending_frames.pop(0)
|
||
active_index = 0
|
||
started.append(active.composite_frame_id)
|
||
if active is not None:
|
||
selected = active.packets[active_index]
|
||
if selected is None:
|
||
continue
|
||
start = max(cursor, selected.available_time_seconds)
|
||
rate_kbps = profile.rate_kbps(start)
|
||
end = start + selected.wire_size_bytes * 8.0 / (rate_kbps * 1000.0)
|
||
admit(end, selected, start)
|
||
blocker, blocking_delay = blocker_by_order.get(selected.arrival_order, (None, 0.0))
|
||
transmitted.append(
|
||
ScheduledPacket(selected, start, end, rate_kbps, blocker, blocking_delay)
|
||
)
|
||
cursor = end
|
||
if selected.packet.traffic_class is TrafficClass.VIDEO:
|
||
assert active is not None
|
||
active_index += 1
|
||
if active_index == len(active.packets):
|
||
frame_id = active.composite_frame_id
|
||
completed.append(frame_id)
|
||
publications[frame_id] = end
|
||
active = None
|
||
active_index = 0
|
||
|
||
digest = hashlib.sha256()
|
||
bit_error = 0.0
|
||
rate_rule_ok = True
|
||
compressed_video: list[int] = []
|
||
high_since_video = False
|
||
priority_between = False
|
||
last_video: int | None = None
|
||
for sent in transmitted:
|
||
digest.update(
|
||
f"{sent.item.arrival_order}:{sent.start_seconds:.12f}:{sent.end_seconds:.12f}:{sent.rate_kbps:.3f};".encode()
|
||
)
|
||
bit_error += abs(
|
||
(sent.end_seconds - sent.start_seconds) * sent.rate_kbps * 1000.0
|
||
- sent.item.wire_size_bytes * 8.0
|
||
)
|
||
rate_rule_ok &= sent.rate_kbps == profile.rate_kbps(sent.start_seconds)
|
||
frame_id = sent.item.composite_frame_id
|
||
if frame_id is None:
|
||
if last_video is not None:
|
||
high_since_video = True
|
||
else:
|
||
if frame_id == last_video and high_since_video:
|
||
priority_between = True
|
||
if not compressed_video or compressed_video[-1] != frame_id:
|
||
compressed_video.append(frame_id)
|
||
last_video = frame_id
|
||
high_since_video = False
|
||
for item in dropped:
|
||
digest.update(f"D{item.frame.composite_frame_id}:{item.time_seconds:.12f};".encode())
|
||
return LongSchedule(
|
||
profile,
|
||
policy,
|
||
tuple(transmitted),
|
||
tuple(dropped),
|
||
tuple(replacements),
|
||
tuple(started),
|
||
tuple(completed),
|
||
publications,
|
||
digest.hexdigest(),
|
||
bit_error,
|
||
rate_rule_ok,
|
||
len(compressed_video) == len(set(compressed_video)),
|
||
priority_between,
|
||
)
|
||
|
||
|
||
def _sample_events(
|
||
events: dict[float, tuple[int, int]],
|
||
sample_times: tuple[float, ...],
|
||
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
||
ordered = sorted(events.items())
|
||
index = 0
|
||
packets = 0
|
||
byte_count = 0
|
||
packet_values = []
|
||
byte_values = []
|
||
for sample in sample_times:
|
||
while index < len(ordered) and ordered[index][0] <= sample + TIME_EPSILON_SECONDS:
|
||
packets += ordered[index][1][0]
|
||
byte_count += ordered[index][1][1]
|
||
index += 1
|
||
packet_values.append(packets)
|
||
byte_values.append(byte_count)
|
||
return tuple(packet_values), tuple(byte_values)
|
||
|
||
|
||
def queue_statistics(
|
||
workload: LongWorkload,
|
||
schedule: LongSchedule,
|
||
) -> QueueStatistics:
|
||
intervals: list[tuple[float, float, int]] = []
|
||
first_start: dict[int, float] = {}
|
||
drop_time = {item.frame.composite_frame_id: item.time_seconds for item in schedule.dropped}
|
||
for sent in schedule.transmitted:
|
||
intervals.append((sent.item.available_time_seconds, sent.end_seconds, sent.item.wire_size_bytes))
|
||
if sent.item.composite_frame_id is not None:
|
||
first_start.setdefault(sent.item.composite_frame_id, sent.start_seconds)
|
||
for item in schedule.dropped:
|
||
intervals.extend(
|
||
(packet.available_time_seconds, item.time_seconds, packet.wire_size_bytes)
|
||
for packet in item.frame.packets
|
||
)
|
||
intervals.extend(
|
||
(item.removed.available_time_seconds, item.time_seconds, item.removed.wire_size_bytes)
|
||
for item in schedule.replacements
|
||
)
|
||
events: dict[float, tuple[int, int]] = {}
|
||
packet_area = 0.0
|
||
byte_area = 0.0
|
||
for start, end, size in intervals:
|
||
left = max(0.0, start)
|
||
right = min(DURATION_SECONDS, end)
|
||
if right <= left + TIME_EPSILON_SECONDS:
|
||
continue
|
||
packet_area += right - left
|
||
byte_area += (right - left) * size
|
||
old = events.get(start, (0, 0))
|
||
events[start] = (old[0] + 1, old[1] + size)
|
||
old = events.get(end, (0, 0))
|
||
events[end] = (old[0] - 1, old[1] - size)
|
||
count = 0
|
||
byte_count = 0
|
||
max_count = 0
|
||
max_bytes = 0
|
||
for time, delta in sorted(events.items()):
|
||
if time > DURATION_SECONDS + TIME_EPSILON_SECONDS:
|
||
break
|
||
count += delta[0]
|
||
byte_count += delta[1]
|
||
max_count = max(max_count, count)
|
||
max_bytes = max(max_bytes, byte_count)
|
||
# One largest frame plus coincident control, telemetry, and emergency is
|
||
# the normal 300-kbit/s envelope, not residual video backlog.
|
||
baseline_limit = max(len(frame.packets) for frame in workload.frames) + 3
|
||
count = 0
|
||
excess_active = False
|
||
excess_clear = 300.0
|
||
for time, delta in sorted(events.items()):
|
||
count += delta[0]
|
||
if time < 300.0 - TIME_EPSILON_SECONDS:
|
||
continue
|
||
if count > baseline_limit:
|
||
excess_active = True
|
||
elif excess_active:
|
||
excess_clear = time
|
||
excess_active = False
|
||
minute_times = tuple(float(value) for value in range(60, 601, 60))
|
||
minute_packets, minute_bytes = _sample_events(events, minute_times)
|
||
timeline_times = tuple(float(value) for value in range(0, 601, 5))
|
||
timeline_packets, timeline_bytes = _sample_events(events, timeline_times)
|
||
fit_times = np.asarray(minute_times[1:], dtype=float)
|
||
fit_bytes = np.asarray(minute_bytes[1:], dtype=float)
|
||
growth = float(np.polyfit(fit_times, fit_bytes, 1)[0]) if len(fit_times) > 1 else 0.0
|
||
|
||
frame_events: dict[float, int] = {}
|
||
frame_area = 0.0
|
||
for frame in workload.frames:
|
||
start = frame.generation_time_seconds
|
||
end = first_start.get(frame.composite_frame_id, drop_time.get(frame.composite_frame_id, start))
|
||
left = max(0.0, start)
|
||
right = min(DURATION_SECONDS, end)
|
||
if right <= left + TIME_EPSILON_SECONDS:
|
||
continue
|
||
frame_area += right - left
|
||
frame_events[left] = frame_events.get(left, 0) + 1
|
||
frame_events[right] = frame_events.get(right, 0) - 1
|
||
waiting = 0
|
||
max_waiting = 0
|
||
for _, delta in sorted(frame_events.items()):
|
||
waiting += delta
|
||
max_waiting = max(max_waiting, waiting)
|
||
published_by_end = sum(time <= DURATION_SECONDS + TIME_EPSILON_SECONDS for time in schedule.publication_times.values())
|
||
dropped_by_end = sum(item.time_seconds <= DURATION_SECONDS + TIME_EPSILON_SECONDS for item in schedule.dropped)
|
||
remaining_frames = FRAME_COUNT - published_by_end - dropped_by_end
|
||
finish = max(
|
||
[DURATION_SECONDS]
|
||
+ [item.end_seconds for item in schedule.transmitted]
|
||
+ [item.time_seconds for item in schedule.dropped]
|
||
+ [item.time_seconds for item in schedule.replacements]
|
||
)
|
||
return QueueStatistics(
|
||
packet_area / DURATION_SECONDS,
|
||
max_count,
|
||
byte_area / DURATION_SECONDS,
|
||
max_bytes,
|
||
frame_area / DURATION_SECONDS,
|
||
max_waiting,
|
||
minute_packets,
|
||
minute_bytes,
|
||
timeline_times,
|
||
timeline_packets,
|
||
timeline_bytes,
|
||
minute_packets[-1],
|
||
minute_bytes[-1],
|
||
remaining_frames,
|
||
max(0.0, finish - DURATION_SECONDS),
|
||
growth,
|
||
growth * 60.0,
|
||
max(0.0, excess_clear - 300.0),
|
||
)
|
||
|
||
|
||
def display_ages(
|
||
publications: dict[int, float],
|
||
sample_times: tuple[float, ...],
|
||
) -> tuple[float, ...]:
|
||
events = sorted((time, frame_id) for frame_id, time in publications.items())
|
||
ages = []
|
||
index = 0
|
||
last_frame: int | None = None
|
||
for sample in sample_times:
|
||
while index < len(events) and events[index][0] <= sample + TIME_EPSILON_SECONDS:
|
||
last_frame = events[index][1]
|
||
index += 1
|
||
generation = 0.0 if last_frame is None else last_frame / COMPOSITE_FPS
|
||
ages.append(max(0.0, sample - generation))
|
||
return tuple(ages)
|
||
|
||
|
||
def _missing_run(published: set[int]) -> int:
|
||
current = 0
|
||
maximum = 0
|
||
for frame_id in range(FRAME_COUNT):
|
||
if frame_id in published:
|
||
current = 0
|
||
else:
|
||
current += 1
|
||
maximum = max(maximum, current)
|
||
return maximum
|
||
|
||
|
||
def video_metrics(
|
||
workload: LongWorkload,
|
||
schedule: LongSchedule,
|
||
) -> tuple[VideoMetrics, tuple[float, ...], tuple[float, ...], tuple[float, ...]]:
|
||
publications = schedule.publication_times
|
||
published = set(publications)
|
||
within = sorted((time, frame_id) for frame_id, time in publications.items() if time <= DURATION_SECONDS + TIME_EPSILON_SECONDS)
|
||
minute_fps = tuple(
|
||
sum(left <= time < right for time, _ in within) / 60.0
|
||
for left, right in zip(range(0, 600, 60), range(60, 601, 60))
|
||
)
|
||
sample_times = tuple(float(value) / 10.0 for value in range(0, 6001))
|
||
ages = display_ages(publications, sample_times)
|
||
timeline_times = tuple(float(value) for value in range(0, 601))
|
||
timeline_ages = display_ages(publications, timeline_times)
|
||
publication_delays = [
|
||
time - frame_id / COMPOSITE_FPS for frame_id, time in publications.items()
|
||
]
|
||
update_times = [0.0] + [time for time, _ in within] + [DURATION_SECONDS]
|
||
no_update = [max(0.0, right - left) for left, right in zip(update_times, update_times[1:])]
|
||
gaps = [right[0] - left[0] for left, right in zip(within, within[1:])]
|
||
transmitted_video_bytes = sum(
|
||
item.item.wire_size_bytes
|
||
for item in schedule.transmitted
|
||
if item.item.packet.traffic_class is TrafficClass.VIDEO
|
||
)
|
||
useful_bytes = sum(workload.jpeg_bytes_by_frame[frame_id] for _, frame_id in within)
|
||
metrics = VideoMetrics(
|
||
schedule.profile.name,
|
||
schedule.policy.name,
|
||
FRAME_COUNT,
|
||
len(schedule.started_frame_ids),
|
||
len(published),
|
||
len(within),
|
||
len(schedule.dropped),
|
||
len(published) / FRAME_COUNT,
|
||
len(within) / DURATION_SECONDS,
|
||
";".join(f"{value:.6f}" for value in minute_fps),
|
||
float(np.mean(publication_delays)) * 1000.0,
|
||
percentile(publication_delays, 95) * 1000.0,
|
||
max(publication_delays, default=0.0) * 1000.0,
|
||
float(np.mean(ages)) * 1000.0,
|
||
percentile(ages, 95) * 1000.0,
|
||
max(ages, default=0.0) * 1000.0,
|
||
sum(value > 0.5 for value in ages) / len(ages),
|
||
sum(value > 1.0 for value in ages) / len(ages),
|
||
float(np.mean(no_update)) * 1000.0,
|
||
percentile(no_update, 95) * 1000.0,
|
||
max(no_update, default=0.0) * 1000.0,
|
||
_missing_run(published),
|
||
float(np.mean(gaps)) * 1000.0 if gaps else 0.0,
|
||
max(gaps, default=0.0) * 1000.0,
|
||
transmitted_video_bytes,
|
||
0,
|
||
useful_bytes * 8.0 / DURATION_SECONDS / 1000.0,
|
||
)
|
||
return metrics, minute_fps, timeline_times, tuple(value * 1000.0 for value in timeline_ages)
|
||
|
||
|
||
def control_metrics(schedule: LongSchedule) -> ControlMetrics:
|
||
by_class = {
|
||
traffic: [item for item in schedule.transmitted if item.item.packet.traffic_class is traffic]
|
||
for traffic in (TrafficClass.CONTROL, TrafficClass.EMERGENCY, TrafficClass.TELEMETRY)
|
||
}
|
||
control = by_class[TrafficClass.CONTROL]
|
||
control_delays = [item.end_seconds - item.item.frame_generation_seconds for item in control]
|
||
control_gaps = [right.end_seconds - left.end_seconds for left, right in zip(control, control[1:])]
|
||
telemetry_delays = [
|
||
item.end_seconds - item.item.frame_generation_seconds
|
||
for item in by_class[TrafficClass.TELEMETRY]
|
||
]
|
||
emergency = by_class[TrafficClass.EMERGENCY]
|
||
if len(emergency) != len(EMERGENCY_TIMES_US):
|
||
raise AssertionError("all four emergency commands must be transmitted")
|
||
emergency_delays = [item.end_seconds - item.item.frame_generation_seconds for item in emergency]
|
||
return ControlMetrics(
|
||
schedule.profile.name,
|
||
schedule.policy.name,
|
||
float(np.mean(control_delays)) * 1000.0,
|
||
percentile(control_delays, 95) * 1000.0,
|
||
max(control_delays) * 1000.0,
|
||
sum(value > 0.1 + TIME_EPSILON_SECONDS for value in control_delays),
|
||
max(control_gaps, default=0.0) * 1000.0,
|
||
";".join(f"{value * 1000.0:.6f}" for value in emergency_delays),
|
||
max(emergency_delays) * 1000.0,
|
||
all(value <= 0.05 + TIME_EPSILON_SECONDS for value in emergency_delays),
|
||
float(np.mean(telemetry_delays)) * 1000.0,
|
||
percentile(telemetry_delays, 95) * 1000.0,
|
||
max(telemetry_delays) * 1000.0,
|
||
sum(value > 0.5 + TIME_EPSILON_SECONDS for value in telemetry_delays),
|
||
)
|
||
|
||
|
||
def _age_at(publications: dict[int, float], time_seconds: float) -> float:
|
||
available = [(time, frame_id) for frame_id, time in publications.items() if time <= time_seconds + TIME_EPSILON_SECONDS]
|
||
if not available:
|
||
return time_seconds
|
||
_, frame_id = max(available)
|
||
return max(0.0, time_seconds - frame_id / COMPOSITE_FPS)
|
||
|
||
|
||
def _time_to_age(
|
||
publications: dict[int, float],
|
||
start_seconds: float,
|
||
threshold_seconds: float,
|
||
) -> float:
|
||
if _age_at(publications, start_seconds) < threshold_seconds:
|
||
return 0.0
|
||
for frame_id, time in sorted(publications.items(), key=lambda item: item[1]):
|
||
if time < start_seconds - TIME_EPSILON_SECONDS or time > DURATION_SECONDS + TIME_EPSILON_SECONDS:
|
||
continue
|
||
if time - frame_id / COMPOSITE_FPS < threshold_seconds:
|
||
return time - start_seconds
|
||
return -1.0
|
||
|
||
|
||
def recovery_metrics(schedule: LongSchedule, queue: QueueStatistics) -> RecoveryMetrics:
|
||
applicable = schedule.profile.kind == "step"
|
||
if not applicable:
|
||
return RecoveryMetrics(schedule.profile.name, schedule.policy.name, False, 0.0, 0.0, 0.0, 0.0, 0, 0)
|
||
dropped_after = sum(
|
||
item.frame.generation_time_seconds < 300.0
|
||
and item.time_seconds >= 300.0 - TIME_EPSILON_SECONDS
|
||
for item in schedule.dropped
|
||
)
|
||
old_published = sum(
|
||
frame_id / COMPOSITE_FPS < 300.0
|
||
and time >= 300.0 - TIME_EPSILON_SECONDS
|
||
for frame_id, time in schedule.publication_times.items()
|
||
)
|
||
return RecoveryMetrics(
|
||
schedule.profile.name,
|
||
schedule.policy.name,
|
||
True,
|
||
_age_at(schedule.publication_times, 300.0) * 1000.0,
|
||
_time_to_age(schedule.publication_times, 300.0, 1.0),
|
||
_time_to_age(schedule.publication_times, 300.0, 0.5),
|
||
queue.excess_queue_clear_time_seconds,
|
||
dropped_after,
|
||
old_published,
|
||
)
|
||
|
||
|
||
def analyze_schedule(workload: LongWorkload, schedule: LongSchedule) -> ScenarioResult:
|
||
queue = queue_statistics(workload, schedule)
|
||
video, minute_fps, age_times, age_values = video_metrics(workload, schedule)
|
||
control = control_metrics(schedule)
|
||
recovery = recovery_metrics(schedule, queue)
|
||
offered_kbps = workload.offered_wire_bytes * 8.0 / DURATION_SECONDS / 1000.0
|
||
video_kbps = workload.video_wire_bytes * 8.0 / DURATION_SECONDS / 1000.0
|
||
nonvideo_kbps = workload.nonvideo_wire_bytes * 8.0 / DURATION_SECONDS / 1000.0
|
||
mean_rate = schedule.profile.mean_rate_kbps
|
||
reserve = mean_rate - offered_kbps
|
||
remaining_video = max(0.0, mean_rate - nonvideo_kbps)
|
||
skip = max(0.0, 1.0 - remaining_video / video_kbps)
|
||
unbounded = (
|
||
schedule.policy.policy is FramePolicy.NO_DROP
|
||
and reserve < 0.0
|
||
and queue.growth_bytes_per_second > 0.0
|
||
)
|
||
transmitted_bytes = sum(item.item.wire_size_bytes for item in schedule.transmitted)
|
||
summary = SummaryMetrics(
|
||
schedule.profile.name,
|
||
schedule.policy.name,
|
||
DURATION_SECONDS,
|
||
mean_rate,
|
||
offered_kbps,
|
||
reserve,
|
||
max(0.0, -reserve) * 1000.0 / 8.0,
|
||
skip,
|
||
COMPOSITE_FPS * min(1.0, remaining_video / video_kbps),
|
||
queue.growth_bytes_per_second,
|
||
queue.growth_bytes_per_minute,
|
||
unbounded,
|
||
queue.mean_packets,
|
||
queue.max_packets,
|
||
queue.mean_bytes,
|
||
queue.max_bytes,
|
||
queue.mean_waiting_frames,
|
||
queue.max_waiting_frames,
|
||
";".join(str(value) for value in queue.minute_packets),
|
||
";".join(str(value) for value in queue.minute_bytes),
|
||
queue.remaining_packets,
|
||
queue.remaining_bytes,
|
||
queue.remaining_frames,
|
||
queue.drain_seconds,
|
||
len(schedule.transmitted),
|
||
transmitted_bytes,
|
||
len(schedule.replacements),
|
||
)
|
||
input_packets = sum(len(frame.packets) for frame in workload.frames) + len(workload.high_priority)
|
||
input_bytes = workload.offered_wire_bytes
|
||
dropped_packets = sum(len(item.frame.packets) for item in schedule.dropped)
|
||
dropped_bytes = sum(item.frame.wire_size_bytes for item in schedule.dropped)
|
||
replaced_packets = len(schedule.replacements)
|
||
replaced_bytes = sum(item.removed.wire_size_bytes for item in schedule.replacements)
|
||
accounting_ok = (
|
||
input_packets == len(schedule.transmitted) + dropped_packets + replaced_packets
|
||
and input_bytes == transmitted_bytes + dropped_bytes + replaced_bytes
|
||
and FRAME_COUNT == len(schedule.completed_frame_ids) + len(schedule.dropped)
|
||
)
|
||
started_complete = (
|
||
set(schedule.started_frame_ids) == set(schedule.completed_frame_ids)
|
||
and not set(schedule.started_frame_ids).intersection(
|
||
item.frame.composite_frame_id for item in schedule.dropped
|
||
)
|
||
)
|
||
return ScenarioResult(
|
||
summary,
|
||
video,
|
||
control,
|
||
recovery,
|
||
minute_fps,
|
||
age_times,
|
||
age_values,
|
||
queue.timeline_seconds,
|
||
queue.timeline_bytes,
|
||
schedule.digest,
|
||
accounting_ok,
|
||
started_complete,
|
||
schedule.frames_do_not_interleave,
|
||
schedule.high_priority_between_video_packets,
|
||
schedule.rate_rule_ok,
|
||
schedule.bit_accounting_error,
|
||
)
|
||
|
||
|
||
def run_experiment(workload: LongWorkload) -> tuple[ScenarioResult, ...]:
|
||
results = []
|
||
for profile in SPEED_PROFILES:
|
||
for policy in POLICIES:
|
||
results.append(analyze_schedule(workload, schedule_long(workload, profile, policy)))
|
||
print(f" {profile.name} / {policy.name}")
|
||
return tuple(results)
|
||
|
||
|
||
def run_functional_tests(
|
||
workload: LongWorkload,
|
||
results: tuple[ScenarioResult, ...],
|
||
) -> tuple[FunctionalTestResult, ...]:
|
||
lookup = {(item.summary.scenario, item.summary.policy): item for item in results}
|
||
checks: list[tuple[str, callable]] = []
|
||
|
||
def check(name):
|
||
def register(function):
|
||
checks.append((name, function))
|
||
return function
|
||
return register
|
||
|
||
@check("01_unique_repeated_frames_and_crc")
|
||
def _():
|
||
assert len({frame.composite_frame_id for frame in workload.frames}) == FRAME_COUNT
|
||
assert workload.repeated_headers_are_fresh
|
||
assert workload.crc_packets_checked > 0 and workload.crc_blocks_checked > 0
|
||
|
||
@check("02_300kbps_latest_has_no_unnecessary_drop")
|
||
def _():
|
||
assert lookup[("constant_300", "latest_only")].video.dropped_before_start_frames == 0
|
||
|
||
@check("03_260kbps_no_drop_matches_small_deficit")
|
||
def _():
|
||
row = lookup[("constant_260", "no_drop")].summary
|
||
assert row.theoretical_queue_growth_bytes_per_second > 0.0
|
||
assert abs(row.actual_queue_growth_bytes_per_second - row.theoretical_queue_growth_bytes_per_second) / row.theoretical_queue_growth_bytes_per_second < 0.45
|
||
|
||
@check("04_230kbps_no_drop_grows_faster")
|
||
def _():
|
||
slow = lookup[("constant_230", "no_drop")].summary.actual_queue_growth_bytes_per_second
|
||
marginal = lookup[("constant_260", "no_drop")].summary.actual_queue_growth_bytes_per_second
|
||
assert slow > marginal * 4.0
|
||
|
||
@check("05_latest_waiting_limit")
|
||
def _():
|
||
assert all(item.summary.max_waiting_video_frames <= 1 for item in results if item.summary.policy == "latest_only")
|
||
|
||
@check("06_two_waiting_limit")
|
||
def _():
|
||
assert all(item.summary.max_waiting_video_frames <= 2 for item in results if item.summary.policy == "two_waiting")
|
||
|
||
@check("07_started_frame_never_dropped")
|
||
def _():
|
||
assert all(item.started_frames_complete for item in results)
|
||
|
||
@check("08_active_video_frames_do_not_interleave")
|
||
def _():
|
||
assert all(item.no_interleave for item in results)
|
||
|
||
@check("09_priority_between_video_packets")
|
||
def _():
|
||
assert all(item.priority_between_video for item in results)
|
||
|
||
@check("10_emergency_never_deleted")
|
||
def _():
|
||
assert all(len(item.control.emergency_delays_ms.split(";")) == 4 for item in results)
|
||
|
||
@check("11_speed_change_bit_accounting")
|
||
def _():
|
||
assert all(item.rate_rule_ok and item.bit_accounting_error < 1e-3 for item in results)
|
||
|
||
@check("12_latest_does_not_replay_full_history")
|
||
def _():
|
||
assert lookup[("step_300_230_300", "latest_only")].recovery.old_frames_published_after_recovery <= 2
|
||
|
||
@check("13_proactive_policies_have_no_waste")
|
||
def _():
|
||
assert all(item.video.wasted_transmitted_video_bytes == 0 for item in results if item.summary.policy != "no_drop")
|
||
|
||
@check("14_incomplete_frame_not_published")
|
||
def _():
|
||
assert all(item.video.published_frames == item.video.started_frames for item in results)
|
||
|
||
@check("15_all_three_crc_layers_pass")
|
||
def _():
|
||
assert workload.crc_packets_checked == sum(len(frame.packets) for frame in workload.frames) + len(workload.high_priority)
|
||
|
||
@check("16_frame_accounting")
|
||
def _():
|
||
assert all(item.video.created_frames == item.video.published_frames + item.video.dropped_before_start_frames for item in results)
|
||
|
||
@check("17_packet_and_byte_accounting")
|
||
def _():
|
||
assert all(item.accounting_ok for item in results)
|
||
|
||
@check("18_reproducible")
|
||
def _():
|
||
repeated = schedule_long(
|
||
workload,
|
||
PROFILE_BY_NAME["constant_230"],
|
||
POLICY_BY_NAME["latest_only"],
|
||
)
|
||
assert repeated.digest == lookup[("constant_230", "latest_only")].digest
|
||
|
||
output = []
|
||
for name, function in checks:
|
||
try:
|
||
function()
|
||
output.append(FunctionalTestResult(name, True, "PASS"))
|
||
except Exception as error:
|
||
output.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}"))
|
||
failed = [item.name for item in output if not item.passed]
|
||
if failed:
|
||
raise AssertionError("functional checks failed: " + ", ".join(failed))
|
||
return tuple(output)
|
||
|
||
|
||
def save_csv(results: tuple[ScenarioResult, ...]) -> None:
|
||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||
for path, cls, rows in (
|
||
(SUMMARY_CSV_PATH, SummaryMetrics, (item.summary for item in results)),
|
||
(VIDEO_CSV_PATH, VideoMetrics, (item.video for item in results)),
|
||
(CONTROL_CSV_PATH, ControlMetrics, (item.control for item in results)),
|
||
(RECOVERY_CSV_PATH, RecoveryMetrics, (item.recovery for item in results)),
|
||
):
|
||
with path.open("w", encoding="utf-8", newline="") as file:
|
||
writer = csv.DictWriter(file, fieldnames=list(cls.__dataclass_fields__))
|
||
writer.writeheader()
|
||
writer.writerows(asdict(row) for row in rows)
|
||
|
||
|
||
def _step_results(results: tuple[ScenarioResult, ...]) -> list[ScenarioResult]:
|
||
return [item for item in results if item.summary.scenario == "step_300_230_300"]
|
||
|
||
|
||
def save_plots(results: tuple[ScenarioResult, ...]) -> None:
|
||
step = _step_results(results)
|
||
fig, axis = plt.subplots(figsize=(11, 5.5))
|
||
for item in step:
|
||
axis.plot(item.age_timeline_seconds, item.age_timeline_ms, label=POLICY_BY_NAME[item.summary.policy].label)
|
||
axis.axvline(120, color="black", ls="--", alpha=.4); axis.axvline(300, color="black", ls="--", alpha=.4)
|
||
axis.set(xlabel="Время, с", ylabel="Возраст, мс", title="Возраст изображения: ступенчатый сценарий")
|
||
axis.grid(alpha=.3); axis.legend(); fig.tight_layout(); fig.savefig(AGE_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
fig, axis = plt.subplots(figsize=(11, 5.5))
|
||
for item in step:
|
||
axis.plot(item.queue_timeline_seconds, np.asarray(item.queue_timeline_bytes) / 1_000_000.0, label=POLICY_BY_NAME[item.summary.policy].label)
|
||
axis.axvline(120, color="black", ls="--", alpha=.4); axis.axvline(300, color="black", ls="--", alpha=.4)
|
||
axis.set(xlabel="Время, с", ylabel="Очередь, Мбайт", title="Очередь во времени")
|
||
axis.grid(alpha=.3); axis.legend(); fig.tight_layout(); fig.savefig(QUEUE_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
variable = [item for item in results if item.summary.scenario == "variable_300_260_230"]
|
||
fig, axis = plt.subplots(figsize=(10, 5.2))
|
||
minutes = np.arange(1, 11)
|
||
for item in variable:
|
||
axis.plot(minutes, item.minute_update_fps, marker="o", label=POLICY_BY_NAME[item.summary.policy].label)
|
||
axis.set(xlabel="Минута", ylabel="Обновлений/с", title="Частота обновления по минутам")
|
||
axis.set_xticks(minutes); axis.grid(alpha=.3); axis.legend(); fig.tight_layout(); fig.savefig(MINUTE_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
fig, axis = plt.subplots(figsize=(10, 5.2))
|
||
for item in step:
|
||
mask = np.asarray(item.age_timeline_seconds) >= 280.0
|
||
axis.plot(np.asarray(item.age_timeline_seconds)[mask], np.asarray(item.age_timeline_ms)[mask], label=POLICY_BY_NAME[item.summary.policy].label)
|
||
axis.axhline(1000, color="orange", ls="--"); axis.axhline(500, color="green", ls=":"); axis.axvline(300, color="black", ls="--")
|
||
axis.set(xlabel="Время, с", ylabel="Возраст, мс", title="Восстановление после возврата к 300 кбит/с")
|
||
axis.grid(alpha=.3); axis.legend(); fig.tight_layout(); fig.savefig(RECOVERY_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
labels = [f"{PROFILE_BY_NAME[item.summary.scenario].label}\n{POLICY_BY_NAME[item.summary.policy].label}" for item in results]
|
||
x = np.arange(len(results))
|
||
fig, axis = plt.subplots(figsize=(16, 6))
|
||
axis.bar(x, [item.control.control_p95_delay_ms for item in results])
|
||
axis.set_xticks(x, labels, rotation=55, ha="right", fontsize=8)
|
||
axis.set(ylabel="P95, мс", title="Задержка обычных команд"); axis.grid(axis="y", alpha=.3)
|
||
fig.tight_layout(); fig.savefig(CONTROL_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
fig, axis = plt.subplots(figsize=(16, 6))
|
||
width = .38
|
||
axis.bar(x - width / 2, [item.video.published_frames for item in results], width, label="Опубликовано")
|
||
axis.bar(x + width / 2, [item.video.dropped_before_start_frames for item in results], width, label="Удалено")
|
||
axis.set_xticks(x, labels, rotation=55, ha="right", fontsize=8)
|
||
axis.set(ylabel="Кадров", title="Опубликованные и удалённые кадры"); axis.legend(); axis.grid(axis="y", alpha=.3)
|
||
fig.tight_layout(); fig.savefig(OUTCOME_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
constants = [item for item in results if item.summary.scenario.startswith("constant_") and item.summary.policy == "no_drop"]
|
||
rates = [item.summary.mean_channel_kbps for item in constants]
|
||
fig, axis = plt.subplots(figsize=(8.5, 5.2))
|
||
axis.plot(rates, [item.summary.theoretical_queue_growth_bytes_per_second for item in constants], marker="o", label="Теория")
|
||
axis.plot(rates, [max(0.0, item.summary.actual_queue_growth_bytes_per_second) for item in constants], marker="s", label="Модель")
|
||
axis.set(xlabel="Скорость, кбит/с", ylabel="Рост, байт/с", title="Теоретический и фактический рост очереди")
|
||
axis.grid(alpha=.3); axis.legend(); fig.tight_layout(); fig.savefig(GROWTH_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
fig, axis = plt.subplots(figsize=(9, 6))
|
||
for policy in POLICIES:
|
||
rows = [item for item in results if item.summary.policy == policy.name]
|
||
axis.scatter([item.video.actual_update_fps for item in rows], [item.video.p95_display_age_ms for item in rows], s=70, label=policy.label)
|
||
axis.set(xlabel="Обновлений/с", ylabel="P95 возраста, мс", title="Сравнение политик")
|
||
axis.grid(alpha=.3); axis.legend(); fig.tight_layout(); fig.savefig(COMPARISON_PLOT_PATH, dpi=150); plt.close(fig)
|
||
|
||
|
||
def write_report(
|
||
workload: LongWorkload,
|
||
results: tuple[ScenarioResult, ...],
|
||
tests: tuple[FunctionalTestResult, ...],
|
||
) -> None:
|
||
git_status = subprocess.run(
|
||
("git", "status", "--short", "--branch"),
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
).stdout.rstrip()
|
||
lines = [
|
||
"Lab036. Долговременная устойчивость видеопланировщика при изменении пропускной способности",
|
||
"",
|
||
"1. Исходное состояние и конфигурация",
|
||
f"- Commit Lab035: {LAB035_COMMIT}.",
|
||
"- Рабочее дерево перед Lab036 было чистым; ветка main.",
|
||
"- Модель использует 600 с, 1800 кадров, повтор реальной последовательности размеров 63 кадров, BASE 240×135 Q23, ROI 320×180 Q33 и 3 составных кадра/с.",
|
||
"- Lab036 использует ровно 3 кадра/с в течение 600 с; короткий ролик Lab034 и предшествующих лабораторных давал немного отличающуюся фактическую частоту формирования кадров.",
|
||
"- Длинный опыт уменьшает влияние начального и конечного участков, поэтому значения общей предложенной нагрузки Lab034 и Lab036 не обязаны побайтно совпадать.",
|
||
"- Каждый повтор получает новые composite_frame_id, времена, заголовки и CRC; JPEG и пакеты на диск не записываются.",
|
||
"- Используются внутренний пакет Lab028 с payload 512 байт, FEC 12+3 с выравниванием по кадрам и общий пакет Lab033.",
|
||
"- Команды 20 Гц, телеметрия 10 Гц; аварийные команды создаются в 60, 180, 310 и 480 с.",
|
||
"- Ошибки радиоканала отсутствуют.",
|
||
"- Смена скорости не прерывает начатый пакет: новая скорость применяется только к следующему пакету после завершения текущего.",
|
||
"",
|
||
"2. Пятнадцать сочетаний",
|
||
"scenario | policy | published/drop | fps | age P95 ms | queue max packets | queue at 600 packets | drain s | control P95 ms | emergency max ms",
|
||
]
|
||
for item in results:
|
||
s, v, c = item.summary, item.video, item.control
|
||
lines.append(
|
||
f"{s.scenario} | {s.policy} | {v.published_frames}/{v.dropped_before_start_frames} | {v.actual_update_fps:.3f} | {v.p95_display_age_ms:.3f} | {s.max_queue_packets} | {s.queue_at_600s_packets} | {s.additional_drain_seconds:.3f} | {c.control_p95_delay_ms:.3f} | {c.emergency_max_delay_ms:.3f}"
|
||
)
|
||
lines.extend(["", "3. Постоянные скорости: теория и модель", "rate | policy | reserve kbps | theory growth B/s | model growth B/s | minimum skip | stable fps | minute queue packets"])
|
||
for item in results:
|
||
if not item.summary.scenario.startswith("constant_"):
|
||
continue
|
||
s = item.summary
|
||
lines.append(
|
||
f"{s.mean_channel_kbps:.0f} | {s.policy} | {s.speed_reserve_kbps:.3f} | {s.theoretical_queue_growth_bytes_per_second:.3f} | {s.actual_queue_growth_bytes_per_second:.3f} | {s.theoretical_minimum_skip_fraction:.6f} | {s.theoretical_sustainable_fps:.3f} | {s.queue_packets_each_minute}"
|
||
)
|
||
lines.extend(["", "4. Восстановление после ухудшения", "policy | age at 300 ms | below 1000 s | below 500 s | excess queue clear s | dropped old | published old"])
|
||
for item in _step_results(results):
|
||
r = item.recovery
|
||
lines.append(
|
||
f"{r.policy} | {r.image_age_at_300s_ms:.3f} | {r.time_to_age_below_1000ms_seconds:.3f} | {r.time_to_age_below_500ms_seconds:.3f} | {r.excess_queue_clear_seconds:.3f} | {r.frames_dropped_after_recovery} | {r.old_frames_published_after_recovery}"
|
||
)
|
||
lines.extend(["", "5. Видео, очередь, команды и телеметрия"])
|
||
latest_rows = [item for item in results if item.summary.policy == "latest_only"]
|
||
lines.extend(
|
||
f"- {item.summary.scenario}: fps={item.video.actual_update_fps:.3f}, age P95={item.video.p95_display_age_ms:.3f} мс, queue max={item.summary.max_queue_packets}, waiting max={item.summary.max_waiting_video_frames}, control P95/max={item.control.control_p95_delay_ms:.3f}/{item.control.control_max_delay_ms:.3f} мс, emergency={item.control.emergency_delays_ms} мс, telemetry P95={item.control.telemetry_p95_delay_ms:.3f} мс."
|
||
for item in latest_rows
|
||
)
|
||
lines.extend([
|
||
"- Политика самого свежего кадра ограничивает ожидающую видеоочередь одним кадром и не передаёт накопленную историю после восстановления.",
|
||
"- Политика двух ожидающих кадров ограничивает очередь двумя кадрами, но увеличивает возраст изображения относительно основной политики.",
|
||
"- Без удаления при дефиците скорости очередь растёт; при 300 кбит/с запас достаточен.",
|
||
"- У упреждающих политик нет бесполезно переданных видеобайтов: начатый кадр всегда завершается.",
|
||
"",
|
||
"6. Функциональные проверки",
|
||
])
|
||
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
|
||
lines.extend([
|
||
f"- CRC-проверено общих пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}.",
|
||
"",
|
||
"7. Созданные файлы",
|
||
"- tests/lab036_long_duration_scheduler.py",
|
||
"- data/processed/lab036/lab036_summary.csv",
|
||
"- data/processed/lab036/lab036_video_metrics.csv",
|
||
"- data/processed/lab036/lab036_control_metrics.csv",
|
||
"- data/processed/lab036/lab036_recovery_metrics.csv",
|
||
"- data/processed/lab036/lab036_report.txt",
|
||
])
|
||
lines.extend(f"- {path.as_posix()}" for path in PLOT_PATHS)
|
||
lines.extend(["", "8. Итоговый Git status", "- Lab036 не добавлена в индекс и не закоммичена.", "", git_status])
|
||
REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
||
|
||
def validate_outputs() -> None:
|
||
for path in (SUMMARY_CSV_PATH, VIDEO_CSV_PATH, CONTROL_CSV_PATH, RECOVERY_CSV_PATH):
|
||
with path.open(encoding="utf-8", newline="") as file:
|
||
rows = list(csv.DictReader(file))
|
||
if len(rows) != 15:
|
||
raise AssertionError(f"{path} must contain 15 result rows")
|
||
if "Lab036" not in REPORT_PATH.read_text(encoding="utf-8"):
|
||
raise AssertionError("invalid Lab036 report")
|
||
for path in PLOT_PATHS:
|
||
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||
if image is None or image.size == 0:
|
||
raise AssertionError(f"OpenCV could not read {path}")
|
||
|
||
|
||
def main() -> None:
|
||
print("Lab036: building 600-second workload")
|
||
workload = build_long_workload()
|
||
print("Lab036: running 15 combinations")
|
||
results = run_experiment(workload)
|
||
tests = run_functional_tests(workload, results)
|
||
save_csv(results)
|
||
save_plots(results)
|
||
write_report(workload, results, tests)
|
||
validate_outputs()
|
||
print(f"Lab036 complete: {len(results)} combinations, {len(tests)} checks")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|