The tests/ directory held 50 laboratory programs and no tests. They model channels, run hundreds of repetitions and write CSV, PNG and reports; calling that a test suite blocked introducing a real one, because any pytest run would have collected the labs and re-executed every experiment. - move all 50 lab programs to experiments/ with git mv, preserving history - rewrite the 38 cross-imports between labs from tests.labNNN to experiments.labNNN - leave tests/ empty for actual fast checks of protocol/ - point quick_gate and the hook at the new layout and add experiments/ to the syntax sweep - update the paths quoted in the Lab042 specification and the verifier agent definition This also defuses the import-time work finding without touching 41 files: the labs still create directories and write files on import, but nothing imports them now except the gate, which does so deliberately. Gate passes: syntax clean, protocol imports, 15 lab modules import, 2 functional suites run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
749 lines
39 KiB
Python
749 lines
39 KiB
Python
"""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 experiments.lab028_video_packetization import COMPOSITE_FPS
|
||
from experiments.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("experiments/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()
|