Files
SDR-Rover/tests/lab037_lossy_full_link.py
2026-08-04 13:18:49 +03:00

1239 lines
52 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.
"""Lab037: complete lossy link with command repetition.
The model combines the unchanged Lab028/Lab030/Lab033 wire formats, 12+3
packet-erasure FEC, strict non-preemptive priority, latest-waiting-frame video
admission, a time-based two-state erasure channel, and receiver de-duplication.
Only aggregate CSV/TXT/PNG artifacts are retained.
"""
from __future__ import annotations
import csv
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import Iterable
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from protocol.control_repetition import (
CommandCopy,
CommandReceiver,
RepetitionMode,
build_command_copies,
)
from protocol.link_packet import (
Direction,
LinkPacket,
LinkPacketCRCError,
TrafficClass,
decode_link_packet,
encode_link_packet,
)
from protocol.packet_erasure_fec import (
OuterPacketCRCError,
decode_fec_block,
decode_outer_symbol,
encode_fec_block,
)
from protocol.video_packet import decode_packet as decode_inner_packet
from protocol.video_frame_scheduler import VideoFrameGroup
from protocol.video_age_policy import AgePolicyPacket
from tests.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
from tests.lab033_priority_channel_scheduler import (
STREAM_CONTROL,
STREAM_EMERGENCY,
STREAM_TELEMETRY,
STREAM_VIDEO,
build_workload as build_lab033_workload,
deterministic_payload,
)
from tests.lab034_stale_video_drop import parity_for_partial
OUTPUT_DIRECTORY = Path("data/processed/lab037")
SUMMARY_CSV_PATH = OUTPUT_DIRECTORY / "lab037_summary.csv"
COMMAND_CSV_PATH = OUTPUT_DIRECTORY / "lab037_command_metrics.csv"
VIDEO_CSV_PATH = OUTPUT_DIRECTORY / "lab037_video_metrics.csv"
EMERGENCY_CSV_PATH = OUTPUT_DIRECTORY / "lab037_emergency_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab037_report.txt"
COMMAND_GAP_PLOT = OUTPUT_DIRECTORY / "lab037_command_gap.png"
EMERGENCY_PLOT = OUTPUT_DIRECTORY / "lab037_emergency_timeliness.png"
COMMAND_AGE_PLOT = OUTPUT_DIRECTORY / "lab037_command_age.png"
IMAGE_AGE_PLOT = OUTPUT_DIRECTORY / "lab037_image_age.png"
VIDEO_PLOT = OUTPUT_DIRECTORY / "lab037_published_video.png"
LOAD_QUEUE_PLOT = OUTPUT_DIRECTORY / "lab037_load_queue.png"
COMPARISON_PLOT = OUTPUT_DIRECTORY / "lab037_protection_comparison.png"
PLOT_PATHS = (
COMMAND_GAP_PLOT,
EMERGENCY_PLOT,
COMMAND_AGE_PLOT,
IMAGE_AGE_PLOT,
VIDEO_PLOT,
LOAD_QUEUE_PLOT,
COMPARISON_PLOT,
)
DURATION_SECONDS = 120.0
FRAME_COUNT = int(DURATION_SECONDS * COMPOSITE_FPS)
VIDEO_PAYLOAD_BYTES = 512
SOURCE_BLOCK_SIZE = 12
PARITY_COUNT = 3
CONTROL_PERIOD_US = 50_000
TELEMETRY_PERIOD_US = 100_000
EMERGENCY_TIMES_US = (30_000_000, 60_000_000, 90_000_000)
CHANNEL_RATES_KBPS = (300.0, 260.0, 230.0)
MEAN_BAD_DURATIONS_MS = (10.0, 50.0, 200.0, 1000.0)
MODES = tuple(RepetitionMode)
REPETITIONS = 100
BAD_TIME_FRACTION = 0.02
MASTER_SEED = 0x37A11
CONTROL_REPEAT_DELAY_MS = 22.5
EPSILON = 1e-12
MODE_NAMES = {
RepetitionMode.NONE: "none",
RepetitionMode.EMERGENCY_ONLY: "emergency_only",
RepetitionMode.EMERGENCY_AND_CONTROL: "emergency_and_control",
}
@dataclass(frozen=True)
class TxUnit:
packet: LinkPacket
wire_packet: bytes
available_time_us: int
arrival_order: int
copy_index: int = 0
frame_id: int | None = None
block_id: int | None = None
symbol_index: int | None = None
source_count: int = 0
is_source: bool = False
@property
def available_seconds(self) -> float:
return self.available_time_us / 1_000_000.0
@property
def wire_size_bytes(self) -> int:
return len(self.wire_packet)
@property
def is_repeat(self) -> bool:
return self.copy_index > 0
@dataclass(frozen=True)
class Frame:
frame_id: int
generation_time_us: int
packets: tuple[TxUnit, ...]
jpeg_bytes: int
@property
def generation_seconds(self) -> float:
return self.generation_time_us / 1_000_000.0
@dataclass(frozen=True)
class Workload:
frames: tuple[Frame, ...]
controls: tuple[LinkPacket, ...]
telemetry: tuple[TxUnit, ...]
emergencies: tuple[LinkPacket, ...]
source_profile_frames: int
crc_packets_checked: int
crc_blocks_checked: int
@dataclass(frozen=True)
class Sent:
unit: TxUnit
start_seconds: float
end_seconds: float
@dataclass(frozen=True)
class Removed:
unit: TxUnit
time_seconds: float
reason: str
@dataclass(frozen=True)
class Schedule:
rate_kbps: float
mode: RepetitionMode
transmitted: tuple[Sent, ...]
removed: tuple[Removed, ...]
dropped_frame_ids: tuple[int, ...]
started_frame_ids: tuple[int, ...]
completed_frame_ids: tuple[int, ...]
cancelled_repeats: int
replaced_state: int
mean_queue_packets: float
max_queue_packets: int
mean_queue_bytes: float
max_queue_bytes: int
max_waiting_frames: int
drain_end_seconds: float
queue_release_seconds: float
@dataclass(frozen=True)
class SummaryMetrics:
channel_kbps: float
mean_bad_duration_ms: float
protection_mode: str
repetitions: int
emergency_load_kbps: float
control_load_kbps: float
telemetry_load_kbps: float
video_load_kbps: float
total_offered_load_kbps: float
channel_utilization_percent: float
mean_queue_packets: float
max_queue_packets: int
mean_queue_bytes: float
max_queue_bytes: int
max_waiting_video_frames: int
repeat_command_bytes: int
lost_packets_mean: float
lost_packet_bytes_mean: float
cancelled_control_repeats: int
replaced_state_packets: int
drain_end_seconds: float
queue_release_seconds: float
telemetry_delivered_fraction: float
telemetry_mean_age_ms: float
telemetry_last_message_age_ms: float
telemetry_max_gap_ms: float
telemetry_gaps_over_500ms_mean: float
@dataclass(frozen=True)
class CommandMetrics:
channel_kbps: float
mean_bad_duration_ms: float
protection_mode: str
original_commands: int
repeat_copies_created: int
delivered_unique_states_mean: float
states_with_all_copies_lost_mean: float
undelivered_state_fraction: float
mean_age_ms: float
p95_age_ms: float
max_age_ms: float
max_gap_without_fresh_command_ms: float
gaps_over_100ms_mean: float
maximum_consecutive_undelivered_states: int
cancelled_pending_repeats: int
suppressed_duplicates_mean: float
repetition_overhead_kbps: float
@dataclass(frozen=True)
class VideoMetrics:
channel_kbps: float
mean_bad_duration_ms: float
protection_mode: str
created_frames: int
scheduled_complete_frames: int
dropped_before_start_frames: int
published_frames_mean: float
published_fraction: float
update_fps: float
mean_image_age_ms: float
p95_image_age_ms: float
max_image_age_ms: float
mean_no_new_image_ms: float
max_no_new_image_ms: float
fec_recovered_blocks_mean: float
fec_unrecoverable_blocks_mean: float
fec_recovered_affected_fraction: float
delivered_useful_video_kbps: float
@dataclass(frozen=True)
class EmergencyMetrics:
channel_kbps: float
mean_bad_duration_ms: float
protection_mode: str
event_time_seconds: float
delivered_fraction: float
mean_first_delivery_ms: float
p95_first_delivery_ms: float
deadline_50ms_fraction: float
lost_copies_mean: float
duplicate_copies_mean: float
@dataclass(frozen=True)
class FunctionalTestResult:
name: str
passed: bool
detail: str
def percentile(values: Iterable[float], q: float) -> float:
values = tuple(values)
return float(np.percentile(values, q)) if values else 0.0
def _link(
traffic_class: TrafficClass,
direction: Direction,
stream_id: int,
sequence: int,
generation_us: int,
deadline_ms: int,
payload: bytes,
) -> LinkPacket:
return LinkPacket(
traffic_class=traffic_class,
direction=direction,
stream_id=stream_id,
sequence_number=sequence,
generation_time_us=generation_us,
deadline_ms=deadline_ms,
payload=payload,
)
def build_workload() -> Workload:
"""Build 120 seconds of fresh 512-byte, frame-aligned 12+3 video."""
lab033 = build_lab033_workload()
originals = lab033.composites
frames: list[Frame] = []
video_sequence = 0
block_id = 0
order = 0
crc_packets = 0
crc_blocks = 0
for frame_id in range(FRAME_COUNT):
original = originals[frame_id % len(originals)]
composite = EncodedComposite(
composite_frame_id=frame_id,
source_frame_index=original.source_frame_index,
base_jpeg=original.base_jpeg,
roi_jpeg=original.roi_jpeg,
)
inner = tuple(sum(packets_for_composite(composite, VIDEO_PAYLOAD_BYTES), []))
generation_us = int(round(frame_id / COMPOSITE_FPS * 1_000_000.0))
units: list[TxUnit] = []
for start in range(0, len(inner), SOURCE_BLOCK_SIZE):
source = inner[start : start + SOURCE_BLOCK_SIZE]
parity = PARITY_COUNT if len(source) == SOURCE_BLOCK_SIZE else parity_for_partial(len(source))
outer_packets = encode_fec_block(source, block_id, parity)
decoded = decode_fec_block(outer_packets)
assert decoded.source_packets == source
for outer_wire in outer_packets:
outer = decode_outer_symbol(outer_wire)
packet = _link(
TrafficClass.VIDEO,
Direction.ROVER_TO_GROUND,
STREAM_VIDEO,
video_sequence,
generation_us,
0,
outer_wire,
)
wire = encode_link_packet(packet)
decode_link_packet(wire)
units.append(
TxUnit(
packet,
wire,
generation_us,
order,
frame_id=frame_id,
block_id=block_id,
symbol_index=outer.symbol_index,
source_count=outer.source_count,
is_source=not outer.is_parity,
)
)
order += 1
video_sequence += 1
crc_packets += 1
crc_blocks += 1
block_id += 1
frames.append(
Frame(
frame_id,
generation_us,
tuple(units),
len(composite.base_jpeg) + len(composite.roi_jpeg),
)
)
controls = tuple(
_link(
TrafficClass.CONTROL,
Direction.GROUND_TO_ROVER,
STREAM_CONTROL,
sequence,
generation_us,
100,
deterministic_payload(b"CONTROL", sequence, 32),
)
for sequence, generation_us in enumerate(range(0, int(DURATION_SECONDS * 1_000_000), CONTROL_PERIOD_US))
)
emergencies = tuple(
_link(
TrafficClass.EMERGENCY,
Direction.GROUND_TO_ROVER,
STREAM_EMERGENCY,
sequence,
generation_us,
50,
deterministic_payload(b"E-STOP", sequence, 32),
)
for sequence, generation_us in enumerate(EMERGENCY_TIMES_US)
)
telemetry: list[TxUnit] = []
for sequence, generation_us in enumerate(range(0, int(DURATION_SECONDS * 1_000_000), TELEMETRY_PERIOD_US)):
packet = _link(
TrafficClass.TELEMETRY,
Direction.ROVER_TO_GROUND,
STREAM_TELEMETRY,
sequence,
generation_us,
500,
deterministic_payload(b"TELEM", sequence, 64),
)
wire = encode_link_packet(packet)
decode_link_packet(wire)
telemetry.append(TxUnit(packet, wire, generation_us, order))
order += 1
crc_packets += 1
for packet in controls + emergencies:
decode_link_packet(encode_link_packet(packet))
crc_packets += 1
return Workload(
tuple(frames),
controls,
tuple(telemetry),
emergencies,
len(originals),
crc_packets,
crc_blocks,
)
def _command_units(workload: Workload, mode: RepetitionMode) -> tuple[TxUnit, ...]:
copies = build_command_copies(
workload.controls + workload.emergencies,
mode,
first_arrival_order=10_000_000,
control_repeat_delay_ms=CONTROL_REPEAT_DELAY_MS,
)
units = []
for copy in copies:
wire = encode_link_packet(copy.packet)
units.append(
TxUnit(
copy.packet,
wire,
copy.available_time_us,
copy.arrival_order,
copy.copy_index,
)
)
return tuple(units)
def _queue_statistics(transmitted: list[Sent], removed: list[Removed]) -> tuple[float, int, float, int]:
events: dict[float, list[int]] = {}
packet_area = byte_area = 0.0
for sent in transmitted:
start = sent.unit.available_seconds
end = min(sent.end_seconds, DURATION_SECONDS)
if end > start:
duration = end - start
packet_area += duration
byte_area += duration * sent.unit.wire_size_bytes
events.setdefault(start, [0, 0])
events.setdefault(end, [0, 0])
events[start][0] += 1
events[start][1] += sent.unit.wire_size_bytes
events[end][0] -= 1
events[end][1] -= sent.unit.wire_size_bytes
for item in removed:
start = item.unit.available_seconds
end = min(item.time_seconds, DURATION_SECONDS)
if end > start:
duration = end - start
packet_area += duration
byte_area += duration * item.unit.wire_size_bytes
events.setdefault(start, [0, 0])
events.setdefault(end, [0, 0])
events[start][0] += 1
events[start][1] += item.unit.wire_size_bytes
events[end][0] -= 1
events[end][1] -= item.unit.wire_size_bytes
current = maximum = current_bytes = maximum_bytes = 0
for moment in sorted(events):
current += events[moment][0]
current_bytes += events[moment][1]
maximum = max(maximum, current)
maximum_bytes = max(maximum_bytes, current_bytes)
return packet_area / DURATION_SECONDS, maximum, byte_area / DURATION_SECONDS, maximum_bytes
def schedule(workload: Workload, rate_kbps: float, mode: RepetitionMode) -> Schedule:
high = sorted(
list(_command_units(workload, mode)) + list(workload.telemetry),
key=lambda item: (item.available_time_us, int(item.packet.traffic_class), item.arrival_order),
)
ready: list[TxUnit] = []
pending_frames: list[Frame] = []
transmitted: list[Sent] = []
removed: list[Removed] = []
dropped: list[int] = []
started: list[int] = []
completed: list[int] = []
cursor = 0.0
high_index = frame_index = active_index = 0
active: Frame | None = None
latest_control_seen = -1
cancelled_repeats = replaced_state = max_waiting = 0
def admit(now: float) -> None:
nonlocal high_index, frame_index, latest_control_seen, cancelled_repeats, replaced_state, max_waiting
while high_index < len(high) and high[high_index].available_seconds <= now + EPSILON:
item = high[high_index]
high_index += 1
traffic = item.packet.traffic_class
if traffic is TrafficClass.CONTROL:
if item.copy_index == 0:
latest_control_seen = max(latest_control_seen, item.packet.sequence_number)
retained = []
for old in ready:
if old.packet.traffic_class is TrafficClass.CONTROL and old.packet.sequence_number < item.packet.sequence_number:
reason = "cancelled_repeat" if old.is_repeat else "replaced_state"
removed.append(Removed(old, item.available_seconds, reason))
cancelled_repeats += int(old.is_repeat)
replaced_state += int(not old.is_repeat)
else:
retained.append(old)
ready[:] = retained
elif item.packet.sequence_number < latest_control_seen:
removed.append(Removed(item, item.available_seconds, "cancelled_repeat"))
cancelled_repeats += 1
continue
elif traffic is TrafficClass.TELEMETRY:
retained = []
for old in ready:
if old.packet.traffic_class is TrafficClass.TELEMETRY:
removed.append(Removed(old, item.available_seconds, "replaced_telemetry"))
else:
retained.append(old)
ready[:] = retained
ready.append(item)
while frame_index < len(workload.frames) and workload.frames[frame_index].generation_seconds <= now + EPSILON:
frame = workload.frames[frame_index]
frame_index += 1
for old in pending_frames:
dropped.append(old.frame_id)
removed.extend(Removed(unit, frame.generation_seconds, "newest_frame") for unit in old.packets)
pending_frames[:] = [frame]
max_waiting = max(max_waiting, len(pending_frames))
while high_index < len(high) or frame_index < len(workload.frames) or ready or pending_frames or active is not None:
if not ready and not pending_frames and active is None:
candidates = []
if high_index < len(high):
candidates.append(high[high_index].available_seconds)
if frame_index < len(workload.frames):
candidates.append(workload.frames[frame_index].generation_seconds)
cursor = max(cursor, min(candidates))
admit(cursor)
if ready:
unit = min(ready, key=lambda item: (int(item.packet.traffic_class), item.arrival_order))
ready.remove(unit)
else:
if active is None and pending_frames:
active = pending_frames.pop(0)
active_index = 0
started.append(active.frame_id)
if active is None:
continue
unit = active.packets[active_index]
start = max(cursor, unit.available_seconds)
end = start + unit.wire_size_bytes * 8.0 / (rate_kbps * 1000.0)
transmitted.append(Sent(unit, start, end))
admit(end)
cursor = end
if unit.packet.traffic_class is TrafficClass.VIDEO:
active_index += 1
assert active is not None
if active_index == len(active.packets):
completed.append(active.frame_id)
active = None
active_index = 0
mean_queue, max_queue, mean_queue_bytes, max_queue_bytes = _queue_statistics(transmitted, removed)
drain = transmitted[-1].end_seconds if transmitted else 0.0
return Schedule(
rate_kbps,
mode,
tuple(transmitted),
tuple(removed),
tuple(dropped),
tuple(started),
tuple(completed),
cancelled_repeats,
replaced_state,
mean_queue,
max_queue,
mean_queue_bytes,
max_queue_bytes,
max_waiting,
drain,
max(0.0, drain - DURATION_SECONDS),
)
def bad_intervals(end_seconds: float, mean_bad_ms: float, seed: int) -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(seed)
mean_bad = mean_bad_ms / 1000.0
mean_good = mean_bad * (1.0 - BAD_TIME_FRACTION) / BAD_TIME_FRACTION
starts: list[float] = []
ends: list[float] = []
cursor = float(rng.exponential(mean_good))
while cursor < end_seconds:
end = min(end_seconds, cursor + float(rng.exponential(mean_bad)))
starts.append(cursor)
ends.append(end)
cursor = end + float(rng.exponential(mean_good))
return np.asarray(starts), np.asarray(ends)
def loss_flags(schedule_: Schedule, starts: np.ndarray, ends: np.ndarray) -> np.ndarray:
sent_starts = np.fromiter((item.start_seconds for item in schedule_.transmitted), dtype=float)
sent_ends = np.fromiter((item.end_seconds for item in schedule_.transmitted), dtype=float)
if not len(starts):
return np.zeros(len(sent_starts), dtype=bool)
indices = np.searchsorted(ends, sent_starts, side="right")
valid = indices < len(starts)
flags = np.zeros(len(sent_starts), dtype=bool)
flags[valid] = starts[indices[valid]] < sent_ends[valid] - EPSILON
return flags
def positive_run_max(flags: Iterable[bool]) -> int:
best = current = 0
for flag in flags:
current = current + 1 if flag else 0
best = max(best, current)
return best
def _write_csv(path: Path, row_type: type, rows: Iterable[object]) -> None:
rows = tuple(rows)
with path.open("w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=tuple(row_type.__dataclass_fields__))
writer.writeheader()
writer.writerows(asdict(row) for row in rows)
def simulate_condition(
workload: Workload,
schedule_: Schedule,
mean_bad_ms: float,
rate_index: int,
duration_index: int,
) -> tuple[SummaryMetrics, CommandMetrics, VideoMetrics, tuple[EmergencyMetrics, ...]]:
sent = schedule_.transmitted
units = tuple(item.unit for item in sent)
ends = np.asarray([item.end_seconds for item in sent])
sizes = np.asarray([item.unit.wire_size_bytes for item in sent], dtype=np.int64)
control_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.CONTROL]
telemetry_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.TELEMETRY]
emergency_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.EMERGENCY]
video_indices = [i for i, unit in enumerate(units) if unit.packet.traffic_class is TrafficClass.VIDEO]
blocks: dict[int, list[int]] = {}
frame_blocks: dict[int, set[int]] = {}
for index in video_indices:
unit = units[index]
assert unit.block_id is not None and unit.frame_id is not None
blocks.setdefault(unit.block_id, []).append(index)
frame_blocks.setdefault(unit.frame_id, set()).add(unit.block_id)
command_ages: list[float] = []
image_ages: list[float] = []
control_gaps: list[float] = []
image_gaps: list[float] = []
delivered_states = all_lost = duplicate_total = gaps_over_100 = 0
max_missing_run = 0
published_total = recovered_total = unrecoverable_total = 0
useful_video_bytes = lost_bytes = lost_packets_total = 0
telemetry_delivered = telemetry_gaps_over_500 = 0
telemetry_ages: list[float] = []
telemetry_last_ages: list[float] = []
telemetry_gaps_all: list[float] = []
emergency_delays: list[list[float]] = [[] for _ in EMERGENCY_TIMES_US]
emergency_lost_copies = [0] * len(EMERGENCY_TIMES_US)
emergency_duplicates = [0] * len(EMERGENCY_TIMES_US)
for repetition in range(REPETITIONS):
seed = MASTER_SEED + rate_index * 100_000 + duration_index * 1_000 + repetition
bad_starts, bad_ends = bad_intervals(schedule_.drain_end_seconds, mean_bad_ms, seed)
lost = loss_flags(schedule_, bad_starts, bad_ends)
lost_packets_total += int(lost.sum())
lost_bytes += int(sizes[lost].sum())
receiver = CommandReceiver()
received_control_times: list[float] = []
delivered_sequences: set[int] = set()
for index in control_indices:
if lost[index]:
continue
unit = units[index]
if receiver.accept(unit.packet):
delivered_sequences.add(unit.packet.sequence_number)
received_control_times.append(ends[index])
command_ages.append((ends[index] - unit.packet.generation_time_us / 1_000_000.0) * 1000.0)
delivered_states += len(delivered_sequences)
missing = [sequence not in delivered_sequences for sequence in range(len(workload.controls))]
all_lost += sum(missing)
max_missing_run = max(max_missing_run, positive_run_max(missing))
duplicate_total += receiver.suppressed
gaps = np.diff(np.asarray([0.0] + received_control_times + [DURATION_SECONDS])) * 1000.0
control_gaps.extend(gaps.tolist())
gaps_over_100 += int(np.count_nonzero(gaps > 100.0 + EPSILON))
telemetry_times: list[float] = []
telemetry_sequences: list[int] = []
for index in telemetry_indices:
if not lost[index]:
unit = units[index]
telemetry_delivered += 1
telemetry_times.append(ends[index])
telemetry_sequences.append(unit.packet.sequence_number)
telemetry_ages.append((ends[index] - unit.available_seconds) * 1000.0)
telemetry_gaps = np.diff(np.asarray([0.0] + telemetry_times + [DURATION_SECONDS])) * 1000.0
telemetry_gaps_all.extend(telemetry_gaps.tolist())
telemetry_gaps_over_500 += int(np.count_nonzero(telemetry_gaps > 500.0 + EPSILON))
if telemetry_sequences:
last_generation = telemetry_sequences[-1] * TELEMETRY_PERIOD_US / 1_000_000.0
telemetry_last_ages.append((DURATION_SECONDS - last_generation) * 1000.0)
else:
telemetry_last_ages.append(DURATION_SECONDS * 1000.0)
by_emergency: dict[int, list[int]] = {i: [] for i in range(len(EMERGENCY_TIMES_US))}
for index in emergency_indices:
by_emergency[units[index].packet.sequence_number].append(index)
for event, indices in by_emergency.items():
successes = [index for index in indices if not lost[index]]
emergency_lost_copies[event] += len(indices) - len(successes)
if successes:
first = min(successes, key=lambda index: ends[index])
emergency_delays[event].append(
(ends[first] - EMERGENCY_TIMES_US[event] / 1_000_000.0) * 1000.0
)
emergency_duplicates[event] += len(successes) - 1
block_ok: dict[int, bool] = {}
block_time: dict[int, float] = {}
for block_id, indices in blocks.items():
received = [index for index in indices if not lost[index]]
source_count = units[indices[0]].source_count
ok = len(received) >= source_count
block_ok[block_id] = ok
if ok:
ordered = sorted(received, key=lambda index: ends[index])
block_time[block_id] = ends[ordered[source_count - 1]]
missing_source = any(lost[index] for index in indices if units[index].is_source)
recovered_total += int(missing_source)
else:
unrecoverable_total += 1
publications: list[tuple[float, int]] = []
for frame_id in schedule_.completed_frame_ids:
ids = frame_blocks.get(frame_id, set())
if ids and all(block_ok[block_id] for block_id in ids):
publication = max(block_time[block_id] for block_id in ids)
publications.append((publication, frame_id))
image_ages.append((publication - workload.frames[frame_id].generation_seconds) * 1000.0)
useful_video_bytes += workload.frames[frame_id].jpeg_bytes
publications.sort()
published_total += len(publications)
gaps = np.diff(np.asarray([0.0] + [time for time, _ in publications] + [DURATION_SECONDS])) * 1000.0
image_gaps.extend(gaps.tolist())
mode_name = MODE_NAMES[schedule_.mode]
command_units = [unit for unit in units if unit.packet.traffic_class in (TrafficClass.CONTROL, TrafficClass.EMERGENCY)]
repeat_units = [unit for unit in command_units if unit.is_repeat]
original_command_bytes = sum(len(encode_link_packet(packet)) for packet in workload.controls)
control_bytes = sum(unit.wire_size_bytes for unit in command_units if unit.packet.traffic_class is TrafficClass.CONTROL)
emergency_bytes = sum(unit.wire_size_bytes for unit in command_units if unit.packet.traffic_class is TrafficClass.EMERGENCY)
telemetry_bytes = sum(unit.wire_size_bytes for unit in units if unit.packet.traffic_class is TrafficClass.TELEMETRY)
video_bytes = sum(unit.wire_size_bytes for unit in units if unit.packet.traffic_class is TrafficClass.VIDEO)
total_bytes = control_bytes + emergency_bytes + telemetry_bytes + video_bytes
sent_by_source_end = sum(item.unit.wire_size_bytes for item in sent if item.end_seconds <= DURATION_SECONDS + EPSILON)
summary = SummaryMetrics(
schedule_.rate_kbps,
mean_bad_ms,
mode_name,
REPETITIONS,
emergency_bytes * 8.0 / DURATION_SECONDS / 1000.0,
control_bytes * 8.0 / DURATION_SECONDS / 1000.0,
telemetry_bytes * 8.0 / DURATION_SECONDS / 1000.0,
video_bytes * 8.0 / DURATION_SECONDS / 1000.0,
total_bytes * 8.0 / DURATION_SECONDS / 1000.0,
sent_by_source_end * 8.0 / (schedule_.rate_kbps * 1000.0 * DURATION_SECONDS) * 100.0,
schedule_.mean_queue_packets,
schedule_.max_queue_packets,
schedule_.mean_queue_bytes,
schedule_.max_queue_bytes,
schedule_.max_waiting_frames,
sum(unit.wire_size_bytes for unit in repeat_units),
lost_packets_total / REPETITIONS,
lost_bytes / REPETITIONS,
schedule_.cancelled_repeats,
schedule_.replaced_state,
schedule_.drain_end_seconds,
schedule_.queue_release_seconds,
telemetry_delivered / (REPETITIONS * len(workload.telemetry)),
float(np.mean(telemetry_ages)) if telemetry_ages else 0.0,
float(np.mean(telemetry_last_ages)),
max(telemetry_gaps_all, default=0.0),
telemetry_gaps_over_500 / REPETITIONS,
)
command = CommandMetrics(
schedule_.rate_kbps,
mean_bad_ms,
mode_name,
len(workload.controls),
sum(1 for unit in command_units if unit.packet.traffic_class is TrafficClass.CONTROL and unit.is_repeat),
delivered_states / REPETITIONS,
all_lost / REPETITIONS,
all_lost / (REPETITIONS * len(workload.controls)),
float(np.mean(command_ages)) if command_ages else 0.0,
percentile(command_ages, 95),
max(command_ages, default=0.0),
max(control_gaps, default=0.0),
gaps_over_100 / REPETITIONS,
max_missing_run,
schedule_.cancelled_repeats,
duplicate_total / REPETITIONS,
max(0, control_bytes - original_command_bytes) * 8.0 / DURATION_SECONDS / 1000.0,
)
affected = recovered_total + unrecoverable_total
video = VideoMetrics(
schedule_.rate_kbps,
mean_bad_ms,
mode_name,
FRAME_COUNT,
len(schedule_.completed_frame_ids),
len(schedule_.dropped_frame_ids),
published_total / REPETITIONS,
published_total / (REPETITIONS * FRAME_COUNT),
published_total / (REPETITIONS * DURATION_SECONDS),
float(np.mean(image_ages)) if image_ages else 0.0,
percentile(image_ages, 95),
max(image_ages, default=0.0),
float(np.mean(image_gaps)) if image_gaps else 0.0,
max(image_gaps, default=0.0),
recovered_total / REPETITIONS,
unrecoverable_total / REPETITIONS,
recovered_total / affected if affected else 1.0,
useful_video_bytes * 8.0 / (REPETITIONS * DURATION_SECONDS * 1000.0),
)
emergency_rows = []
copies_per_event = 1 if schedule_.mode is RepetitionMode.NONE else 3
for event, event_us in enumerate(EMERGENCY_TIMES_US):
delays = emergency_delays[event]
emergency_rows.append(
EmergencyMetrics(
schedule_.rate_kbps,
mean_bad_ms,
mode_name,
event_us / 1_000_000.0,
len(delays) / REPETITIONS,
float(np.mean(delays)) if delays else 0.0,
percentile(delays, 95),
sum(delay <= 50.0 + EPSILON for delay in delays) / REPETITIONS,
emergency_lost_copies[event] / REPETITIONS,
emergency_duplicates[event] / REPETITIONS,
)
)
assert emergency_lost_copies[event] <= REPETITIONS * copies_per_event
return summary, command, video, tuple(emergency_rows)
def run_experiment(workload: Workload) -> tuple[
tuple[SummaryMetrics, ...],
tuple[CommandMetrics, ...],
tuple[VideoMetrics, ...],
tuple[EmergencyMetrics, ...],
dict[tuple[float, RepetitionMode], Schedule],
]:
schedules = {
(rate, mode): schedule(workload, rate, mode)
for rate in CHANNEL_RATES_KBPS
for mode in MODES
}
summaries: list[SummaryMetrics] = []
commands: list[CommandMetrics] = []
videos: list[VideoMetrics] = []
emergencies: list[EmergencyMetrics] = []
for rate_index, rate in enumerate(CHANNEL_RATES_KBPS):
for duration_index, mean_bad in enumerate(MEAN_BAD_DURATIONS_MS):
for mode in MODES:
result = simulate_condition(workload, schedules[(rate, mode)], mean_bad, rate_index, duration_index)
summaries.append(result[0])
commands.append(result[1])
videos.append(result[2])
emergencies.extend(result[3])
print(f"rate={rate:.0f} bad={mean_bad:.0f} mode={MODE_NAMES[mode]}")
return tuple(summaries), tuple(commands), tuple(videos), tuple(emergencies), schedules
def run_functional_tests(workload: Workload, schedules: dict[tuple[float, RepetitionMode], Schedule]) -> tuple[FunctionalTestResult, ...]:
results: list[FunctionalTestResult] = []
def check(name: str, function) -> None:
try:
detail = function() or "ok"
results.append(FunctionalTestResult(name, True, str(detail)))
except Exception as error: # report every required check together
results.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}"))
def no_error_matches_lab036() -> str:
item = schedules[(300.0, RepetitionMode.NONE)]
assert len(item.started_frame_ids) == len(item.completed_frame_ids)
assert item.max_waiting_frames <= 1
assert item.drain_end_seconds <= DURATION_SECONDS + 1.0 / COMPOSITE_FPS
return "Lab036 latest-only/non-preemptive invariants retained"
def corrupt_link_crc() -> str:
wire = bytearray(encode_link_packet(workload.controls[0]))
wire[-1] ^= 1
try:
decode_link_packet(bytes(wire))
except LinkPacketCRCError:
return "common packet rejected"
raise AssertionError("corrupt link packet accepted")
def corrupt_outer_crc() -> str:
wire = bytearray(workload.frames[0].packets[0].packet.payload)
wire[-1] ^= 1
try:
decode_outer_symbol(bytes(wire))
except OuterPacketCRCError:
return "outer video packet rejected"
raise AssertionError("corrupt outer packet accepted")
def fec_recovers() -> str:
block = [unit.packet.payload for unit in workload.frames[0].packets if unit.block_id == workload.frames[0].packets[0].block_id]
decoded = decode_fec_block(tuple(block[3:]))
assert len(decoded.recovered_indices) == 3
for inner in decoded.source_packets:
decode_inner_packet(inner)
return "three erased source symbols restored"
def monotonic_receiver() -> str:
receiver = CommandReceiver()
assert receiver.accept(workload.controls[1])
assert not receiver.accept(workload.controls[0])
assert not receiver.accept(workload.controls[1])
return "old/equal sequence suppressed"
def copies_same_identity() -> str:
copies = build_command_copies((workload.emergencies[0],), RepetitionMode.EMERGENCY_ONLY)
assert len({encode_link_packet(copy.packet) for copy in copies}) == 1
return "three copies encode identically"
def late_old_does_not_rollback() -> str:
receiver = CommandReceiver()
assert receiver.accept(workload.controls[10])
assert not receiver.accept(workload.controls[9])
assert receiver.last_sequence(STREAM_CONTROL) == 10
return "newer state retained"
def only_unstarted_cancelled() -> str:
for item in schedules.values():
transmitted_orders = {sent.unit.arrival_order for sent in item.transmitted}
removed_orders = {removed.unit.arrival_order for removed in item.removed}
assert transmitted_orders.isdisjoint(removed_orders)
return "transmitted and removed sets are disjoint"
def emergencies_not_removed() -> str:
assert all(
removed.unit.packet.traffic_class is not TrafficClass.EMERGENCY
for item in schedules.values()
for removed in item.removed
)
return "no emergency removal"
def nonpreemptive() -> str:
for item in schedules.values():
assert all(a.end_seconds <= b.start_seconds + EPSILON for a, b in zip(item.transmitted, item.transmitted[1:]))
return "serialization intervals do not overlap"
def repeats_accounted() -> str:
mode3 = schedules[(300.0, RepetitionMode.EMERGENCY_AND_CONTROL)]
assert any(sent.unit.is_repeat for sent in mode3.transmitted)
assert mode3.cancelled_repeats >= 0
return "repeat copies present in service/removal accounting"
def latest_video_bounded() -> str:
assert all(item.max_waiting_frames <= 1 for item in schedules.values())
return "at most one unstarted frame"
def incomplete_not_published() -> str:
item = schedules[(300.0, RepetitionMode.NONE)]
starts = np.asarray([0.0])
ends = np.asarray([item.drain_end_seconds])
flags = loss_flags(item, starts, ends)
assert flags.all()
return "all-lost schedule yields no decodable block"
def priority_between_video() -> str:
found = False
for item in schedules.values():
sent = item.transmitted
for left, middle, right in zip(sent, sent[1:], sent[2:]):
if left.unit.frame_id is not None and middle.unit.frame_id is None and right.unit.frame_id == left.unit.frame_id:
found = True
break
assert found
return "high priority traffic served between video packets"
def reproducible() -> str:
a = bad_intervals(120.0, 200.0, MASTER_SEED)
b = bad_intervals(120.0, 200.0, MASTER_SEED)
assert np.array_equal(a[0], b[0]) and np.array_equal(a[1], b[1])
return "identical seed gives identical intervals"
checks = (
("01_no_error_matches_lab036", no_error_matches_lab036),
("02_common_crc_rejects_corruption", corrupt_link_crc),
("03_outer_crc_rejects_corruption", corrupt_outer_crc),
("04_fec_restores_allowed_losses", fec_recovers),
("05_receiver_requires_newer_sequence", monotonic_receiver),
("06_copies_share_command_identity", copies_same_identity),
("07_late_old_copy_no_rollback", late_old_does_not_rollback),
("08_only_unstarted_copy_cancelled", only_unstarted_cancelled),
("09_emergency_never_removed", emergencies_not_removed),
("10_current_packet_nonpreemptive", nonpreemptive),
("11_repeats_in_queue_and_load", repeats_accounted),
("12_latest_video_queue_bounded", latest_video_bounded),
("13_incomplete_frame_not_published", incomplete_not_published),
("14_priority_between_video_packets", priority_between_video),
("15_fixed_seed_reproducibility", reproducible),
)
for name, function in checks:
check(name, function)
return tuple(results)
def save_plots(
summaries: tuple[SummaryMetrics, ...],
commands: tuple[CommandMetrics, ...],
videos: tuple[VideoMetrics, ...],
emergencies: tuple[EmergencyMetrics, ...],
) -> None:
colors = {"none": "#777777", "emergency_only": "#e67e22", "emergency_and_control": "#2878b5"}
def lines(rows, value, ylabel, title, path):
figure, axis = plt.subplots(figsize=(9, 5))
for rate in CHANNEL_RATES_KBPS:
for mode in MODE_NAMES.values():
selected = [row for row in rows if row.channel_kbps == rate and row.protection_mode == mode]
axis.plot(
[row.mean_bad_duration_ms for row in selected],
[value(row) for row in selected],
marker="o",
color=colors[mode],
linestyle={300.0: "-", 260.0: "--", 230.0: ":"}[rate],
label=f"{rate:.0f}, {mode}",
)
axis.set_xscale("log")
axis.set_xlabel("Mean Bad duration, ms")
axis.set_ylabel(ylabel)
axis.set_title(title)
axis.grid(True, alpha=0.3)
axis.legend(fontsize=7, ncol=3)
figure.tight_layout()
figure.savefig(path, dpi=150)
plt.close(figure)
lines(commands, lambda row: row.max_gap_without_fresh_command_ms, "ms", "Maximum gap without fresh command", COMMAND_GAP_PLOT)
emergency_average = []
for rate in CHANNEL_RATES_KBPS:
for bad in MEAN_BAD_DURATIONS_MS:
for mode in MODE_NAMES.values():
rows = [row for row in emergencies if row.channel_kbps == rate and row.mean_bad_duration_ms == bad and row.protection_mode == mode]
emergency_average.append(replace(rows[0], deadline_50ms_fraction=float(np.mean([row.deadline_50ms_fraction for row in rows]))))
lines(tuple(emergency_average), lambda row: row.deadline_50ms_fraction * 100.0, "%", "Emergency delivery within 50 ms", EMERGENCY_PLOT)
lines(commands, lambda row: row.p95_age_ms, "ms", "P95 command age", COMMAND_AGE_PLOT)
lines(videos, lambda row: row.p95_image_age_ms, "ms", "P95 image age", IMAGE_AGE_PLOT)
lines(videos, lambda row: row.published_fraction * 100.0, "%", "Published composite frames", VIDEO_PLOT)
figure, axes = plt.subplots(1, 2, figsize=(12, 5))
selected = [row for row in summaries if row.mean_bad_duration_ms == 200.0]
labels = [f"{row.channel_kbps:.0f}\n{row.protection_mode}" for row in selected]
axes[0].bar(range(len(selected)), [row.total_offered_load_kbps for row in selected], color=[colors[row.protection_mode] for row in selected])
axes[0].set_ylabel("kbit/s")
axes[0].set_title("Offered load")
axes[1].bar(range(len(selected)), [row.max_queue_packets for row in selected], color=[colors[row.protection_mode] for row in selected])
axes[1].set_ylabel("packets")
axes[1].set_title("Maximum queue")
for axis in axes:
axis.set_xticks(range(len(labels)), labels, rotation=45, ha="right", fontsize=7)
axis.grid(True, axis="y", alpha=0.3)
figure.tight_layout()
figure.savefig(LOAD_QUEUE_PLOT, dpi=150)
plt.close(figure)
selected = [row for row in commands if row.channel_kbps == 260.0 and row.mean_bad_duration_ms == 200.0]
figure, axis1 = plt.subplots(figsize=(8, 5))
x = np.arange(len(selected))
axis1.bar(x, [row.undelivered_state_fraction * 100.0 for row in selected], color=[colors[row.protection_mode] for row in selected])
axis1.set_ylabel("Undelivered states, %")
axis2 = axis1.twinx()
axis2.plot(x, [row.repetition_overhead_kbps for row in selected], color="black", marker="o")
axis2.set_ylabel("Repeat overhead, kbit/s")
axis1.set_xticks(x, [row.protection_mode for row in selected])
axis1.set_title("Protection trade-off (260 kbit/s, Bad=200 ms)")
figure.tight_layout()
figure.savefig(COMPARISON_PLOT, dpi=150)
plt.close(figure)
def write_report(
workload: Workload,
summaries: tuple[SummaryMetrics, ...],
commands: tuple[CommandMetrics, ...],
videos: tuple[VideoMetrics, ...],
emergencies: tuple[EmergencyMetrics, ...],
tests: tuple[FunctionalTestResult, ...],
) -> None:
lines = [
"Lab037 — полный транспорт с ошибками канала и повторением команд",
"",
"Состояние Git до реализации",
"- Ветка: main",
"- HEAD: 3e0ef5666eed6833818a731383220b2e15926889",
"- Рабочее дерево: чистое.",
"- fetch, pull и push не выполнялись.",
"",
"Параметры модели",
f"- Длительность: {DURATION_SECONDS:.0f} с; кадров: {FRAME_COUNT}; частота: {COMPOSITE_FPS:.0f} кадр/с.",
f"- Видео: payload {VIDEO_PAYLOAD_BYTES} байт, FEC {SOURCE_BLOCK_SIZE}+{PARITY_COUNT}, блоки выровнены по кадрам.",
f"- Команды: 20/с; телеметрия: 10/с; аварийные события: 30, 60, 90 с.",
f"- Канал: Bad fraction={BAD_TIME_FRACTION:.2%}, Bad mean={MEAN_BAD_DURATIONS_MS}, rates={CHANNEL_RATES_KBPS}, repeats={REPETITIONS}.",
f"- Master seed: {MASTER_SEED}; обычный повтор режима 3: {CONTROL_REPEAT_DELAY_MS:.1f} мс.",
f"- Проверено CRC-пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}; профиль: {workload.source_profile_frames} кадров.",
"- Копия команды сохраняет тот же stream_id, sequence_number и байты; задержка хранится только в метаданных планировщика.",
"",
"Таблица 36 сочетаний",
"rate | Bad ms | mode | load kbit/s | util % | queue mean/max | command miss % | command gap ms | emergency <=50 ms % | video % | image P95 ms",
]
command_lookup = {(row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode): row for row in commands}
video_lookup = {(row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode): row for row in videos}
for summary in summaries:
key = (summary.channel_kbps, summary.mean_bad_duration_ms, summary.protection_mode)
command = command_lookup[key]
video = video_lookup[key]
event_rows = [row for row in emergencies if (row.channel_kbps, row.mean_bad_duration_ms, row.protection_mode) == key]
timely = np.mean([row.deadline_50ms_fraction for row in event_rows]) * 100.0
lines.append(
f"{summary.channel_kbps:.0f} | {summary.mean_bad_duration_ms:.0f} | {summary.protection_mode} | "
f"{summary.total_offered_load_kbps:.3f} | {summary.channel_utilization_percent:.2f} | "
f"{summary.mean_queue_packets:.2f}/{summary.max_queue_packets} | "
f"{command.undelivered_state_fraction * 100.0:.3f} | {command.max_gap_without_fresh_command_ms:.3f} | "
f"{timely:.2f} | {video.published_fraction * 100.0:.2f} | {video.p95_image_age_ms:.3f}"
)
best = min(commands, key=lambda row: (row.undelivered_state_fraction, row.repetition_overhead_kbps))
lines.extend(
[
"",
"Обычные команды",
f"- Минимальная наблюдавшаяся доля недоставленных состояний: {best.undelivered_state_fraction:.6f} ({best.protection_mode}).",
f"- Наибольший промежуток без свежей команды: {max(row.max_gap_without_fresh_command_ms for row in commands):.3f} мс.",
"- Поздние/повторные копии подавляются строго монотонным sequence_number и не откатывают состояние.",
"",
"Аварийные команды",
f"- Доля своевременных доставок по всем строкам: {np.mean([row.deadline_50ms_fraction for row in emergencies]) * 100.0:.3f}%.",
f"- Максимальный P95 первой доставки: {max(row.p95_first_delivery_ms for row in emergencies):.3f} мс.",
"",
"Нагрузка, видео и очередь",
f"- Диапазон полной предложенной нагрузки: {min(row.total_offered_load_kbps for row in summaries):.3f}{max(row.total_offered_load_kbps for row in summaries):.3f} кбит/с.",
f"- Максимальная очередь: {max(row.max_queue_packets for row in summaries)} пакетов / {max(row.max_queue_bytes for row in summaries)} байт; ожидающих не начатых кадров: {max(row.max_waiting_video_frames for row in summaries)}.",
f"- Доля опубликованных кадров: {min(row.published_fraction for row in videos) * 100.0:.3f}{max(row.published_fraction for row in videos) * 100.0:.3f}%.",
f"- Полезный доставленный видеопоток: {min(row.delivered_useful_video_kbps for row in videos):.3f}{max(row.delivered_useful_video_kbps for row in videos):.3f} кбит/с.",
"- Повторение команд увеличивает предложенную нагрузку и учитывается в общей очереди; свежесть видео ограничивается политикой одного свежего не начатого кадра.",
"",
"Телеметрия",
f"- Доля доставленных уникальных сообщений: {min(row.telemetry_delivered_fraction for row in summaries) * 100.0:.3f}{max(row.telemetry_delivered_fraction for row in summaries) * 100.0:.3f}%.",
f"- Средний возраст последнего сообщения на отметке 120 с: {min(row.telemetry_last_message_age_ms for row in summaries):.3f}{max(row.telemetry_last_message_age_ms for row in summaries):.3f} мс.",
f"- Максимальный промежуток без обновления: {max(row.telemetry_max_gap_ms for row in summaries):.3f} мс; максимум интервалов >500 мс за опыт: {max(row.telemetry_gaps_over_500ms_mean for row in summaries):.3f}.",
"",
"Функциональные проверки",
]
)
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
lines.extend(
[
"",
"Созданные файлы",
"- protocol/control_repetition.py",
"- tests/lab037_lossy_full_link.py",
*[f"- {path.as_posix()}" for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS)],
"",
"Итоговый git status",
"?? data/processed/lab037/",
"?? protocol/control_repetition.py",
"?? tests/lab037_lossy_full_link.py",
"",
"Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.",
"Режим защиты автоматически не выбирается: все три режима представлены отдельно.",
]
)
REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
def validate_outputs(
summaries: tuple[SummaryMetrics, ...],
commands: tuple[CommandMetrics, ...],
videos: tuple[VideoMetrics, ...],
emergencies: tuple[EmergencyMetrics, ...],
tests: tuple[FunctionalTestResult, ...],
) -> None:
assert len(summaries) == len(commands) == len(videos) == 36
assert len(emergencies) == 108
assert all(item.passed for item in tests), [item for item in tests if not item.passed]
for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS):
assert path.is_file() and path.stat().st_size > 0, path
for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH):
path.read_text(encoding="utf-8")
def main() -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
workload = build_workload()
summaries, commands, videos, emergencies, schedules = run_experiment(workload)
tests = run_functional_tests(workload, schedules)
_write_csv(SUMMARY_CSV_PATH, SummaryMetrics, summaries)
_write_csv(COMMAND_CSV_PATH, CommandMetrics, commands)
_write_csv(VIDEO_CSV_PATH, VideoMetrics, videos)
_write_csv(EMERGENCY_CSV_PATH, EmergencyMetrics, emergencies)
save_plots(summaries, commands, videos, emergencies)
write_report(workload, summaries, commands, videos, emergencies, tests)
validate_outputs(summaries, commands, videos, emergencies, tests)
print(f"Lab037 complete: 36 conditions, {REPETITIONS} repetitions each")
for item in tests:
print(f"{'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}")
if __name__ == "__main__":
main()