Lab035: add proactive video frame admission
This commit is contained in:
748
tests/lab035_video_frame_admission.py
Normal file
748
tests/lab035_video_frame_admission.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""Lab035: predictive admission and whole-frame video service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import asdict, dataclass
|
||||
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 HEADER_SIZE, TrafficClass, decode_link_packet
|
||||
from protocol.packet_erasure_fec import decode_fec_block, decode_outer_symbol
|
||||
from protocol.video_frame_scheduler import (
|
||||
FramePolicy,
|
||||
FrameScheduleResult,
|
||||
VideoFrameGroup,
|
||||
predict_frame_completion,
|
||||
schedule_video_frames,
|
||||
)
|
||||
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS
|
||||
from tests.lab034_stale_video_drop import (
|
||||
PolicyDefinition as Lab034Policy,
|
||||
build_aligned_workload,
|
||||
build_lab033_workload,
|
||||
percentile,
|
||||
simulate_aligned,
|
||||
)
|
||||
|
||||
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab035")
|
||||
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab035_summary.csv"
|
||||
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab035_video_metrics.csv"
|
||||
CONTROL_CSV_PATH = OUTPUT_DIRECTORY / "lab035_control_metrics.csv"
|
||||
PREDICTION_CSV_PATH = OUTPUT_DIRECTORY / "lab035_prediction_metrics.csv"
|
||||
REPORT_PATH = OUTPUT_DIRECTORY / "lab035_report.txt"
|
||||
UPDATE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_update_rate.png"
|
||||
AGE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_image_age.png"
|
||||
PUBLICATION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_publication_delay.png"
|
||||
OUTCOME_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_frame_outcomes.png"
|
||||
QUEUE_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_queue_size.png"
|
||||
PREDICTION_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_prediction_accuracy.png"
|
||||
CONTROL_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_control_delay.png"
|
||||
COMPARISON_PLOT_PATH = OUTPUT_DIRECTORY / "lab035_policy_comparison.png"
|
||||
PLOT_PATHS = (
|
||||
UPDATE_PLOT_PATH,
|
||||
AGE_PLOT_PATH,
|
||||
PUBLICATION_PLOT_PATH,
|
||||
OUTCOME_PLOT_PATH,
|
||||
QUEUE_PLOT_PATH,
|
||||
PREDICTION_PLOT_PATH,
|
||||
CONTROL_PLOT_PATH,
|
||||
COMPARISON_PLOT_PATH,
|
||||
)
|
||||
LAB034_COMMIT = "b63e36abdb7031d642de8b8138b43cc29e94b759"
|
||||
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
|
||||
TIME_EPSILON_SECONDS = 1e-9
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDefinition:
|
||||
name: str
|
||||
label: str
|
||||
scheduler_policy: FramePolicy | None
|
||||
reactive: bool = False
|
||||
|
||||
|
||||
POLICIES = (
|
||||
PolicyDefinition("no_drop", "Без удаления", FramePolicy.NO_DROP),
|
||||
PolicyDefinition("reactive_1500ms", "Реактивная 1500 мс", None, True),
|
||||
PolicyDefinition("latest_only", "Самый свежий", FramePolicy.LATEST_ONLY),
|
||||
PolicyDefinition("two_waiting", "Два ожидающих", FramePolicy.TWO_WAITING),
|
||||
PolicyDefinition("predict_1000ms", "Прогноз 1000 мс", FramePolicy.PREDICT_1000MS),
|
||||
PolicyDefinition("predict_500ms", "Прогноз 500 мс", FramePolicy.PREDICT_500MS),
|
||||
)
|
||||
POLICY_BY_NAME = {policy.name: policy for policy in POLICIES}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TheoreticalCapacity:
|
||||
channel_kbps: float
|
||||
nonvideo_load_kbps: float
|
||||
remaining_video_kbps: float
|
||||
video_capacity_ratio: float
|
||||
minimum_skip_fraction: float
|
||||
maximum_update_fps: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SummaryMetrics:
|
||||
channel_kbps: float
|
||||
policy: str
|
||||
offered_load_kbps: float
|
||||
offered_to_capacity_ratio: float
|
||||
transmitted_packets: int
|
||||
transmitted_bytes: int
|
||||
dropped_before_start_packets: int
|
||||
dropped_before_start_bytes: int
|
||||
wasted_transmitted_bytes: int
|
||||
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_at_source_end_packets: int
|
||||
additional_drain_seconds: float
|
||||
remaining_video_capacity_kbps: float
|
||||
theoretical_minimum_skip_fraction: float
|
||||
theoretical_maximum_update_fps: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMetrics:
|
||||
channel_kbps: float
|
||||
policy: str
|
||||
created_frames: int
|
||||
started_frames: int
|
||||
published_frames: int
|
||||
dropped_before_start_frames: int
|
||||
partially_transmitted_cancelled_frames: int
|
||||
published_fraction: float
|
||||
actual_update_fps: float
|
||||
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
|
||||
mean_missing_run_frames: float
|
||||
p95_missing_run_frames: float
|
||||
max_missing_run_frames: int
|
||||
mean_publication_gap_ms: float
|
||||
max_publication_gap_ms: float
|
||||
transmitted_video_bytes: int
|
||||
dropped_before_start_video_bytes: int
|
||||
wasted_transmitted_video_bytes: int
|
||||
delivered_useful_video_kbps: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlMetrics:
|
||||
channel_kbps: float
|
||||
policy: str
|
||||
control_p95_delay_ms: float
|
||||
control_max_delay_ms: float
|
||||
control_deadline_misses: int
|
||||
control_max_receive_gap_ms: float
|
||||
emergency_delay_ms: float
|
||||
emergency_deadline_met: bool
|
||||
emergency_blocker_class: str
|
||||
emergency_blocking_delay_ms: float
|
||||
telemetry_deadline_misses: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PredictionMetrics:
|
||||
channel_kbps: float
|
||||
policy: str
|
||||
admitted_frames: int
|
||||
prediction_rejected_frames: int
|
||||
mean_absolute_error_ms: float
|
||||
p95_absolute_error_ms: float
|
||||
max_absolute_error_ms: float
|
||||
published_after_deadline_frames: int
|
||||
false_rejections: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioResult:
|
||||
summary: SummaryMetrics
|
||||
video: VideoMetrics
|
||||
control: ControlMetrics
|
||||
prediction: PredictionMetrics
|
||||
publication_times: dict[int, float]
|
||||
schedule: FrameScheduleResult | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FunctionalTestResult:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def build_frame_groups(aligned) -> tuple[VideoFrameGroup, ...]:
|
||||
grouped: dict[int, list] = {}
|
||||
for item in aligned.packets:
|
||||
if item.composite_frame_id is not None:
|
||||
grouped.setdefault(item.composite_frame_id, []).append(item)
|
||||
return tuple(
|
||||
VideoFrameGroup(
|
||||
composite_frame_id=frame_id,
|
||||
generation_time_us=packets[0].packet.generation_time_us,
|
||||
packets=tuple(sorted(packets, key=lambda item: item.packet.sequence_number)),
|
||||
)
|
||||
for frame_id, packets in sorted(grouped.items())
|
||||
)
|
||||
|
||||
|
||||
def high_priority_packets(aligned) -> tuple:
|
||||
return tuple(
|
||||
item for item in aligned.packets
|
||||
if item.packet.traffic_class is not TrafficClass.VIDEO
|
||||
)
|
||||
|
||||
|
||||
def theoretical_capacity(aligned, rate: float) -> TheoreticalCapacity:
|
||||
duration = aligned.lab033.metadata.duration_seconds
|
||||
nonvideo_bytes = sum(
|
||||
item.wire_size_bytes for item in aligned.packets
|
||||
if item.packet.traffic_class is not TrafficClass.VIDEO
|
||||
)
|
||||
nonvideo_kbps = nonvideo_bytes * 8.0 / duration / 1000.0
|
||||
video_kbps = aligned.layout_metrics.after_link_kbps
|
||||
remaining = max(0.0, rate - nonvideo_kbps)
|
||||
ratio = remaining / video_kbps
|
||||
skip = max(0.0, 1.0 - ratio)
|
||||
return TheoreticalCapacity(
|
||||
rate,
|
||||
nonvideo_kbps,
|
||||
remaining,
|
||||
ratio,
|
||||
skip,
|
||||
COMPOSITE_FPS * min(1.0, ratio),
|
||||
)
|
||||
|
||||
|
||||
def receive_whole_frames(schedule: FrameScheduleResult) -> dict[int, float]:
|
||||
dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
|
||||
receiver = CompositeReassembler()
|
||||
publication_times: dict[int, float] = {}
|
||||
symbols_by_block: dict[int, list[bytes]] = {}
|
||||
decoded_blocks: set[int] = set()
|
||||
for sent in schedule.transmitted:
|
||||
link = decode_link_packet(sent.wire_packet)
|
||||
if link.traffic_class is not TrafficClass.VIDEO:
|
||||
continue
|
||||
frame_id = sent.item.composite_frame_id
|
||||
assert frame_id is not None and frame_id not in dropped
|
||||
outer = decode_outer_symbol(link.payload)
|
||||
symbols = symbols_by_block.setdefault(outer.block_id, [])
|
||||
symbols.append(link.payload)
|
||||
if not outer.is_parity:
|
||||
completed = receiver.ingest(outer.data)
|
||||
if completed is not None:
|
||||
publication_times[completed.composite_frame_id] = sent.end_seconds
|
||||
if outer.block_id not in decoded_blocks and len(symbols) >= outer.source_count:
|
||||
decoded = decode_fec_block(tuple(symbols))
|
||||
for inner in decoded.source_packets:
|
||||
decode_inner_packet(inner)
|
||||
decoded_blocks.add(outer.block_id)
|
||||
if set(publication_times) != set(schedule.completed_frame_ids):
|
||||
raise AssertionError("published frames differ from completed whole frames")
|
||||
return publication_times
|
||||
|
||||
|
||||
def display_and_gap_metrics(publications: dict[int, float], source_end: float):
|
||||
events = sorted((time, frame) for frame, time in publications.items() if time <= source_end + TIME_EPSILON_SECONDS)
|
||||
samples = np.arange(0.0, source_end + 0.005, 0.01)
|
||||
ages = []
|
||||
index = 0
|
||||
last_frame = None
|
||||
for time in samples:
|
||||
while index < len(events) and events[index][0] <= time:
|
||||
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, time - generation))
|
||||
update_times = [0.0] + [time for time, _ in events] + [source_end]
|
||||
no_update = tuple(max(0.0, right - left) for left, right in zip(update_times, update_times[1:]))
|
||||
publication_gaps = tuple(right[0] - left[0] for left, right in zip(events, events[1:]))
|
||||
return ages, no_update, publication_gaps
|
||||
|
||||
|
||||
def missing_runs(frame_count: int, published: set[int]) -> tuple[int, ...]:
|
||||
runs = []
|
||||
current = 0
|
||||
for frame_id in range(frame_count):
|
||||
if frame_id not in published:
|
||||
current += 1
|
||||
elif current:
|
||||
runs.append(current); current = 0
|
||||
if current:
|
||||
runs.append(current)
|
||||
return tuple(runs)
|
||||
|
||||
|
||||
def queue_metrics(schedule: FrameScheduleResult, frames, source_end: float):
|
||||
intervals = []
|
||||
first_start: dict[int, float] = {}
|
||||
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)
|
||||
drop_time = {item.frame.composite_frame_id: item.drop_time_seconds for item in schedule.dropped_frames}
|
||||
for item in schedule.dropped_frames:
|
||||
for packet in item.frame.packets:
|
||||
intervals.append((packet.available_time_seconds, item.drop_time_seconds, packet.wire_size_bytes))
|
||||
for item in schedule.replacements:
|
||||
intervals.append((item.removed.available_time_seconds, item.time_seconds, item.removed.wire_size_bytes))
|
||||
packet_area = byte_area = 0.0
|
||||
events: dict[float, list[int]] = {}
|
||||
remaining = 0
|
||||
for start, end, size in intervals:
|
||||
if start <= source_end + TIME_EPSILON_SECONDS < end - TIME_EPSILON_SECONDS:
|
||||
remaining += 1
|
||||
left, right = max(0.0, start), min(source_end, end)
|
||||
if right <= left + TIME_EPSILON_SECONDS:
|
||||
continue
|
||||
packet_area += right - left; byte_area += (right - left) * size
|
||||
events.setdefault(left, [0, 0])[0] += 1; events[left][1] += size
|
||||
events.setdefault(right, [0, 0])[0] -= 1; events[right][1] -= size
|
||||
count = size_now = max_count = max_size = 0
|
||||
for time in sorted(events):
|
||||
count += events[time][0]; size_now += events[time][1]
|
||||
max_count = max(max_count, count); max_size = max(max_size, size_now)
|
||||
frame_intervals = []
|
||||
for frame in frames:
|
||||
end = first_start.get(frame.composite_frame_id, drop_time.get(frame.composite_frame_id, source_end))
|
||||
frame_intervals.append((frame.generation_time_seconds, end))
|
||||
frame_area = 0.0; frame_events: dict[float, int] = {}
|
||||
for start, end in frame_intervals:
|
||||
left, right = max(0.0, start), min(source_end, 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 = max_waiting = 0
|
||||
for time in sorted(frame_events):
|
||||
waiting += frame_events[time]; max_waiting = max(max_waiting, waiting)
|
||||
finish = max([source_end] + [item.end_seconds for item in schedule.transmitted] + [item.drop_time_seconds for item in schedule.dropped_frames])
|
||||
return (
|
||||
packet_area / source_end, max_count, byte_area / source_end, max_size,
|
||||
frame_area / source_end, max_waiting, remaining, max(0.0, finish - source_end),
|
||||
)
|
||||
|
||||
|
||||
def control_metrics(schedule: FrameScheduleResult, rate: float, policy: str) -> 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.packet.generation_time_us / 1_000_000.0 for item in control]
|
||||
gaps = [right.end_seconds - left.end_seconds for left, right in zip(control, control[1:])]
|
||||
telemetry_delays = [item.end_seconds - item.item.packet.generation_time_us / 1_000_000.0 for item in by_class[TrafficClass.TELEMETRY]]
|
||||
emergency = by_class[TrafficClass.EMERGENCY]
|
||||
if len(emergency) != 1: raise AssertionError("exactly one emergency command is required")
|
||||
urgent = emergency[0]
|
||||
urgent_delay = urgent.end_seconds - urgent.item.packet.generation_time_us / 1_000_000.0
|
||||
return ControlMetrics(
|
||||
rate, policy,
|
||||
percentile(control_delays, 95) * 1000.0,
|
||||
max(control_delays) * 1000.0,
|
||||
sum(delay > 0.1 + TIME_EPSILON_SECONDS for delay in control_delays),
|
||||
max(gaps, default=0.0) * 1000.0,
|
||||
urgent_delay * 1000.0,
|
||||
urgent_delay <= 0.05 + TIME_EPSILON_SECONDS,
|
||||
urgent.blocked_by.packet.traffic_class.name.lower() if urgent.blocked_by else "none",
|
||||
urgent.blocking_delay_seconds * 1000.0,
|
||||
sum(delay > 0.5 + TIME_EPSILON_SECONDS for delay in telemetry_delays),
|
||||
)
|
||||
|
||||
|
||||
def video_metrics(aligned, schedule, publications, rate, policy):
|
||||
source_end = aligned.lab033.metadata.duration_seconds
|
||||
published = set(publications)
|
||||
dropped = {item.frame.composite_frame_id for item in schedule.dropped_frames}
|
||||
delays = [publications[frame] - frame / COMPOSITE_FPS for frame in sorted(published)]
|
||||
ages, no_update, publication_gaps = display_and_gap_metrics(publications, source_end)
|
||||
runs = missing_runs(len(aligned.lab033.composites), published)
|
||||
transmitted_video_bytes = sum(
|
||||
len(item.wire_packet) for item in schedule.transmitted
|
||||
if item.item.packet.traffic_class is TrafficClass.VIDEO
|
||||
)
|
||||
dropped_bytes = sum(item.frame.wire_size_bytes for item in schedule.dropped_frames)
|
||||
useful = sum(
|
||||
len(aligned.lab033.composites[frame].base_jpeg) + len(aligned.lab033.composites[frame].roi_jpeg)
|
||||
for frame in published
|
||||
)
|
||||
return VideoMetrics(
|
||||
rate, policy, len(aligned.lab033.composites), len(schedule.started_frame_ids),
|
||||
len(published), len(dropped), 0, len(published) / len(aligned.lab033.composites),
|
||||
sum(time <= source_end + TIME_EPSILON_SECONDS for time in publications.values()) / source_end,
|
||||
float(np.mean(delays)) * 1000.0 if delays else 0.0,
|
||||
percentile(delays, 95) * 1000.0, max(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(age > 0.5 for age in ages) / len(ages),
|
||||
sum(age > 1.0 for age 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,
|
||||
float(np.mean(runs)) if runs else 0.0, percentile(runs, 95), max(runs, default=0),
|
||||
float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
|
||||
max(publication_gaps, default=0.0) * 1000.0,
|
||||
transmitted_video_bytes, dropped_bytes, 0,
|
||||
useful * 8.0 / source_end / 1000.0,
|
||||
)
|
||||
|
||||
|
||||
def prediction_metrics(schedule, rate, policy):
|
||||
errors = [abs(item.prediction_error_seconds) for item in schedule.admissions]
|
||||
deadline = schedule.policy.deadline_seconds
|
||||
rejected = [item for item in schedule.dropped_frames if item.reason == "prediction_reject"]
|
||||
late = sum(
|
||||
item.actual_completion_seconds - item.composite_frame_id / COMPOSITE_FPS
|
||||
> deadline + TIME_EPSILON_SECONDS
|
||||
for item in schedule.admissions
|
||||
) if deadline is not None else 0
|
||||
return PredictionMetrics(
|
||||
rate, policy, len(schedule.admissions), len(rejected),
|
||||
float(np.mean(errors)) * 1000.0 if errors else 0.0,
|
||||
percentile(errors, 95) * 1000.0, max(errors, default=0.0) * 1000.0,
|
||||
late, 0,
|
||||
)
|
||||
|
||||
|
||||
def whole_frame_result(aligned, frames, high, rate, policy_def):
|
||||
schedule = schedule_video_frames(frames, high, policy_def.scheduler_policy, rate * 1000.0)
|
||||
publications = receive_whole_frames(schedule)
|
||||
video = video_metrics(aligned, schedule, publications, rate, policy_def.name)
|
||||
control = control_metrics(schedule, rate, policy_def.name)
|
||||
prediction = prediction_metrics(schedule, rate, policy_def.name)
|
||||
source_end = aligned.lab033.metadata.duration_seconds
|
||||
qp, qmax, qb, qbmax, fq, fqmax, remaining, drain = queue_metrics(schedule, frames, source_end)
|
||||
capacity = theoretical_capacity(aligned, rate)
|
||||
offered_bytes = sum(item.wire_size_bytes for item in aligned.packets)
|
||||
summary = SummaryMetrics(
|
||||
rate, policy_def.name,
|
||||
offered_bytes * 8.0 / source_end / 1000.0,
|
||||
offered_bytes * 8.0 / source_end / (rate * 1000.0),
|
||||
len(schedule.transmitted), sum(len(item.wire_packet) for item in schedule.transmitted),
|
||||
sum(len(item.frame.packets) for item in schedule.dropped_frames),
|
||||
sum(item.frame.wire_size_bytes for item in schedule.dropped_frames), 0,
|
||||
qp, qmax, qb, qbmax, fq, fqmax, remaining, drain,
|
||||
capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
|
||||
capacity.maximum_update_fps,
|
||||
)
|
||||
return ScenarioResult(summary, video, control, prediction, publications, schedule)
|
||||
|
||||
|
||||
def reactive_result(aligned, rate):
|
||||
old_policy = Lab034Policy("aligned_1500ms", "По кадрам, 1500 мс", "aligned", 1500)
|
||||
old = simulate_aligned(aligned, rate, old_policy)
|
||||
capacity = theoretical_capacity(aligned, rate)
|
||||
s, v, c = old.summary, old.video, old.control
|
||||
source_end = aligned.lab033.metadata.duration_seconds
|
||||
dropped_frames = set(old.dropped_frames)
|
||||
partial_frames = {
|
||||
frame_id for frame_id in dropped_frames
|
||||
if old.transmitted_video_by_frame.get(frame_id, 0) > 0
|
||||
}
|
||||
before_start_frames = dropped_frames - partial_frames
|
||||
dropped_before_packets = [
|
||||
item for item in old.schedule.dropped_video
|
||||
if item.item.composite_frame_id in before_start_frames
|
||||
]
|
||||
first_start = {}
|
||||
for item in old.schedule.transmitted:
|
||||
if item.item.composite_frame_id is not None:
|
||||
first_start.setdefault(item.item.composite_frame_id, item.start_seconds)
|
||||
frame_drop_time = {}
|
||||
for item in old.schedule.dropped_video:
|
||||
assert item.item.composite_frame_id is not None
|
||||
frame_drop_time.setdefault(item.item.composite_frame_id, item.drop_time_seconds)
|
||||
frame_events = {}
|
||||
frame_area = 0.0
|
||||
for frame_id in range(len(aligned.lab033.composites)):
|
||||
start = frame_id / COMPOSITE_FPS
|
||||
end = first_start.get(frame_id, frame_drop_time.get(frame_id, start))
|
||||
left, right = max(0.0, start), min(source_end, 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 = max_waiting = 0
|
||||
for time in sorted(frame_events):
|
||||
waiting += frame_events[time]
|
||||
max_waiting = max(max_waiting, waiting)
|
||||
publication_events = sorted(old.publication_times.values())
|
||||
publication_gaps = [
|
||||
right - left for left, right in zip(publication_events, publication_events[1:])
|
||||
]
|
||||
summary = SummaryMetrics(
|
||||
rate, "reactive_1500ms", s.offered_load_kbps, s.offered_to_capacity_ratio,
|
||||
s.transmitted_packets, s.transmitted_bytes,
|
||||
len(dropped_before_packets),
|
||||
sum(item.item.wire_size_bytes for item in dropped_before_packets),
|
||||
s.wasted_transmitted_bytes, s.mean_queue_packets, s.max_queue_packets,
|
||||
s.mean_queue_bytes, s.max_queue_bytes,
|
||||
frame_area / source_end, max_waiting,
|
||||
s.queue_at_source_end_packets, s.additional_drain_seconds,
|
||||
capacity.remaining_video_kbps, capacity.minimum_skip_fraction,
|
||||
capacity.maximum_update_fps,
|
||||
)
|
||||
video = VideoMetrics(
|
||||
rate, "reactive_1500ms", v.created_frames,
|
||||
v.published_frames + v.partially_transmitted_cancelled_frames,
|
||||
v.published_frames,
|
||||
v.intentionally_dropped_frames - v.partially_transmitted_cancelled_frames,
|
||||
v.partially_transmitted_cancelled_frames,
|
||||
v.published_fraction, v.actual_update_fps,
|
||||
v.mean_publication_delay_ms, v.p95_publication_delay_ms, v.max_publication_delay_ms,
|
||||
v.mean_display_age_ms, v.p95_display_age_ms, v.max_display_age_ms,
|
||||
v.display_age_over_500ms_fraction, v.display_age_over_1000ms_fraction,
|
||||
v.mean_no_update_duration_ms, v.p95_no_update_duration_ms, v.max_no_update_duration_ms,
|
||||
v.mean_missing_run_frames, v.p95_missing_run_frames, v.max_missing_run_frames,
|
||||
float(np.mean(publication_gaps)) * 1000.0 if publication_gaps else 0.0,
|
||||
max(publication_gaps, default=0.0) * 1000.0,
|
||||
sum(len(item.wire_packet) for item in old.schedule.transmitted if item.item.packet.traffic_class is TrafficClass.VIDEO),
|
||||
0, v.wasted_transmitted_video_bytes, v.delivered_useful_video_kbps,
|
||||
)
|
||||
control = ControlMetrics(
|
||||
rate, "reactive_1500ms", c.control_p95_age_ms, c.control_max_age_ms,
|
||||
c.control_deadline_misses, c.control_max_receive_gap_ms,
|
||||
c.emergency_total_delay_ms, c.emergency_deadline_met,
|
||||
c.emergency_blocker_class, c.emergency_blocking_delay_ms,
|
||||
c.telemetry_deadline_misses,
|
||||
)
|
||||
prediction = PredictionMetrics(rate, "reactive_1500ms", 0, 0, 0.0, 0.0, 0.0, 0, 0)
|
||||
return ScenarioResult(summary, video, control, prediction, old.publication_times, None)
|
||||
|
||||
|
||||
def run_experiment(aligned, frames, high):
|
||||
results = []
|
||||
for rate in CHANNEL_RATES_KBPS:
|
||||
for policy in POLICIES:
|
||||
results.append(
|
||||
reactive_result(aligned, rate)
|
||||
if policy.reactive
|
||||
else whole_frame_result(aligned, frames, high, rate, policy)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def run_functional_tests(aligned, frames, high, results):
|
||||
lookup = {(r.summary.channel_kbps, r.summary.policy): r for r in results}
|
||||
checks = []
|
||||
def check(name):
|
||||
def decorator(function): checks.append((name, function)); return function
|
||||
return decorator
|
||||
|
||||
whole = [result for result in results if result.schedule is not None]
|
||||
|
||||
@check("01_video_frames_do_not_interleave")
|
||||
def _():
|
||||
for result in whole:
|
||||
sequence = [item.item.composite_frame_id for item in result.schedule.transmitted if item.item.composite_frame_id is not None]
|
||||
compressed = [frame for index, frame in enumerate(sequence) if index == 0 or frame != sequence[index - 1]]
|
||||
assert len(compressed) == len(set(compressed))
|
||||
|
||||
@check("02_started_frame_never_dropped")
|
||||
def _():
|
||||
for result in whole:
|
||||
assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})
|
||||
|
||||
@check("03_high_priority_between_frame_packets")
|
||||
def _():
|
||||
assert any(
|
||||
any(item.item.packet.traffic_class is not TrafficClass.VIDEO for item in result.schedule.transmitted[left + 1:right])
|
||||
for result in whole
|
||||
for left, right in zip(
|
||||
[i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][:-1],
|
||||
[i for i, item in enumerate(result.schedule.transmitted) if item.item.composite_frame_id is not None][1:],
|
||||
)
|
||||
if result.schedule.transmitted[left].item.composite_frame_id == result.schedule.transmitted[right].item.composite_frame_id
|
||||
)
|
||||
|
||||
@check("04_latest_drops_only_unstarted")
|
||||
def _():
|
||||
for rate in CHANNEL_RATES_KBPS:
|
||||
result = lookup[(rate, "latest_only")]
|
||||
assert not (set(result.schedule.started_frame_ids) & {item.frame.composite_frame_id for item in result.schedule.dropped_frames})
|
||||
|
||||
@check("05_two_waiting_limit")
|
||||
def _(): assert all(lookup[(rate, "two_waiting")].summary.max_waiting_video_frames <= 2 for rate in CHANNEL_RATES_KBPS)
|
||||
|
||||
@check("06_prediction_is_pure")
|
||||
def _():
|
||||
ready = list(high[:2]); future = list(high[2:20]); ready_before=list(ready); future_before=list(future)
|
||||
predict_frame_completion(0.0, frames[0], 230_000.0, ready, future)
|
||||
assert ready == ready_before and future == future_before
|
||||
|
||||
@check("07_prediction_uses_actual_sizes")
|
||||
def _():
|
||||
small = VideoFrameGroup(999, 0, (frames[0].packets[0],))
|
||||
full = predict_frame_completion(0.0, frames[0], 300_000.0, (), ())
|
||||
one = predict_frame_completion(0.0, small, 300_000.0, (), ())
|
||||
assert full > one and abs(one - small.wire_size_bytes * 8.0 / 300_000.0) < 1e-12
|
||||
|
||||
@check("08_prestart_drop_has_no_waste")
|
||||
def _(): assert all(result.video.wasted_transmitted_video_bytes == 0 for result in whole)
|
||||
|
||||
@check("09_partial_only_reactive")
|
||||
def _():
|
||||
assert all(result.video.partially_transmitted_cancelled_frames == 0 for result in whole)
|
||||
assert lookup[(230.0, "reactive_1500ms")].video.partially_transmitted_cancelled_frames > 0
|
||||
|
||||
@check("10_incomplete_not_published")
|
||||
def _():
|
||||
for result in whole:
|
||||
dropped = {item.frame.composite_frame_id for item in result.schedule.dropped_frames}
|
||||
assert not (dropped & set(result.publication_times))
|
||||
|
||||
@check("11_crc_layers_pass")
|
||||
def _(): assert all(result.video.published_frames == len(result.publication_times) for result in results)
|
||||
|
||||
@check("12_emergency_never_deleted")
|
||||
def _(): assert all(result.control.emergency_deadline_met for result in results)
|
||||
|
||||
@check("13_priority_above_video")
|
||||
def _(): assert all(result.control.control_deadline_misses == 0 and result.control.telemetry_deadline_misses == 0 for result in results)
|
||||
|
||||
@check("14_300kbps_no_unnecessary_loss")
|
||||
def _(): assert all(lookup[(300.0, policy.name)].video.published_frames == 63 for policy in POLICIES)
|
||||
|
||||
@check("15_230kbps_bounded_queue")
|
||||
def _():
|
||||
baseline = lookup[(230.0, "no_drop")].summary.max_queue_packets
|
||||
assert all(lookup[(230.0, name)].summary.max_queue_packets < baseline for name in ("latest_only", "two_waiting", "predict_1000ms", "predict_500ms"))
|
||||
|
||||
@check("16_frame_accounting")
|
||||
def _():
|
||||
for result in results:
|
||||
assert result.video.published_frames + result.video.dropped_before_start_frames + result.video.partially_transmitted_cancelled_frames == 63
|
||||
|
||||
@check("17_byte_accounting")
|
||||
def _():
|
||||
for result in whole:
|
||||
assert result.summary.transmitted_bytes == sum(len(item.wire_packet) for item in result.schedule.transmitted)
|
||||
assert result.summary.dropped_before_start_bytes == sum(item.frame.wire_size_bytes for item in result.schedule.dropped_frames)
|
||||
|
||||
@check("18_reproducible")
|
||||
def _():
|
||||
original = lookup[(230.0, "predict_1000ms")].schedule
|
||||
repeated = schedule_video_frames(frames, high, FramePolicy.PREDICT_1000MS, 230_000.0)
|
||||
assert [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in original.transmitted] == [(x.item.arrival_order,x.start_seconds,x.end_seconds) for x in repeated.transmitted]
|
||||
|
||||
@check("19_predict_1000_never_known_late")
|
||||
def _(): assert all(lookup[(rate, "predict_1000ms")].prediction.published_after_deadline_frames == 0 for rate in CHANNEL_RATES_KBPS)
|
||||
|
||||
@check("20_command_delay_bound")
|
||||
def _():
|
||||
lab033 = {}
|
||||
with Path("data/processed/lab033/lab033_summary.csv").open(encoding="utf-8") as file:
|
||||
for row in csv.DictReader(file):
|
||||
if row["scheduler"] == "latest_state": lab033[float(row["channel_kbps"])] = float(row["control_max_age_ms"])
|
||||
max_video_bytes = max(packet.wire_size_bytes for frame in frames for packet in frame.packets)
|
||||
for result in results:
|
||||
bound = lab033[result.summary.channel_kbps] + max_video_bytes * 8.0 / (result.summary.channel_kbps * 1000.0) * 1000.0
|
||||
assert result.control.control_max_delay_ms <= bound + 1e-9
|
||||
|
||||
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}"))
|
||||
if not all(item.passed for item in output): raise AssertionError("functional checks failed: "+", ".join(item.name for item in output if not item.passed))
|
||||
return tuple(output)
|
||||
|
||||
|
||||
def save_csv(results):
|
||||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
for path, cls, rows in (
|
||||
(SUMMARY_CSV_PATH, SummaryMetrics, (r.summary for r in results)),
|
||||
(VIDEO_CSV_PATH, VideoMetrics, (r.video for r in results)),
|
||||
(CONTROL_CSV_PATH, ControlMetrics, (r.control for r in results)),
|
||||
(PREDICTION_CSV_PATH, PredictionMetrics, (r.prediction for r 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 grouped_plot(results,value,ylabel,title,path):
|
||||
x=np.arange(len(CHANNEL_RATES_KBPS)); width=.13
|
||||
fig,axis=plt.subplots(figsize=(12,5.5))
|
||||
for index,policy in enumerate(POLICIES):
|
||||
rows=[r for r in results if r.summary.policy==policy.name]
|
||||
axis.bar(x+(index-2.5)*width,[value(r) for r in rows],width,label=policy.label)
|
||||
axis.set_xticks(x,[f"{rate:.0f}" for rate in CHANNEL_RATES_KBPS]); axis.set_xlabel("Скорость, кбит/с"); axis.set_ylabel(ylabel); axis.set_title(title); axis.grid(axis="y",alpha=.3); axis.legend(fontsize=8); fig.tight_layout(); fig.savefig(path,dpi=150); plt.close(fig)
|
||||
|
||||
|
||||
def save_plots(results):
|
||||
grouped_plot(results,lambda r:r.video.actual_update_fps,"Обновлений/с","Фактическая частота обновления",UPDATE_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.video.p95_display_age_ms,"P95 возраста, мс","Возраст отображаемого изображения",AGE_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.video.p95_publication_delay_ms,"P95 задержки, мс","Задержка публикации",PUBLICATION_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.video.published_frames,"Кадров","Опубликованные кадры",OUTCOME_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.summary.max_queue_packets,"Пакетов","Максимальный размер очереди",QUEUE_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.prediction.p95_absolute_error_ms,"P95 ошибки, мс","Точность прогноза",PREDICTION_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.control.control_p95_delay_ms,"P95, мс","Задержка команд",CONTROL_PLOT_PATH)
|
||||
grouped_plot(results,lambda r:r.video.dropped_before_start_frames,"Кадров","Сравнение политик упреждающего удаления",COMPARISON_PLOT_PATH)
|
||||
|
||||
|
||||
def write_report(aligned,results,tests):
|
||||
git_status=subprocess.run(("git","status","--short","--branch"),check=True,capture_output=True,text=True,encoding="utf-8").stdout.rstrip()
|
||||
capacities={rate:theoretical_capacity(aligned,rate) for rate in CHANNEL_RATES_KBPS}
|
||||
lines=[
|
||||
"Lab035. Упреждающий допуск видеокадров и обслуживание видео целыми кадрами","",
|
||||
"1. Исходное состояние",f"- Commit Lab034: {LAB034_COMMIT}.","- Перед Lab035 рабочее дерево было чистым; main опережала origin/main на два commit.","",
|
||||
"2. Правило обслуживания","- После первого видеопакета кадр становится активным и не удаляется.","- При повторном выборе видео передаётся следующий пакет активного кадра; команды и телеметрия могут передаваться между пакетами.","- Новый видеокадр начинается только после полного завершения активного; видеопакеты разных кадров не чередуются; отдельный пакет не прерывается.","- Только неактивные кадры могут быть удалены до передачи первого пакета.","",
|
||||
"3. Теоретическая пропускная способность","speed | nonvideo kbps | remaining video kbps | remaining/aligned | minimum skip | maximum fps",
|
||||
]
|
||||
for rate in CHANNEL_RATES_KBPS:
|
||||
c=capacities[rate]; lines.append(f"{rate:.0f} | {c.nonvideo_load_kbps:.3f} | {c.remaining_video_kbps:.3f} | {c.video_capacity_ratio:.6f} | {c.minimum_skip_fraction:.6f} | {c.maximum_update_fps:.3f}")
|
||||
lines.extend(["","4. Восемнадцать сочетаний","speed | policy | published/drop/partial | fps | age P95 ms | no-update max ms | queue max/waiting frames | waste bytes | prediction MAE/P95/max ms | control P95/max ms | emergency ms"])
|
||||
for r in results:
|
||||
s,v,c,p=r.summary,r.video,r.control,r.prediction
|
||||
lines.append(f"{s.channel_kbps:.0f} | {POLICY_BY_NAME[s.policy].label} | {v.published_frames}/{v.dropped_before_start_frames}/{v.partially_transmitted_cancelled_frames} | {v.actual_update_fps:.3f} | {v.p95_display_age_ms:.3f} | {v.max_no_update_duration_ms:.3f} | {s.max_queue_packets}/{s.max_waiting_video_frames} | {v.wasted_transmitted_video_bytes} | {p.mean_absolute_error_ms:.6f}/{p.p95_absolute_error_ms:.6f}/{p.max_absolute_error_ms:.6f} | {c.control_p95_delay_ms:.3f}/{c.control_max_delay_ms:.3f} | {c.emergency_delay_ms:.3f}")
|
||||
lines.extend(["","5. Интерпретация","- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.","- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.","- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.","- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.","- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.","- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.","- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.","","6. Допущения","- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.","- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.","- Политика автоматически не выбирается.","","7. Функциональные проверки"])
|
||||
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
|
||||
lines.extend(["","8. Созданные файлы"])
|
||||
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("tests/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
|
||||
lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
|
||||
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")
|
||||
|
||||
|
||||
def validate_outputs():
|
||||
for path in (SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH):
|
||||
with path.open(encoding="utf-8",newline="") as file: rows=list(csv.DictReader(file))
|
||||
if len(rows)!=18: raise AssertionError(f"{path} must contain 18 rows")
|
||||
if "Lab035" not in REPORT_PATH.read_text(encoding="utf-8"): raise AssertionError("invalid 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():
|
||||
lab033=build_lab033_workload(); aligned=build_aligned_workload(lab033)
|
||||
frames=build_frame_groups(aligned); high=high_priority_packets(aligned)
|
||||
results=run_experiment(aligned,frames,high)
|
||||
tests=run_functional_tests(aligned,frames,high,results)
|
||||
save_csv(results); save_plots(results); write_report(aligned,results,tests); validate_outputs()
|
||||
print(f"Lab035 complete: {len(results)} scenarios, {len(tests)} checks")
|
||||
|
||||
|
||||
if __name__=="__main__": main()
|
||||
Reference in New Issue
Block a user