Files
SDR-Rover/protocol/video_frame_scheduler.py
2026-08-03 15:42:16 +03:00

331 lines
12 KiB
Python

"""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,
)