Files
SDR-Rover/tests/lab029b_time_based_burst_simulation.py
2026-07-27 16:28:54 +03:00

1379 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()