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