Lab034: evaluate stale video frame dropping
This commit is contained in:
224
protocol/video_age_policy.py
Normal file
224
protocol/video_age_policy.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Frame-aware stale-video policy for the Lab034 shared link queue.
|
||||
|
||||
The scheduler remains strict-priority, state-replacing, and non-preemptive.
|
||||
Unlike Lab033, packet availability is separate from the timestamp stored in
|
||||
the common header. Every aligned video packet therefore carries the source
|
||||
composite-frame generation time while FEC construction metadata remains local
|
||||
to the scheduler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from protocol.link_packet import HEADER_SIZE, LinkPacket, TrafficClass, encode_link_packet
|
||||
from protocol.priority_scheduler import transmission_duration_seconds
|
||||
|
||||
|
||||
TIME_EPSILON_SECONDS = 1e-12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgePolicyPacket:
|
||||
"""A common packet plus non-serialized availability/frame metadata."""
|
||||
|
||||
packet: LinkPacket
|
||||
arrival_order: int
|
||||
available_time_us: int
|
||||
composite_frame_id: int | None = None
|
||||
|
||||
@property
|
||||
def available_time_seconds(self) -> float:
|
||||
return self.available_time_us / 1_000_000.0
|
||||
|
||||
@property
|
||||
def frame_generation_seconds(self) -> float:
|
||||
return self.packet.generation_time_us / 1_000_000.0
|
||||
|
||||
@property
|
||||
def wire_size_bytes(self) -> int:
|
||||
return HEADER_SIZE + len(self.packet.payload)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateReplacement:
|
||||
removed: AgePolicyPacket
|
||||
replacement: AgePolicyPacket
|
||||
time_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DroppedVideoPacket:
|
||||
item: AgePolicyPacket
|
||||
drop_time_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgeScheduledPacket:
|
||||
item: AgePolicyPacket
|
||||
start_seconds: float
|
||||
end_seconds: float
|
||||
blocked_by: AgePolicyPacket | None
|
||||
blocking_delay_seconds: float
|
||||
wire_packet: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgeScheduleResult:
|
||||
channel_bitrate_bps: float
|
||||
max_video_age_seconds: float | None
|
||||
transmitted: tuple[AgeScheduledPacket, ...]
|
||||
dropped_video: tuple[DroppedVideoPacket, ...]
|
||||
replacements: tuple[StateReplacement, ...]
|
||||
|
||||
|
||||
def _enqueue(
|
||||
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 schedule_with_video_age(
|
||||
packets: Iterable[AgePolicyPacket],
|
||||
channel_bitrate_bps: float,
|
||||
max_video_age_seconds: float | None,
|
||||
) -> AgeScheduleResult:
|
||||
"""Run a strict-priority non-preemptive scheduler with frame drops.
|
||||
|
||||
At every packet-selection instant all queued packets belonging to a video
|
||||
frame older than ``max_video_age_seconds`` are removed together. An active
|
||||
packet has already left the queue and is never interrupted.
|
||||
"""
|
||||
|
||||
if channel_bitrate_bps <= 0.0:
|
||||
raise ValueError("channel bitrate must be positive")
|
||||
if max_video_age_seconds is not None and max_video_age_seconds <= 0.0:
|
||||
raise ValueError("maximum video age must be positive")
|
||||
arrivals = sorted(
|
||||
tuple(packets),
|
||||
key=lambda item: (item.available_time_seconds, item.arrival_order),
|
||||
)
|
||||
if len({item.arrival_order for item in arrivals}) != len(arrivals):
|
||||
raise ValueError("arrival_order values must be unique")
|
||||
for item in arrivals:
|
||||
if item.packet.traffic_class is TrafficClass.VIDEO:
|
||||
if item.composite_frame_id is None:
|
||||
raise ValueError("video packet lacks composite_frame_id")
|
||||
elif item.composite_frame_id is not None:
|
||||
raise ValueError("non-video packet has composite_frame_id")
|
||||
|
||||
ready: list[AgePolicyPacket] = []
|
||||
transmitted: list[AgeScheduledPacket] = []
|
||||
dropped: list[DroppedVideoPacket] = []
|
||||
replacements: list[StateReplacement] = []
|
||||
blocker_by_order: dict[int, tuple[AgePolicyPacket, float]] = {}
|
||||
dropped_frames: set[int] = set()
|
||||
arrival_index = 0
|
||||
cursor = 0.0
|
||||
|
||||
def admit_until(limit: float, active: AgePolicyPacket | None = None, active_end: float = 0.0) -> None:
|
||||
nonlocal arrival_index
|
||||
while (
|
||||
arrival_index < len(arrivals)
|
||||
and arrivals[arrival_index].available_time_seconds
|
||||
<= limit + TIME_EPSILON_SECONDS
|
||||
):
|
||||
item = arrivals[arrival_index]
|
||||
arrival_index += 1
|
||||
if (
|
||||
active is not None
|
||||
and item.available_time_seconds > cursor + TIME_EPSILON_SECONDS
|
||||
):
|
||||
blocker_by_order[item.arrival_order] = (
|
||||
active,
|
||||
max(0.0, active_end - item.available_time_seconds),
|
||||
)
|
||||
if item.composite_frame_id in dropped_frames:
|
||||
dropped.append(DroppedVideoPacket(item, limit))
|
||||
else:
|
||||
_enqueue(ready, item, replacements)
|
||||
|
||||
def expire_video(now: float) -> None:
|
||||
if max_video_age_seconds is None:
|
||||
return
|
||||
expired_frames = {
|
||||
item.composite_frame_id
|
||||
for item in ready
|
||||
if (
|
||||
item.packet.traffic_class is TrafficClass.VIDEO
|
||||
and now - item.frame_generation_seconds
|
||||
> max_video_age_seconds + TIME_EPSILON_SECONDS
|
||||
)
|
||||
}
|
||||
if not expired_frames:
|
||||
return
|
||||
retained = []
|
||||
for item in ready:
|
||||
if item.composite_frame_id in expired_frames:
|
||||
dropped.append(DroppedVideoPacket(item, now))
|
||||
assert item.composite_frame_id is not None
|
||||
dropped_frames.add(item.composite_frame_id)
|
||||
else:
|
||||
retained.append(item)
|
||||
ready[:] = retained
|
||||
|
||||
while arrival_index < len(arrivals) or ready:
|
||||
if not ready:
|
||||
cursor = max(cursor, arrivals[arrival_index].available_time_seconds)
|
||||
admit_until(cursor)
|
||||
expire_video(cursor)
|
||||
if not ready:
|
||||
continue
|
||||
selected_index = min(
|
||||
range(len(ready)),
|
||||
key=lambda index: (
|
||||
int(ready[index].packet.traffic_class),
|
||||
ready[index].arrival_order,
|
||||
),
|
||||
)
|
||||
selected = ready.pop(selected_index)
|
||||
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_until(end, selected, end)
|
||||
blocker, blocking_delay = blocker_by_order.get(
|
||||
selected.arrival_order, (None, 0.0)
|
||||
)
|
||||
transmitted.append(
|
||||
AgeScheduledPacket(
|
||||
item=selected,
|
||||
start_seconds=start,
|
||||
end_seconds=end,
|
||||
blocked_by=blocker,
|
||||
blocking_delay_seconds=blocking_delay,
|
||||
wire_packet=wire_packet,
|
||||
)
|
||||
)
|
||||
cursor = end
|
||||
|
||||
return AgeScheduleResult(
|
||||
channel_bitrate_bps=channel_bitrate_bps,
|
||||
max_video_age_seconds=max_video_age_seconds,
|
||||
transmitted=tuple(transmitted),
|
||||
dropped_video=tuple(dropped),
|
||||
replacements=tuple(replacements),
|
||||
)
|
||||
Reference in New Issue
Block a user