Lab035: add proactive video frame admission
This commit is contained in:
330
protocol/video_frame_scheduler.py
Normal file
330
protocol/video_frame_scheduler.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Whole-frame video scheduling and predictive admission for Lab035."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Iterable
|
||||
|
||||
from protocol.link_packet import TrafficClass, encode_link_packet
|
||||
from protocol.priority_scheduler import transmission_duration_seconds
|
||||
from protocol.video_age_policy import AgePolicyPacket
|
||||
|
||||
|
||||
TIME_EPSILON_SECONDS = 1e-12
|
||||
|
||||
|
||||
class FramePolicy(str, Enum):
|
||||
NO_DROP = "no_drop"
|
||||
LATEST_ONLY = "latest_only"
|
||||
TWO_WAITING = "two_waiting"
|
||||
PREDICT_1000MS = "predict_1000ms"
|
||||
PREDICT_500MS = "predict_500ms"
|
||||
|
||||
@property
|
||||
def deadline_seconds(self) -> float | None:
|
||||
if self is FramePolicy.PREDICT_1000MS:
|
||||
return 1.0
|
||||
if self is FramePolicy.PREDICT_500MS:
|
||||
return 0.5
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoFrameGroup:
|
||||
composite_frame_id: int
|
||||
generation_time_us: int
|
||||
packets: tuple[AgePolicyPacket, ...]
|
||||
|
||||
@property
|
||||
def generation_time_seconds(self) -> float:
|
||||
return self.generation_time_us / 1_000_000.0
|
||||
|
||||
@property
|
||||
def wire_size_bytes(self) -> int:
|
||||
return sum(packet.wire_size_bytes for packet in self.packets)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameDrop:
|
||||
frame: VideoFrameGroup
|
||||
drop_time_seconds: float
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateReplacement:
|
||||
removed: AgePolicyPacket
|
||||
replacement: AgePolicyPacket
|
||||
time_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameScheduledPacket:
|
||||
item: AgePolicyPacket
|
||||
start_seconds: float
|
||||
end_seconds: float
|
||||
blocked_by: AgePolicyPacket | None
|
||||
blocking_delay_seconds: float
|
||||
wire_packet: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameAdmission:
|
||||
composite_frame_id: int
|
||||
predicted_completion_seconds: float
|
||||
actual_completion_seconds: float
|
||||
|
||||
@property
|
||||
def prediction_error_seconds(self) -> float:
|
||||
return self.actual_completion_seconds - self.predicted_completion_seconds
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameScheduleResult:
|
||||
policy: FramePolicy
|
||||
channel_bitrate_bps: float
|
||||
transmitted: tuple[FrameScheduledPacket, ...]
|
||||
dropped_frames: tuple[FrameDrop, ...]
|
||||
replacements: tuple[StateReplacement, ...]
|
||||
started_frame_ids: tuple[int, ...]
|
||||
completed_frame_ids: tuple[int, ...]
|
||||
admissions: tuple[FrameAdmission, ...]
|
||||
|
||||
|
||||
def _replace_state(
|
||||
ready: list[AgePolicyPacket],
|
||||
item: AgePolicyPacket,
|
||||
replacements: list[StateReplacement],
|
||||
) -> None:
|
||||
if item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY):
|
||||
retained = []
|
||||
for old in ready:
|
||||
if (
|
||||
old.packet.traffic_class == item.packet.traffic_class
|
||||
and old.packet.stream_id == item.packet.stream_id
|
||||
):
|
||||
replacements.append(StateReplacement(old, item, item.available_time_seconds))
|
||||
else:
|
||||
retained.append(old)
|
||||
ready[:] = retained
|
||||
ready.append(item)
|
||||
|
||||
|
||||
def _serve_high_priority(
|
||||
cursor: float,
|
||||
ready: list[AgePolicyPacket],
|
||||
future: tuple[AgePolicyPacket, ...],
|
||||
future_index: int,
|
||||
channel_bitrate_bps: float,
|
||||
) -> tuple[float, int, list[AgePolicyPacket]]:
|
||||
"""Pure local helper used only by the completion predictor."""
|
||||
|
||||
copied_ready = list(ready)
|
||||
index = future_index
|
||||
while index < len(future) and future[index].available_time_seconds <= cursor + TIME_EPSILON_SECONDS:
|
||||
item = future[index]
|
||||
index += 1
|
||||
copied_ready = [
|
||||
old for old in copied_ready
|
||||
if not (
|
||||
old.packet.traffic_class == item.packet.traffic_class
|
||||
and old.packet.stream_id == item.packet.stream_id
|
||||
and item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY)
|
||||
)
|
||||
]
|
||||
copied_ready.append(item)
|
||||
while copied_ready:
|
||||
selected = min(
|
||||
copied_ready,
|
||||
key=lambda item: (int(item.packet.traffic_class), item.arrival_order),
|
||||
)
|
||||
copied_ready.remove(selected)
|
||||
cursor += transmission_duration_seconds(selected.wire_size_bytes, channel_bitrate_bps)
|
||||
while index < len(future) and future[index].available_time_seconds <= cursor + TIME_EPSILON_SECONDS:
|
||||
item = future[index]
|
||||
index += 1
|
||||
copied_ready = [
|
||||
old for old in copied_ready
|
||||
if not (
|
||||
old.packet.traffic_class == item.packet.traffic_class
|
||||
and old.packet.stream_id == item.packet.stream_id
|
||||
and item.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.TELEMETRY)
|
||||
)
|
||||
]
|
||||
copied_ready.append(item)
|
||||
return cursor, index, copied_ready
|
||||
|
||||
|
||||
def predict_frame_completion(
|
||||
current_time_seconds: float,
|
||||
frame: VideoFrameGroup,
|
||||
channel_bitrate_bps: float,
|
||||
ready_high_priority: Iterable[AgePolicyPacket],
|
||||
future_high_priority: Iterable[AgePolicyPacket],
|
||||
active_packet_remaining_seconds: float = 0.0,
|
||||
) -> float:
|
||||
"""Predict full-frame completion without mutating any caller collection.
|
||||
|
||||
Known periodic commands, telemetry, and the scheduled emergency event are
|
||||
simulated exactly at packet boundaries. Unknown future discrete events are
|
||||
outside the Lab035 traffic model and therefore cannot be included.
|
||||
"""
|
||||
|
||||
if channel_bitrate_bps <= 0.0:
|
||||
raise ValueError("channel bitrate must be positive")
|
||||
if active_packet_remaining_seconds < 0.0:
|
||||
raise ValueError("active packet remainder must not be negative")
|
||||
cursor = current_time_seconds + active_packet_remaining_seconds
|
||||
ready = list(ready_high_priority)
|
||||
future = tuple(sorted(future_high_priority, key=lambda item: (item.available_time_seconds, item.arrival_order)))
|
||||
future_index = 0
|
||||
for video_packet in frame.packets:
|
||||
cursor, future_index, ready = _serve_high_priority(
|
||||
cursor, ready, future, future_index, channel_bitrate_bps
|
||||
)
|
||||
cursor += transmission_duration_seconds(video_packet.wire_size_bytes, channel_bitrate_bps)
|
||||
while future_index < len(future) and future[future_index].available_time_seconds <= cursor + TIME_EPSILON_SECONDS:
|
||||
ready.append(future[future_index])
|
||||
future_index += 1
|
||||
return cursor
|
||||
|
||||
|
||||
def schedule_video_frames(
|
||||
frames: Iterable[VideoFrameGroup],
|
||||
high_priority_packets: Iterable[AgePolicyPacket],
|
||||
policy: FramePolicy,
|
||||
channel_bitrate_bps: float,
|
||||
) -> FrameScheduleResult:
|
||||
"""Run strict-priority service while keeping video frames contiguous."""
|
||||
|
||||
policy = FramePolicy(policy)
|
||||
if channel_bitrate_bps <= 0.0:
|
||||
raise ValueError("channel bitrate must be positive")
|
||||
frame_arrivals = tuple(sorted(frames, key=lambda frame: (frame.generation_time_seconds, frame.composite_frame_id)))
|
||||
high_arrivals = tuple(sorted(high_priority_packets, key=lambda item: (item.available_time_seconds, item.arrival_order)))
|
||||
ready_high: list[AgePolicyPacket] = []
|
||||
pending_frames: list[VideoFrameGroup] = []
|
||||
transmitted: list[FrameScheduledPacket] = []
|
||||
dropped: list[FrameDrop] = []
|
||||
replacements: list[StateReplacement] = []
|
||||
started: list[int] = []
|
||||
completed: list[int] = []
|
||||
predicted_by_frame: dict[int, float] = {}
|
||||
actual_by_frame: dict[int, float] = {}
|
||||
active: VideoFrameGroup | None = None
|
||||
active_index = 0
|
||||
cursor = 0.0
|
||||
frame_index = high_index = 0
|
||||
blocker_by_order: dict[int, tuple[AgePolicyPacket, float]] = {}
|
||||
|
||||
def drop_frame(frame: VideoFrameGroup, reason: str) -> None:
|
||||
dropped.append(FrameDrop(frame, cursor, reason))
|
||||
|
||||
def admit(now: float, active_packet: AgePolicyPacket | None = None, active_end: 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 active_packet is not None and item.available_time_seconds > cursor + TIME_EPSILON_SECONDS:
|
||||
blocker_by_order[item.arrival_order] = (
|
||||
active_packet,
|
||||
max(0.0, active_end - item.available_time_seconds),
|
||||
)
|
||||
_replace_state(ready_high, item, replacements)
|
||||
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
|
||||
if policy is FramePolicy.LATEST_ONLY:
|
||||
for old in pending_frames:
|
||||
drop_frame(old, "replaced_by_newest")
|
||||
pending_frames[:] = [frame]
|
||||
elif policy is FramePolicy.TWO_WAITING:
|
||||
pending_frames.append(frame)
|
||||
while len(pending_frames) > 2:
|
||||
drop_frame(pending_frames.pop(0), "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:
|
||||
while pending_frames and active is None:
|
||||
candidate = pending_frames.pop(0)
|
||||
deadline = policy.deadline_seconds
|
||||
if deadline is not None:
|
||||
future_high = high_arrivals[high_index:]
|
||||
predicted = predict_frame_completion(
|
||||
cursor,
|
||||
candidate,
|
||||
channel_bitrate_bps,
|
||||
tuple(ready_high),
|
||||
future_high,
|
||||
)
|
||||
if predicted - candidate.generation_time_seconds > deadline + TIME_EPSILON_SECONDS:
|
||||
drop_frame(candidate, "prediction_reject")
|
||||
continue
|
||||
predicted_by_frame[candidate.composite_frame_id] = predicted
|
||||
active = candidate
|
||||
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)
|
||||
wire_packet = encode_link_packet(selected.packet)
|
||||
end = start + transmission_duration_seconds(len(wire_packet), channel_bitrate_bps)
|
||||
cursor = start
|
||||
admit(end, selected, end)
|
||||
blocker, blocking_delay = blocker_by_order.get(selected.arrival_order, (None, 0.0))
|
||||
transmitted.append(
|
||||
FrameScheduledPacket(selected, start, end, blocker, blocking_delay, wire_packet)
|
||||
)
|
||||
cursor = end
|
||||
if selected.packet.traffic_class is TrafficClass.VIDEO:
|
||||
assert active is not None
|
||||
active_index += 1
|
||||
if active_index == len(active.packets):
|
||||
completed.append(active.composite_frame_id)
|
||||
actual_by_frame[active.composite_frame_id] = end
|
||||
active = None
|
||||
active_index = 0
|
||||
|
||||
admissions = tuple(
|
||||
FrameAdmission(frame_id, predicted, actual_by_frame[frame_id])
|
||||
for frame_id, predicted in sorted(predicted_by_frame.items())
|
||||
)
|
||||
return FrameScheduleResult(
|
||||
policy=policy,
|
||||
channel_bitrate_bps=channel_bitrate_bps,
|
||||
transmitted=tuple(transmitted),
|
||||
dropped_frames=tuple(dropped),
|
||||
replacements=tuple(replacements),
|
||||
started_frame_ids=tuple(started),
|
||||
completed_frame_ids=tuple(completed),
|
||||
admissions=admissions,
|
||||
)
|
||||
Reference in New Issue
Block a user