1379 lines
47 KiB
Python
1379 lines
47 KiB
Python
"""
|
||
Lab029B. Time-based burst loss simulation for Lab028 video packets.
|
||
|
||
The existing Lab028 packet format and reassembler are used unchanged. Real
|
||
BASE/ROI JPEGs are packetized in the existing order: all BASE fragments of a
|
||
composite frame, then all ROI fragments. Packet transmission intervals use
|
||
the actual wire length at a laboratory bit rate of 300 kbit/s.
|
||
|
||
The channel alternates between exponentially distributed Good and Bad time
|
||
states. A packet is conservatively lost if any part of its transmission
|
||
interval overlaps Bad. JPEGs and packets remain in memory.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from bisect import bisect_right
|
||
import csv
|
||
from dataclasses import asdict, dataclass
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
import cv2
|
||
import matplotlib
|
||
import numpy as np
|
||
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
|
||
from protocol.video_packet import (
|
||
CompositeReassembler,
|
||
HEADER_SIZE,
|
||
ObjectType,
|
||
decode_packet,
|
||
)
|
||
from tests.lab028_video_packetization import (
|
||
COMPOSITE_FPS,
|
||
EncodedComposite,
|
||
SOURCE_VIDEO_PATH,
|
||
VideoMetadata,
|
||
load_video_profile,
|
||
)
|
||
from tests.lab029_packet_channel_simulation import (
|
||
PAYLOAD_SIZES,
|
||
PreparedProfile,
|
||
prepare_profiles,
|
||
)
|
||
|
||
|
||
OUTPUT_DIRECTORY = Path("data/processed/lab029b")
|
||
CSV_PATH = OUTPUT_DIRECTORY / "lab029b_results.csv"
|
||
REPORT_PATH = OUTPUT_DIRECTORY / "lab029b_report.txt"
|
||
COMPOSITE_SUCCESS_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab029b_composite_success.png"
|
||
)
|
||
NO_IMAGE_DURATION_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab029b_no_image_duration.png"
|
||
)
|
||
MISSING_FRAME_RUN_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab029b_missing_frame_runs.png"
|
||
)
|
||
PAYLOAD_COMPARISON_PLOT_PATH = (
|
||
OUTPUT_DIRECTORY / "lab029b_payload_comparison.png"
|
||
)
|
||
|
||
CONTROL_STREAM_BITRATE_BPS = 300_000.0
|
||
CONTROL_STREAM_BITRATE_KBPS = CONTROL_STREAM_BITRATE_BPS / 1000.0
|
||
BAD_TIME_FRACTION = 0.02
|
||
MEAN_BAD_DURATIONS_SECONDS = (0.010, 0.050, 0.200, 1.000)
|
||
MONTE_CARLO_REPETITIONS = 200
|
||
MASTER_SEED = 290_290
|
||
SEED_BASE = MASTER_SEED
|
||
TIME_EPSILON_SECONDS = 1e-12
|
||
|
||
CSV_FIELDS = [
|
||
"payload_size",
|
||
"mean_bad_duration_ms",
|
||
"mean_good_duration_ms",
|
||
"target_bad_time_fraction",
|
||
"actual_bad_time_fraction",
|
||
"control_stream_bitrate_kbps",
|
||
"schedule_duration_seconds",
|
||
"monte_carlo_repetitions",
|
||
"seed",
|
||
"transmitted_packets",
|
||
"received_packets",
|
||
"lost_packets",
|
||
"packet_delivery_rate",
|
||
"base_objects_completed",
|
||
"base_object_success_rate",
|
||
"roi_objects_completed",
|
||
"roi_object_success_rate",
|
||
"atomic_composite_frames_completed",
|
||
"composite_success_rate",
|
||
"base_only_frames",
|
||
"roi_only_frames",
|
||
"incomplete_frames",
|
||
"mean_lost_packet_burst",
|
||
"p95_lost_packet_burst",
|
||
"max_lost_packet_burst",
|
||
"mean_consecutive_incomplete_frames",
|
||
"p95_consecutive_incomplete_frames",
|
||
"max_consecutive_incomplete_frames",
|
||
"mean_no_new_image_duration_seconds",
|
||
"p95_no_new_image_duration_seconds",
|
||
"max_no_new_image_duration_seconds",
|
||
"mean_interference_to_next_frame_seconds",
|
||
"p95_interference_to_next_frame_seconds",
|
||
"max_interference_to_next_frame_seconds",
|
||
"effective_delivered_video_bitrate_kbps",
|
||
"bad_interval_count",
|
||
"actual_mean_bad_duration_ms",
|
||
"actual_p95_bad_duration_ms",
|
||
"actual_max_bad_duration_ms",
|
||
]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TimeInterval:
|
||
start_seconds: float
|
||
end_seconds: float
|
||
|
||
@property
|
||
def duration_seconds(self) -> float:
|
||
return self.end_seconds - self.start_seconds
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScheduledPacket:
|
||
composite_frame_id: int
|
||
wire_packet: bytes
|
||
start_seconds: float
|
||
end_seconds: float
|
||
|
||
@property
|
||
def duration_seconds(self) -> float:
|
||
return self.end_seconds - self.start_seconds
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TransmissionSchedule:
|
||
payload_size: int
|
||
frames: tuple
|
||
packets: tuple[ScheduledPacket, ...]
|
||
duration_seconds: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RepetitionResult:
|
||
transmitted_packets: int
|
||
received_packets: int
|
||
lost_packets: int
|
||
base_objects_completed: int
|
||
roi_objects_completed: int
|
||
atomic_composite_frames_completed: int
|
||
base_only_frames: int
|
||
roi_only_frames: int
|
||
incomplete_frames: int
|
||
delivered_jpeg_bytes: int
|
||
lost_packet_bursts: tuple[int, ...]
|
||
incomplete_frame_runs: tuple[int, ...]
|
||
no_new_image_durations: tuple[float, ...]
|
||
interference_to_next_frame_delays: tuple[float, ...]
|
||
bad_time_seconds: float
|
||
bad_durations: tuple[float, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TimeSimulationResult:
|
||
payload_size: int
|
||
mean_bad_duration_ms: float
|
||
mean_good_duration_ms: float
|
||
target_bad_time_fraction: float
|
||
actual_bad_time_fraction: float
|
||
control_stream_bitrate_kbps: float
|
||
schedule_duration_seconds: float
|
||
monte_carlo_repetitions: int
|
||
seed: int
|
||
transmitted_packets: int
|
||
received_packets: int
|
||
lost_packets: int
|
||
packet_delivery_rate: float
|
||
base_objects_completed: int
|
||
base_object_success_rate: float
|
||
roi_objects_completed: int
|
||
roi_object_success_rate: float
|
||
atomic_composite_frames_completed: int
|
||
composite_success_rate: float
|
||
base_only_frames: int
|
||
roi_only_frames: int
|
||
incomplete_frames: int
|
||
mean_lost_packet_burst: float
|
||
p95_lost_packet_burst: float
|
||
max_lost_packet_burst: int
|
||
mean_consecutive_incomplete_frames: float
|
||
p95_consecutive_incomplete_frames: float
|
||
max_consecutive_incomplete_frames: int
|
||
mean_no_new_image_duration_seconds: float
|
||
p95_no_new_image_duration_seconds: float
|
||
max_no_new_image_duration_seconds: float
|
||
mean_interference_to_next_frame_seconds: float
|
||
p95_interference_to_next_frame_seconds: float
|
||
max_interference_to_next_frame_seconds: float
|
||
effective_delivered_video_bitrate_kbps: float
|
||
bad_interval_count: int
|
||
actual_mean_bad_duration_ms: float
|
||
actual_p95_bad_duration_ms: float
|
||
actual_max_bad_duration_ms: float
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FunctionalTestResult:
|
||
name: str
|
||
passed: bool
|
||
detail: str
|
||
|
||
|
||
def mean_good_duration(mean_bad_duration: float) -> float:
|
||
"""Keep the stationary Bad time fraction at two percent."""
|
||
|
||
return (
|
||
mean_bad_duration
|
||
* (1.0 - BAD_TIME_FRACTION)
|
||
/ BAD_TIME_FRACTION
|
||
)
|
||
|
||
|
||
def percentile(values: list[float] | tuple[float, ...], q: float) -> float:
|
||
if not values:
|
||
return 0.0
|
||
return float(np.percentile(values, q))
|
||
|
||
|
||
def build_transmission_schedule(
|
||
profile: PreparedProfile,
|
||
) -> TransmissionSchedule:
|
||
"""
|
||
Schedule real packets at 3 composite fps and 300 kbit/s.
|
||
|
||
A frame becomes available at frame_id / 3. BASE packets precede ROI
|
||
packets exactly as returned by the Lab028 packets_for_composite helper.
|
||
Packets inside one frame are contiguous. If transmission finishes before
|
||
the next frame is generated, the remaining frame period is naturally
|
||
idle; no artificial inter-packet interval is added.
|
||
"""
|
||
|
||
scheduled_packets = []
|
||
cursor_seconds = 0.0
|
||
for frame in profile.frames:
|
||
nominal_frame_time = (
|
||
frame.composite_frame_id / COMPOSITE_FPS
|
||
)
|
||
cursor_seconds = max(cursor_seconds, nominal_frame_time)
|
||
object_types = [
|
||
decode_packet(packet).object_type
|
||
for packet in frame.packets
|
||
]
|
||
first_roi = next(
|
||
(
|
||
index
|
||
for index, object_type in enumerate(object_types)
|
||
if object_type == ObjectType.ROI
|
||
),
|
||
len(object_types),
|
||
)
|
||
if any(
|
||
object_type != ObjectType.BASE
|
||
for object_type in object_types[:first_roi]
|
||
) or any(
|
||
object_type != ObjectType.ROI
|
||
for object_type in object_types[first_roi:]
|
||
):
|
||
raise RuntimeError("Lab028 packet order is not BASE then ROI")
|
||
|
||
for wire_packet in frame.packets:
|
||
duration_seconds = (
|
||
len(wire_packet) * 8.0 / CONTROL_STREAM_BITRATE_BPS
|
||
)
|
||
end_seconds = cursor_seconds + duration_seconds
|
||
scheduled_packets.append(
|
||
ScheduledPacket(
|
||
composite_frame_id=frame.composite_frame_id,
|
||
wire_packet=wire_packet,
|
||
start_seconds=cursor_seconds,
|
||
end_seconds=end_seconds,
|
||
)
|
||
)
|
||
cursor_seconds = end_seconds
|
||
|
||
if not scheduled_packets:
|
||
raise RuntimeError("transmission schedule is empty")
|
||
return TransmissionSchedule(
|
||
payload_size=profile.payload_size,
|
||
frames=profile.frames,
|
||
packets=tuple(scheduled_packets),
|
||
duration_seconds=scheduled_packets[-1].end_seconds,
|
||
)
|
||
|
||
|
||
def build_schedules(
|
||
profiles: dict[int, PreparedProfile],
|
||
) -> dict[int, TransmissionSchedule]:
|
||
return {
|
||
payload_size: build_transmission_schedule(profiles[payload_size])
|
||
for payload_size in PAYLOAD_SIZES
|
||
}
|
||
|
||
|
||
def generate_bad_intervals(
|
||
horizon_seconds: float,
|
||
mean_bad_duration_seconds: float,
|
||
rng: np.random.Generator,
|
||
) -> tuple[TimeInterval, ...]:
|
||
"""Generate a stationary alternating exponential Good/Bad timeline."""
|
||
|
||
if horizon_seconds <= 0.0:
|
||
raise ValueError("horizon must be positive")
|
||
if mean_bad_duration_seconds <= 0.0:
|
||
raise ValueError("mean Bad duration must be positive")
|
||
|
||
mean_good_seconds = mean_good_duration(
|
||
mean_bad_duration_seconds
|
||
)
|
||
bad_state = rng.random() < BAD_TIME_FRACTION
|
||
current_time = 0.0
|
||
intervals = []
|
||
while current_time < horizon_seconds:
|
||
state_mean = (
|
||
mean_bad_duration_seconds
|
||
if bad_state
|
||
else mean_good_seconds
|
||
)
|
||
state_duration = float(rng.exponential(state_mean))
|
||
state_end = min(
|
||
current_time + state_duration,
|
||
horizon_seconds,
|
||
)
|
||
if bad_state and state_end > current_time:
|
||
intervals.append(
|
||
TimeInterval(current_time, state_end)
|
||
)
|
||
current_time += state_duration
|
||
bad_state = not bad_state
|
||
return tuple(intervals)
|
||
|
||
|
||
def packet_loss_flags(
|
||
schedule: TransmissionSchedule,
|
||
bad_intervals: tuple[TimeInterval, ...],
|
||
) -> np.ndarray:
|
||
"""
|
||
Mark a packet lost when any part of its transmission overlaps Bad.
|
||
"""
|
||
|
||
flags = np.zeros(len(schedule.packets), dtype=np.bool_)
|
||
interval_index = 0
|
||
for packet_index, packet in enumerate(schedule.packets):
|
||
while (
|
||
interval_index < len(bad_intervals)
|
||
and bad_intervals[interval_index].end_seconds
|
||
<= packet.start_seconds + TIME_EPSILON_SECONDS
|
||
):
|
||
interval_index += 1
|
||
if interval_index >= len(bad_intervals):
|
||
break
|
||
interval = bad_intervals[interval_index]
|
||
if (
|
||
interval.start_seconds
|
||
< packet.end_seconds - TIME_EPSILON_SECONDS
|
||
and interval.end_seconds
|
||
> packet.start_seconds + TIME_EPSILON_SECONDS
|
||
):
|
||
flags[packet_index] = True
|
||
return flags
|
||
|
||
|
||
def positive_runs(flags: list[bool] | np.ndarray) -> tuple[int, ...]:
|
||
runs = []
|
||
current = 0
|
||
for flag in flags:
|
||
if bool(flag):
|
||
current += 1
|
||
elif current:
|
||
runs.append(current)
|
||
current = 0
|
||
if current:
|
||
runs.append(current)
|
||
return tuple(runs)
|
||
|
||
|
||
def frame_outage_metrics(
|
||
schedule: TransmissionSchedule,
|
||
completion_times: dict[int, float],
|
||
bad_intervals: tuple[TimeInterval, ...],
|
||
) -> tuple[
|
||
tuple[int, ...],
|
||
tuple[float, ...],
|
||
tuple[float, ...],
|
||
]:
|
||
"""
|
||
Measure failed-frame runs, held-image durations, and recovery delays.
|
||
|
||
For each run of incomplete frames, no-new-image duration is measured
|
||
from publication of the preceding complete frame to publication of the
|
||
next complete frame. At the boundaries, zero or schedule end is used.
|
||
Only runs containing at least one incomplete frame are included.
|
||
"""
|
||
|
||
frame_ids = [
|
||
frame.composite_frame_id for frame in schedule.frames
|
||
]
|
||
complete_flags = [
|
||
frame_id in completion_times for frame_id in frame_ids
|
||
]
|
||
incomplete_runs = positive_runs(
|
||
[not flag for flag in complete_flags]
|
||
)
|
||
|
||
no_image_durations = []
|
||
index = 0
|
||
while index < len(frame_ids):
|
||
if complete_flags[index]:
|
||
index += 1
|
||
continue
|
||
run_start = index
|
||
while index < len(frame_ids) and not complete_flags[index]:
|
||
index += 1
|
||
next_complete_index = index
|
||
if run_start > 0:
|
||
previous_id = frame_ids[run_start - 1]
|
||
start_time = completion_times.get(previous_id, 0.0)
|
||
else:
|
||
start_time = 0.0
|
||
if next_complete_index < len(frame_ids):
|
||
next_id = frame_ids[next_complete_index]
|
||
end_time = completion_times[next_id]
|
||
else:
|
||
end_time = schedule.duration_seconds
|
||
no_image_durations.append(max(0.0, end_time - start_time))
|
||
|
||
sorted_completion_times = sorted(completion_times.values())
|
||
recovery_delays = []
|
||
for interval in bad_intervals:
|
||
completion_index = bisect_right(
|
||
sorted_completion_times,
|
||
interval.start_seconds,
|
||
)
|
||
if completion_index < len(sorted_completion_times):
|
||
next_completion = sorted_completion_times[completion_index]
|
||
else:
|
||
next_completion = schedule.duration_seconds
|
||
recovery_delays.append(
|
||
max(0.0, next_completion - interval.start_seconds)
|
||
)
|
||
return (
|
||
incomplete_runs,
|
||
tuple(no_image_durations),
|
||
tuple(recovery_delays),
|
||
)
|
||
|
||
|
||
def simulate_repetition(
|
||
schedule: TransmissionSchedule,
|
||
bad_intervals: tuple[TimeInterval, ...],
|
||
) -> RepetitionResult:
|
||
"""Run one already generated time-channel realization."""
|
||
|
||
loss_flags = packet_loss_flags(schedule, bad_intervals)
|
||
receiver = CompositeReassembler()
|
||
completion_times: dict[int, float] = {}
|
||
received_packets = 0
|
||
delivered_jpeg_bytes = 0
|
||
|
||
for lost, packet in zip(loss_flags, schedule.packets):
|
||
if bool(lost):
|
||
continue
|
||
received_packets += 1
|
||
completed = receiver.ingest(packet.wire_packet)
|
||
if completed is not None:
|
||
completion_times[completed.composite_frame_id] = (
|
||
packet.end_seconds
|
||
)
|
||
delivered_jpeg_bytes += (
|
||
len(completed.base_jpeg) + len(completed.roi_jpeg)
|
||
)
|
||
|
||
base_completed = 0
|
||
roi_completed = 0
|
||
base_only = 0
|
||
roi_only = 0
|
||
for frame in schedule.frames:
|
||
frame_id = frame.composite_frame_id
|
||
atomic = frame_id in completion_times
|
||
base = (
|
||
atomic
|
||
or receiver.object_is_complete(frame_id, ObjectType.BASE)
|
||
)
|
||
roi = (
|
||
atomic
|
||
or receiver.object_is_complete(frame_id, ObjectType.ROI)
|
||
)
|
||
base_completed += int(base)
|
||
roi_completed += int(roi)
|
||
base_only += int(base and not roi)
|
||
roi_only += int(roi and not base)
|
||
|
||
incomplete_runs, no_image_durations, recovery_delays = (
|
||
frame_outage_metrics(
|
||
schedule, completion_times, bad_intervals
|
||
)
|
||
)
|
||
bad_durations = tuple(
|
||
interval.duration_seconds for interval in bad_intervals
|
||
)
|
||
transmitted = len(schedule.packets)
|
||
completed_count = len(completion_times)
|
||
return RepetitionResult(
|
||
transmitted_packets=transmitted,
|
||
received_packets=received_packets,
|
||
lost_packets=transmitted - received_packets,
|
||
base_objects_completed=base_completed,
|
||
roi_objects_completed=roi_completed,
|
||
atomic_composite_frames_completed=completed_count,
|
||
base_only_frames=base_only,
|
||
roi_only_frames=roi_only,
|
||
incomplete_frames=len(schedule.frames) - completed_count,
|
||
delivered_jpeg_bytes=delivered_jpeg_bytes,
|
||
lost_packet_bursts=positive_runs(loss_flags),
|
||
incomplete_frame_runs=incomplete_runs,
|
||
no_new_image_durations=no_image_durations,
|
||
interference_to_next_frame_delays=recovery_delays,
|
||
bad_time_seconds=sum(bad_durations),
|
||
bad_durations=bad_durations,
|
||
)
|
||
|
||
|
||
def simulate_condition(
|
||
schedule: TransmissionSchedule,
|
||
mean_bad_duration_seconds: float,
|
||
seed: int,
|
||
repetitions: int,
|
||
) -> TimeSimulationResult:
|
||
"""Aggregate one payload/time-based interference condition."""
|
||
|
||
rng = np.random.default_rng(seed)
|
||
repetitions_results = []
|
||
for _ in range(repetitions):
|
||
bad_intervals = generate_bad_intervals(
|
||
schedule.duration_seconds,
|
||
mean_bad_duration_seconds,
|
||
rng,
|
||
)
|
||
repetitions_results.append(
|
||
simulate_repetition(schedule, bad_intervals)
|
||
)
|
||
|
||
transmitted = sum(
|
||
result.transmitted_packets for result in repetitions_results
|
||
)
|
||
received = sum(
|
||
result.received_packets for result in repetitions_results
|
||
)
|
||
lost = sum(
|
||
result.lost_packets for result in repetitions_results
|
||
)
|
||
base_completed = sum(
|
||
result.base_objects_completed for result in repetitions_results
|
||
)
|
||
roi_completed = sum(
|
||
result.roi_objects_completed for result in repetitions_results
|
||
)
|
||
composite_completed = sum(
|
||
result.atomic_composite_frames_completed
|
||
for result in repetitions_results
|
||
)
|
||
base_only = sum(
|
||
result.base_only_frames for result in repetitions_results
|
||
)
|
||
roi_only = sum(
|
||
result.roi_only_frames for result in repetitions_results
|
||
)
|
||
incomplete = sum(
|
||
result.incomplete_frames for result in repetitions_results
|
||
)
|
||
delivered_bytes = sum(
|
||
result.delivered_jpeg_bytes for result in repetitions_results
|
||
)
|
||
lost_bursts = [
|
||
value
|
||
for result in repetitions_results
|
||
for value in result.lost_packet_bursts
|
||
]
|
||
incomplete_runs = [
|
||
value
|
||
for result in repetitions_results
|
||
for value in result.incomplete_frame_runs
|
||
]
|
||
no_image_durations = [
|
||
value
|
||
for result in repetitions_results
|
||
for value in result.no_new_image_durations
|
||
]
|
||
recovery_delays = [
|
||
value
|
||
for result in repetitions_results
|
||
for value in result.interference_to_next_frame_delays
|
||
]
|
||
bad_durations = [
|
||
value
|
||
for result in repetitions_results
|
||
for value in result.bad_durations
|
||
]
|
||
bad_time = sum(
|
||
result.bad_time_seconds for result in repetitions_results
|
||
)
|
||
|
||
total_frames = len(schedule.frames) * repetitions
|
||
total_time = schedule.duration_seconds * repetitions
|
||
if received + lost != transmitted:
|
||
raise RuntimeError("packet accounting disagrees")
|
||
if composite_completed + incomplete != total_frames:
|
||
raise RuntimeError("frame accounting disagrees")
|
||
if base_completed != composite_completed + base_only:
|
||
raise RuntimeError("BASE object accounting disagrees")
|
||
if roi_completed != composite_completed + roi_only:
|
||
raise RuntimeError("ROI object accounting disagrees")
|
||
|
||
return TimeSimulationResult(
|
||
payload_size=schedule.payload_size,
|
||
mean_bad_duration_ms=mean_bad_duration_seconds * 1000.0,
|
||
mean_good_duration_ms=(
|
||
mean_good_duration(mean_bad_duration_seconds) * 1000.0
|
||
),
|
||
target_bad_time_fraction=BAD_TIME_FRACTION,
|
||
actual_bad_time_fraction=bad_time / total_time,
|
||
control_stream_bitrate_kbps=CONTROL_STREAM_BITRATE_KBPS,
|
||
schedule_duration_seconds=schedule.duration_seconds,
|
||
monte_carlo_repetitions=repetitions,
|
||
seed=seed,
|
||
transmitted_packets=transmitted,
|
||
received_packets=received,
|
||
lost_packets=lost,
|
||
packet_delivery_rate=received / transmitted,
|
||
base_objects_completed=base_completed,
|
||
base_object_success_rate=base_completed / total_frames,
|
||
roi_objects_completed=roi_completed,
|
||
roi_object_success_rate=roi_completed / total_frames,
|
||
atomic_composite_frames_completed=composite_completed,
|
||
composite_success_rate=composite_completed / total_frames,
|
||
base_only_frames=base_only,
|
||
roi_only_frames=roi_only,
|
||
incomplete_frames=incomplete,
|
||
mean_lost_packet_burst=(
|
||
float(np.mean(lost_bursts)) if lost_bursts else 0.0
|
||
),
|
||
p95_lost_packet_burst=percentile(lost_bursts, 95),
|
||
max_lost_packet_burst=max(lost_bursts) if lost_bursts else 0,
|
||
mean_consecutive_incomplete_frames=(
|
||
float(np.mean(incomplete_runs))
|
||
if incomplete_runs
|
||
else 0.0
|
||
),
|
||
p95_consecutive_incomplete_frames=percentile(
|
||
incomplete_runs, 95
|
||
),
|
||
max_consecutive_incomplete_frames=(
|
||
max(incomplete_runs) if incomplete_runs else 0
|
||
),
|
||
mean_no_new_image_duration_seconds=(
|
||
float(np.mean(no_image_durations))
|
||
if no_image_durations
|
||
else 0.0
|
||
),
|
||
p95_no_new_image_duration_seconds=percentile(
|
||
no_image_durations, 95
|
||
),
|
||
max_no_new_image_duration_seconds=(
|
||
max(no_image_durations) if no_image_durations else 0.0
|
||
),
|
||
mean_interference_to_next_frame_seconds=(
|
||
float(np.mean(recovery_delays))
|
||
if recovery_delays
|
||
else 0.0
|
||
),
|
||
p95_interference_to_next_frame_seconds=percentile(
|
||
recovery_delays, 95
|
||
),
|
||
max_interference_to_next_frame_seconds=(
|
||
max(recovery_delays) if recovery_delays else 0.0
|
||
),
|
||
effective_delivered_video_bitrate_kbps=(
|
||
delivered_bytes * 8.0 / total_time / 1000.0
|
||
),
|
||
bad_interval_count=len(bad_durations),
|
||
actual_mean_bad_duration_ms=(
|
||
float(np.mean(bad_durations)) * 1000.0
|
||
if bad_durations
|
||
else 0.0
|
||
),
|
||
actual_p95_bad_duration_ms=(
|
||
percentile(bad_durations, 95) * 1000.0
|
||
),
|
||
actual_max_bad_duration_ms=(
|
||
max(bad_durations) * 1000.0
|
||
if bad_durations
|
||
else 0.0
|
||
),
|
||
)
|
||
|
||
|
||
def run_monte_carlo(
|
||
schedules: dict[int, TransmissionSchedule],
|
||
) -> list[TimeSimulationResult]:
|
||
results = []
|
||
for duration_index, mean_bad_duration_seconds in enumerate(
|
||
MEAN_BAD_DURATIONS_SECONDS
|
||
):
|
||
seed = SEED_BASE + duration_index
|
||
for payload_size in PAYLOAD_SIZES:
|
||
results.append(
|
||
simulate_condition(
|
||
schedules[payload_size],
|
||
mean_bad_duration_seconds,
|
||
seed,
|
||
MONTE_CARLO_REPETITIONS,
|
||
)
|
||
)
|
||
return results
|
||
|
||
|
||
def result_lookup(
|
||
results: list[TimeSimulationResult],
|
||
mean_bad_duration_ms: float,
|
||
) -> dict[int, TimeSimulationResult]:
|
||
return {
|
||
result.payload_size: result
|
||
for result in results
|
||
if result.mean_bad_duration_ms == mean_bad_duration_ms
|
||
}
|
||
|
||
|
||
def run_functional_tests(
|
||
composites: list[EncodedComposite],
|
||
schedules: dict[int, TransmissionSchedule],
|
||
results: list[TimeSimulationResult],
|
||
) -> list[FunctionalTestResult]:
|
||
tests: list[tuple[str, Callable[[], str]]] = []
|
||
|
||
def no_bad_state_is_perfect() -> str:
|
||
for payload_size in PAYLOAD_SIZES:
|
||
repetition = simulate_repetition(
|
||
schedules[payload_size], ()
|
||
)
|
||
if (
|
||
repetition.lost_packets != 0
|
||
or repetition.atomic_composite_frames_completed
|
||
!= len(schedules[payload_size].frames)
|
||
):
|
||
raise AssertionError(
|
||
f"zero-Bad failed for payload {payload_size}"
|
||
)
|
||
return "all 63 composite frames restored for all payloads"
|
||
|
||
def same_seed_is_reproducible() -> str:
|
||
first = simulate_condition(
|
||
schedules[512], 0.050, MASTER_SEED + 999, 5
|
||
)
|
||
second = simulate_condition(
|
||
schedules[512], 0.050, MASTER_SEED + 999, 5
|
||
)
|
||
if first != second:
|
||
raise AssertionError("identical seed changed the result")
|
||
return "identical seed produced identical aggregate fields"
|
||
|
||
def adjacent_frames_do_not_mix() -> str:
|
||
schedule = schedules[512]
|
||
receiver = CompositeReassembler()
|
||
completed = {}
|
||
selected = [
|
||
packet
|
||
for packet in schedule.packets
|
||
if packet.composite_frame_id in {0, 1}
|
||
]
|
||
first = [p for p in selected if p.composite_frame_id == 0]
|
||
second = [p for p in selected if p.composite_frame_id == 1]
|
||
interleaved = []
|
||
for index in range(max(len(first), len(second))):
|
||
if index < len(second):
|
||
interleaved.append(second[index])
|
||
if index < len(first):
|
||
interleaved.append(first[index])
|
||
for packet in interleaved:
|
||
frame = receiver.ingest(packet.wire_packet)
|
||
if frame is not None:
|
||
completed[frame.composite_frame_id] = frame
|
||
if set(completed) != {0, 1}:
|
||
raise AssertionError("neighboring frame IDs were mixed")
|
||
for composite in composites[:2]:
|
||
restored = completed[composite.composite_frame_id]
|
||
if (
|
||
restored.base_jpeg != composite.base_jpeg
|
||
or restored.roi_jpeg != composite.roi_jpeg
|
||
):
|
||
raise AssertionError("neighboring JPEGs were mixed")
|
||
return "two interleaved neighboring frames remained independent"
|
||
|
||
def incomplete_frame_is_not_published() -> str:
|
||
frame_packets = [
|
||
packet
|
||
for packet in schedules[512].packets
|
||
if packet.composite_frame_id == 0
|
||
]
|
||
receiver = CompositeReassembler()
|
||
published = 0
|
||
for packet in frame_packets[:-1]:
|
||
published += int(
|
||
receiver.ingest(packet.wire_packet) is not None
|
||
)
|
||
if published:
|
||
raise AssertionError("incomplete frame was published")
|
||
if not receiver.object_is_complete(0, ObjectType.BASE):
|
||
raise AssertionError("complete BASE was not retained")
|
||
return "missing ROI fragment prevented atomic publication"
|
||
|
||
def bad_fraction_is_close() -> str:
|
||
worst_error = max(
|
||
abs(
|
||
result.actual_bad_time_fraction
|
||
- result.target_bad_time_fraction
|
||
)
|
||
for result in results
|
||
)
|
||
if worst_error > 0.01:
|
||
raise AssertionError(
|
||
f"Bad fraction error is too large: {worst_error:.6f}"
|
||
)
|
||
return (
|
||
"all observed Bad fractions are within "
|
||
f"{worst_error * 100.0:.3f} percentage points of 2%"
|
||
)
|
||
|
||
def longer_bad_means_longer_outage() -> str:
|
||
short_mean = float(
|
||
np.mean(
|
||
[
|
||
result.mean_no_new_image_duration_seconds
|
||
for result in results
|
||
if result.mean_bad_duration_ms == 10.0
|
||
]
|
||
)
|
||
)
|
||
long_mean = float(
|
||
np.mean(
|
||
[
|
||
result.mean_no_new_image_duration_seconds
|
||
for result in results
|
||
if result.mean_bad_duration_ms == 1000.0
|
||
]
|
||
)
|
||
)
|
||
if long_mean <= short_mean:
|
||
raise AssertionError(
|
||
f"long outage {long_mean} <= short outage {short_mean}"
|
||
)
|
||
return (
|
||
f"mean held-image interval increased from "
|
||
f"{short_mean:.6f} to {long_mean:.6f} s"
|
||
)
|
||
|
||
def packet_time_uses_actual_size() -> str:
|
||
schedule = schedules[512]
|
||
durations = [
|
||
packet.duration_seconds for packet in schedule.packets
|
||
]
|
||
sizes = [len(packet.wire_packet) for packet in schedule.packets]
|
||
if len(set(sizes)) < 2 or len(set(durations)) < 2:
|
||
raise AssertionError("last packet duration did not differ")
|
||
for packet in schedule.packets:
|
||
expected = (
|
||
len(packet.wire_packet)
|
||
* 8.0
|
||
/ CONTROL_STREAM_BITRATE_BPS
|
||
)
|
||
if abs(packet.duration_seconds - expected) > 1e-12:
|
||
raise AssertionError("packet duration formula disagrees")
|
||
if (
|
||
max(
|
||
packet.duration_seconds
|
||
for packet in schedules[1024].packets
|
||
)
|
||
<= max(
|
||
packet.duration_seconds
|
||
for packet in schedules[256].packets
|
||
)
|
||
):
|
||
raise AssertionError("larger payload did not take longer")
|
||
return (
|
||
"duration equals actual wire bits / 300000 for full and "
|
||
"short final packets"
|
||
)
|
||
|
||
tests.extend(
|
||
[
|
||
("zero_bad_state_100_percent", no_bad_state_is_perfect),
|
||
("fixed_seed_reproducibility", same_seed_is_reproducible),
|
||
("adjacent_frame_isolation", adjacent_frames_do_not_mix),
|
||
("atomic_incomplete_frame", incomplete_frame_is_not_published),
|
||
("stationary_bad_fraction", bad_fraction_is_close),
|
||
("longer_bad_longer_outage", longer_bad_means_longer_outage),
|
||
("actual_packet_transmission_time", packet_time_uses_actual_size),
|
||
]
|
||
)
|
||
|
||
test_results = []
|
||
for name, test in tests:
|
||
try:
|
||
detail = test()
|
||
except Exception as error:
|
||
test_results.append(
|
||
FunctionalTestResult(name, False, str(error))
|
||
)
|
||
else:
|
||
test_results.append(
|
||
FunctionalTestResult(name, True, detail)
|
||
)
|
||
failed = [result for result in test_results if not result.passed]
|
||
if failed:
|
||
details = "; ".join(
|
||
f"{result.name}: {result.detail}" for result in failed
|
||
)
|
||
raise RuntimeError(f"Lab029B functional checks failed: {details}")
|
||
return test_results
|
||
|
||
|
||
def validate_results(results: list[TimeSimulationResult]) -> None:
|
||
if len(results) != 12:
|
||
raise RuntimeError(f"expected 12 rows, got {len(results)}")
|
||
keys = {
|
||
(result.payload_size, result.mean_bad_duration_ms)
|
||
for result in results
|
||
}
|
||
if len(keys) != 12:
|
||
raise RuntimeError("result conditions are not unique")
|
||
for result in results:
|
||
if (
|
||
result.received_packets + result.lost_packets
|
||
!= result.transmitted_packets
|
||
):
|
||
raise RuntimeError("packet counts disagree")
|
||
if not 0.0 <= result.composite_success_rate <= 1.0:
|
||
raise RuntimeError("composite success is outside 0...1")
|
||
|
||
|
||
def save_csv(results: list[TimeSimulationResult]) -> None:
|
||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||
with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
|
||
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
|
||
writer.writeheader()
|
||
for result in results:
|
||
raw = asdict(result)
|
||
row = {
|
||
field: (
|
||
f"{raw[field]:.12g}"
|
||
if isinstance(raw[field], float)
|
||
else raw[field]
|
||
)
|
||
for field in CSV_FIELDS
|
||
}
|
||
writer.writerow(row)
|
||
|
||
|
||
def save_line_plot(
|
||
results: list[TimeSimulationResult],
|
||
value_getter: Callable[[TimeSimulationResult], float],
|
||
ylabel: str,
|
||
title: str,
|
||
output_path: Path,
|
||
) -> None:
|
||
x_values = [
|
||
duration * 1000.0
|
||
for duration in MEAN_BAD_DURATIONS_SECONDS
|
||
]
|
||
figure, axis = plt.subplots(figsize=(9, 5.5))
|
||
for payload_size in PAYLOAD_SIZES:
|
||
values = [
|
||
value_getter(
|
||
result_lookup(results, duration_ms)[payload_size]
|
||
)
|
||
for duration_ms in x_values
|
||
]
|
||
axis.plot(
|
||
x_values,
|
||
values,
|
||
marker="o",
|
||
linewidth=2,
|
||
label=f"payload {payload_size} B",
|
||
)
|
||
axis.set_xscale("log")
|
||
axis.set_xticks(x_values)
|
||
axis.set_xticklabels(
|
||
[f"{value:g}" for value in x_values]
|
||
)
|
||
axis.set_xlabel("Средняя длительность Bad, мс")
|
||
axis.set_ylabel(ylabel)
|
||
axis.set_title(title)
|
||
axis.grid(True, which="both", alpha=0.3)
|
||
axis.legend()
|
||
figure.tight_layout()
|
||
figure.savefig(output_path, dpi=160)
|
||
plt.close(figure)
|
||
|
||
|
||
def save_plots(results: list[TimeSimulationResult]) -> None:
|
||
save_line_plot(
|
||
results,
|
||
lambda result: result.composite_success_rate * 100.0,
|
||
"Полностью восстановленные составные кадры, %",
|
||
"Lab029B. Успешная атомарная сборка",
|
||
COMPOSITE_SUCCESS_PLOT_PATH,
|
||
)
|
||
save_line_plot(
|
||
results,
|
||
lambda result: result.p95_no_new_image_duration_seconds,
|
||
"P95 отсутствия нового изображения, с",
|
||
"Lab029B. Длительность удержания последнего изображения",
|
||
NO_IMAGE_DURATION_PLOT_PATH,
|
||
)
|
||
save_line_plot(
|
||
results,
|
||
lambda result: result.p95_consecutive_incomplete_frames,
|
||
"P95 последовательных невосстановленных кадров",
|
||
"Lab029B. Серии невосстановленных составных кадров",
|
||
MISSING_FRAME_RUN_PLOT_PATH,
|
||
)
|
||
|
||
comparison = result_lookup(results, 200.0)
|
||
x_positions = np.arange(len(PAYLOAD_SIZES))
|
||
success = [
|
||
comparison[payload].composite_success_rate * 100.0
|
||
for payload in PAYLOAD_SIZES
|
||
]
|
||
outage = [
|
||
comparison[
|
||
payload
|
||
].p95_no_new_image_duration_seconds
|
||
for payload in PAYLOAD_SIZES
|
||
]
|
||
figure, success_axis = plt.subplots(figsize=(9, 5.5))
|
||
outage_axis = success_axis.twinx()
|
||
bars = success_axis.bar(
|
||
x_positions - 0.18,
|
||
success,
|
||
width=0.36,
|
||
color="tab:blue",
|
||
label="Composite success",
|
||
)
|
||
outage_bars = outage_axis.bar(
|
||
x_positions + 0.18,
|
||
outage,
|
||
width=0.36,
|
||
color="tab:orange",
|
||
label="P95 no-new-image",
|
||
)
|
||
success_axis.set_xticks(x_positions)
|
||
success_axis.set_xticklabels(
|
||
[str(payload) for payload in PAYLOAD_SIZES]
|
||
)
|
||
success_axis.set_xlabel("Payload, байт")
|
||
success_axis.set_ylabel(
|
||
"Восстановленные составные кадры, %",
|
||
color="tab:blue",
|
||
)
|
||
outage_axis.set_ylabel(
|
||
"P95 отсутствия нового изображения, с",
|
||
color="tab:orange",
|
||
)
|
||
success_axis.set_title(
|
||
"Lab029B. Сравнение payload при среднем Bad 200 мс"
|
||
)
|
||
success_axis.grid(True, axis="y", alpha=0.3)
|
||
success_axis.legend(
|
||
[bars, outage_bars],
|
||
["Composite success", "P95 no-new-image"],
|
||
loc="best",
|
||
)
|
||
figure.tight_layout()
|
||
figure.savefig(PAYLOAD_COMPARISON_PLOT_PATH, dpi=160)
|
||
plt.close(figure)
|
||
|
||
|
||
def comparison_table_lines(
|
||
results: list[TimeSimulationResult],
|
||
) -> list[str]:
|
||
lines = [
|
||
(
|
||
"Bad ms | payload | Bad actual | tx/rx/lost packets | "
|
||
"packet delivery | BASE | ROI | composite | BASE-only | "
|
||
"ROI-only | incomplete | "
|
||
"lost burst mean/p95/max | incomplete run mean/p95/max | "
|
||
"no image mean/p95/max s | recovery mean/p95/max s | "
|
||
"video kbit/s"
|
||
),
|
||
(
|
||
"------:|--------:|-----------:|-------------------:|"
|
||
"----------------:|-----:|----:|----------:|----------:|"
|
||
"---------:|-----------:|"
|
||
"-----------------------:|----------------------------:|"
|
||
"-------------------------:|------------------------:|"
|
||
"------------:"
|
||
),
|
||
]
|
||
for duration_seconds in MEAN_BAD_DURATIONS_SECONDS:
|
||
duration_ms = duration_seconds * 1000.0
|
||
by_payload = result_lookup(results, duration_ms)
|
||
for payload in PAYLOAD_SIZES:
|
||
result = by_payload[payload]
|
||
lines.append(
|
||
f"{duration_ms:.0f} | {payload} | "
|
||
f"{result.actual_bad_time_fraction * 100.0:.3f}% | "
|
||
f"{result.transmitted_packets}/"
|
||
f"{result.received_packets}/"
|
||
f"{result.lost_packets} | "
|
||
f"{result.packet_delivery_rate * 100.0:.3f}% | "
|
||
f"{result.base_object_success_rate * 100.0:.3f}% | "
|
||
f"{result.roi_object_success_rate * 100.0:.3f}% | "
|
||
f"{result.composite_success_rate * 100.0:.3f}% | "
|
||
f"{result.base_only_frames} | {result.roi_only_frames} | "
|
||
f"{result.incomplete_frames} | "
|
||
f"{result.mean_lost_packet_burst:.2f}/"
|
||
f"{result.p95_lost_packet_burst:.1f}/"
|
||
f"{result.max_lost_packet_burst} | "
|
||
f"{result.mean_consecutive_incomplete_frames:.2f}/"
|
||
f"{result.p95_consecutive_incomplete_frames:.1f}/"
|
||
f"{result.max_consecutive_incomplete_frames} | "
|
||
f"{result.mean_no_new_image_duration_seconds:.3f}/"
|
||
f"{result.p95_no_new_image_duration_seconds:.3f}/"
|
||
f"{result.max_no_new_image_duration_seconds:.3f} | "
|
||
f"{result.mean_interference_to_next_frame_seconds:.3f}/"
|
||
f"{result.p95_interference_to_next_frame_seconds:.3f}/"
|
||
f"{result.max_interference_to_next_frame_seconds:.3f} | "
|
||
f"{result.effective_delivered_video_bitrate_kbps:.3f}"
|
||
)
|
||
return lines
|
||
|
||
|
||
def write_report(
|
||
metadata: VideoMetadata,
|
||
composites: list[EncodedComposite],
|
||
schedules: dict[int, TransmissionSchedule],
|
||
results: list[TimeSimulationResult],
|
||
tests: list[FunctionalTestResult],
|
||
) -> None:
|
||
lines = [
|
||
"Lab029B. Временная модель серийных потерь видеопакетов",
|
||
"",
|
||
"Исходные данные и транспорт",
|
||
f"- Видео: {SOURCE_VIDEO_PATH}",
|
||
(
|
||
f"- Источник: {metadata.width}x{metadata.height}, "
|
||
f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
|
||
f"{metadata.duration_seconds:.6f} с."
|
||
),
|
||
(
|
||
f"- Использованы все {len(composites)} реальные пары: "
|
||
"BASE 240x135 grayscale JPEG Q23, ROI 320x180 grayscale "
|
||
"JPEG Q33, 3 составных кадра/с."
|
||
),
|
||
(
|
||
f"- Формат Lab028 не изменён: заголовок {HEADER_SIZE} байта, "
|
||
"packet CRC32, object CRC32, отдельные BASE/ROI, атомарный "
|
||
"CompositeReassembler."
|
||
),
|
||
(
|
||
"- Порядок передачи внутри кадра: сначала все BASE-фрагменты, "
|
||
"затем все ROI-фрагменты. Поэтому длительная помеха может "
|
||
"затрагивать объекты несимметрично."
|
||
),
|
||
"",
|
||
"Временная модель передачи",
|
||
(
|
||
f"- Контрольная скорость: {CONTROL_STREAM_BITRATE_KBPS:.0f} "
|
||
"кбит/с. Это параметр лабораторной, а не выбранная скорость "
|
||
"радиоканала."
|
||
),
|
||
(
|
||
"- Длительность пакета = фактическая длина "
|
||
"(32-byte header + фактический payload) × 8 / 300000."
|
||
),
|
||
(
|
||
"- Кадр доступен в момент frame_id / 3. Пакеты BASE и ROI "
|
||
"передаются подряд; искусственных межпакетных интервалов нет. "
|
||
"Остаток периода до следующего кадра является естественным idle."
|
||
),
|
||
]
|
||
for payload in PAYLOAD_SIZES:
|
||
schedule = schedules[payload]
|
||
packet_durations_ms = [
|
||
packet.duration_seconds * 1000.0
|
||
for packet in schedule.packets
|
||
]
|
||
lines.append(
|
||
f"- Payload {payload}: packets={len(schedule.packets)}, "
|
||
f"timeline={schedule.duration_seconds:.6f} с, "
|
||
f"packet duration min/max="
|
||
f"{min(packet_durations_ms):.6f}/"
|
||
f"{max(packet_durations_ms):.6f} мс."
|
||
)
|
||
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"Временная модель канала",
|
||
(
|
||
"- Good пропускает пакеты, Bad теряет пакеты. Длительности "
|
||
"состояний независимы и экспоненциально распределены."
|
||
),
|
||
(
|
||
f"- Целевая стационарная доля Bad: "
|
||
f"{BAD_TIME_FRACTION * 100.0:.1f}%."
|
||
),
|
||
(
|
||
"- mean Good = mean Bad × (1-0.02)/0.02 = "
|
||
"49 × mean Bad."
|
||
),
|
||
(
|
||
"- Консервативное допущение: пакет считается потерянным, "
|
||
"если хотя бы часть его передачи пересекается с Bad."
|
||
),
|
||
(
|
||
f"- Monte Carlo: {MONTE_CARLO_REPETITIONS} повторов на "
|
||
f"условие; master seed={MASTER_SEED}; seeds "
|
||
f"{SEED_BASE}...{SEED_BASE + 3}."
|
||
),
|
||
(
|
||
"- Один seed для одинаковой длительности повторно "
|
||
"используется для payload 256/512/1024."
|
||
),
|
||
"",
|
||
"Параметры состояний",
|
||
"mean Bad | mean Good | target Bad",
|
||
"--------:|----------:|----------:",
|
||
]
|
||
)
|
||
for duration in MEAN_BAD_DURATIONS_SECONDS:
|
||
lines.append(
|
||
f"{duration * 1000.0:.0f} мс | "
|
||
f"{mean_good_duration(duration) * 1000.0:.0f} мс | "
|
||
f"{BAD_TIME_FRACTION * 100.0:.1f}%"
|
||
)
|
||
|
||
lines.extend(["", "Функциональные проверки"])
|
||
lines.extend(
|
||
f"- {'PASS' if test.passed else 'FAIL'} {test.name}: {test.detail}"
|
||
for test in tests
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"Полная сравнительная таблица",
|
||
*comparison_table_lines(results),
|
||
"",
|
||
"Допущения и ограничения",
|
||
(
|
||
"- Не моделируются FEC, интерливинг, ARQ, повторные "
|
||
"передачи, искусственные межпакетные интервалы, преамбула "
|
||
"физического уровня, управление и телеметрия."
|
||
),
|
||
(
|
||
"- Bad-интервалы не зависят от границ пакетов; любое "
|
||
"частичное пересечение уничтожает пакет целиком."
|
||
),
|
||
(
|
||
"- no-new-image duration для серии невосстановленных кадров "
|
||
"измеряется от публикации предыдущего полного кадра до "
|
||
"публикации следующего; при краевых сериях используются "
|
||
"начало и конец временной шкалы."
|
||
),
|
||
(
|
||
"- interference-to-next-frame измеряется от начала каждого "
|
||
"Bad-интервала до первого следующего опубликованного "
|
||
"составного кадра или до конца шкалы."
|
||
),
|
||
(
|
||
"- Эффективный видеопоток учитывает JPEG bytes только "
|
||
"атомарно восстановленных BASE+ROI и полную длительность "
|
||
"расписания."
|
||
),
|
||
(
|
||
"- Результаты зависят от порядка BASE затем ROI и от "
|
||
"контрольной скорости 300 кбит/с."
|
||
),
|
||
(
|
||
"- Окончательный размер payload автоматически не "
|
||
"выбирается."
|
||
),
|
||
"",
|
||
"Артефакты",
|
||
f"- CSV: {CSV_PATH}",
|
||
f"- Composite success: {COMPOSITE_SUCCESS_PLOT_PATH}",
|
||
f"- No-new-image duration: {NO_IMAGE_DURATION_PLOT_PATH}",
|
||
f"- Missing frame runs: {MISSING_FRAME_RUN_PLOT_PATH}",
|
||
f"- Payload comparison: {PAYLOAD_COMPARISON_PLOT_PATH}",
|
||
"- JPEG, пакеты и бинарные дампы не сохранялись.",
|
||
"",
|
||
]
|
||
)
|
||
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
|
||
def validate_output_files() -> None:
|
||
for path in (
|
||
CSV_PATH,
|
||
REPORT_PATH,
|
||
COMPOSITE_SUCCESS_PLOT_PATH,
|
||
NO_IMAGE_DURATION_PLOT_PATH,
|
||
MISSING_FRAME_RUN_PLOT_PATH,
|
||
PAYLOAD_COMPARISON_PLOT_PATH,
|
||
):
|
||
if not path.exists() or path.stat().st_size <= 0:
|
||
raise RuntimeError(f"missing or empty output: {path}")
|
||
|
||
|
||
def main() -> None:
|
||
print("Lab029B: loading real BASE/ROI JPEGs...")
|
||
metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
|
||
profiles = prepare_profiles(composites)
|
||
schedules = build_schedules(profiles)
|
||
for payload in PAYLOAD_SIZES:
|
||
schedule = schedules[payload]
|
||
print(
|
||
f" payload={payload}: packets={len(schedule.packets)}, "
|
||
f"timeline={schedule.duration_seconds:.6f} s"
|
||
)
|
||
|
||
print(
|
||
f"Running 12 time-channel conditions, "
|
||
f"{MONTE_CARLO_REPETITIONS} repetitions each..."
|
||
)
|
||
results = run_monte_carlo(schedules)
|
||
validate_results(results)
|
||
|
||
print("Running Lab029B functional checks...")
|
||
tests = run_functional_tests(
|
||
composites, schedules, results
|
||
)
|
||
for test in tests:
|
||
print(f" PASS {test.name}: {test.detail}")
|
||
|
||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||
save_csv(results)
|
||
save_plots(results)
|
||
write_report(
|
||
metadata,
|
||
composites,
|
||
schedules,
|
||
results,
|
||
tests,
|
||
)
|
||
validate_output_files()
|
||
|
||
print("Composite success and P95 no-new-image duration:")
|
||
for duration in MEAN_BAD_DURATIONS_SECONDS:
|
||
duration_ms = duration * 1000.0
|
||
by_payload = result_lookup(results, duration_ms)
|
||
for payload in PAYLOAD_SIZES:
|
||
result = by_payload[payload]
|
||
print(
|
||
f" Bad={duration_ms:.0f} ms, payload={payload}: "
|
||
f"success={result.composite_success_rate:.6f}, "
|
||
f"no-image-p95="
|
||
f"{result.p95_no_new_image_duration_seconds:.6f} s"
|
||
)
|
||
print(f"CSV: {CSV_PATH}")
|
||
print(f"Report: {REPORT_PATH}")
|
||
print("Lab029B completed successfully.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|