Files
SDR-Rover/tests/lab041_session_restart_safety.py
2026-08-07 10:50:42 +03:00

1535 lines
82 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.
"""Lab041: сеансы связи, безопасный запуск и старые пакеты."""
from __future__ import annotations
import ast
import csv
from dataclasses import asdict, dataclass
from enum import IntEnum
import inspect
from pathlib import Path
import re
from typing import Iterable
import cv2
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from protocol.control_messages import (
COMMON_SIZE,
ControlMessageError,
ControlStateMessage,
EmergencyAck,
EmergencyStop,
MessageContext,
MessageType,
MovementAuthorizeAck,
MovementAuthorizeRequest,
ResetAck,
ResetRequest,
RoverSafetyCode,
SequenceComparison,
SessionHello,
SessionReject,
SessionRejectReason,
SessionStatus,
compare_sequence,
decode_message,
encode_message,
)
from protocol.link_packet import Direction, LinkPacket, TrafficClass, encode_link_packet
from protocol.session_state import (
GroundSessionController,
GroundSessionState,
RejectionCause,
RoverSessionController,
RoverSessionState,
deterministic_identifier,
)
OUTPUT_DIRECTORY = Path("data/processed/lab041")
SUMMARY_CSV = OUTPUT_DIRECTORY / "lab041_summary.csv"
SESSION_CSV = OUTPUT_DIRECTORY / "lab041_session_metrics.csv"
REJECTION_CSV = OUTPUT_DIRECTORY / "lab041_rejection_metrics.csv"
RESTART_CSV = OUTPUT_DIRECTORY / "lab041_restart_metrics.csv"
LOAD_CSV = OUTPUT_DIRECTORY / "lab041_load_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab041_report.txt"
PLOT_PATHS = (
OUTPUT_DIRECTORY / "lab041_session_time.png",
OUTPUT_DIRECTORY / "lab041_authorization_time.png",
OUTPUT_DIRECTORY / "lab041_old_packet_rejection.png",
OUTPUT_DIRECTORY / "lab041_restart_distance.png",
OUTPUT_DIRECTORY / "lab041_emergency_intent.png",
OUTPUT_DIRECTORY / "lab041_safety_violations.png",
OUTPUT_DIRECTORY / "lab041_protection_load.png",
OUTPUT_DIRECTORY / "lab041_traffic_impact.png",
)
REPETITIONS = 200
EXPERIMENT_SECONDS = 20.0
REPEAT_SECONDS = 0.050
BAD_FRACTION = 0.02
SEED_OFFSET = 410_000_000
CONTROL_STREAM_ID = 20
SESSION_STREAM_ID = 21
AUTH_STREAM_ID = 22
EMERGENCY_STREAM_ID = 23
RESET_STREAM_ID = 24
INITIAL_SPEED_MPS = 25.0 / 3.6
CONTROL_FREQUENCY_HZ = 20.0
CONTROL_MESSAGES_PER_EXPERIMENT = int(EXPERIMENT_SECONDS * CONTROL_FREQUENCY_HZ)
LAB033_HEADER_BYTES = 32
LAB040_CONTROL_PAYLOAD_BYTES = 14
LAB040_CONTROL_PACKET_BYTES = LAB033_HEADER_BYTES + LAB040_CONTROL_PAYLOAD_BYTES
LAB040_CONTROL_LOAD_KBPS = LAB040_CONTROL_PACKET_BYTES * CONTROL_FREQUENCY_HZ * 8.0 / 1000.0
VIDEO_OFFERED_LOAD_KBPS = 243.39286666666666
TELEMETRY_OFFERED_LOAD_KBPS = 7.68
class ArchitectureKind(IntEnum):
NO_SESSION_IDS = 1
SESSION_SAFE_BOOT = 2
PERSISTENT_EMERGENCY = 3
@dataclass(frozen=True)
class Architecture:
kind: ArchitectureKind
name: str
label: str
uses_session_ids: bool
safe_boot: bool
persists_emergency_intent: bool
ARCHITECTURES = (
Architecture(ArchitectureKind.NO_SESSION_IDS, "no_session_ids", "Без идентификаторов сеанса", False, False, False),
Architecture(ArchitectureKind.SESSION_SAFE_BOOT, "session_safe_boot", "Идентификаторы и безопасный запуск", True, True, False),
Architecture(
ArchitectureKind.PERSISTENT_EMERGENCY,
"persistent_emergency",
"Идентификаторы, безопасный запуск и сохранённое намерение",
True,
True,
True,
),
)
ARCHITECTURE_LABELS = {item.name: item.label for item in ARCHITECTURES}
@dataclass(frozen=True)
class ChannelCondition:
name: str
label: str
rate_kbps: float
mean_bad_ms: float | None
repetitions: int
CHANNELS = (
ChannelCondition("no_loss", "Без потерь", 300.0, None, 1),
ChannelCondition("260_200", "260 кбит/с, помеха 200 мс", 260.0, 200.0, REPETITIONS),
ChannelCondition("230_1000", "230 кбит/с, помеха 1000 мс", 230.0, 1000.0, REPETITIONS),
)
CHANNEL_LABELS = {item.name: item.label for item in CHANNELS}
@dataclass(frozen=True)
class Scenario:
number: int
name: str
label: str
SCENARIOS = (
Scenario(1, "normal_start", "Обычный запуск обеих сторон"),
Scenario(2, "ground_restart_moving", "Перезапуск наземной станции при движении"),
Scenario(3, "rover_restart_moving", "Перезапуск ровера при движении"),
Scenario(4, "both_restart", "Одновременный перезапуск"),
Scenario(5, "ground_restart_emergency", "Перезапуск наземной станции при аварийном намерении"),
Scenario(6, "rover_restart_latched", "Перезапуск ровера в аварийной фиксации"),
Scenario(7, "long_link_break", "Длительный перерыв связи"),
Scenario(8, "old_ground_session_control", "Старая команда прежнего сеанса станции"),
Scenario(9, "old_rover_boot_control", "Старая команда прежнего запуска ровера"),
Scenario(10, "old_control_epoch", "Старый период управления"),
Scenario(11, "sequence_reset_new_session", "Номер сообщения с нуля в новом сеансе"),
Scenario(12, "sequence_wrap", "Переполнение 32-битного номера"),
Scenario(13, "duplicate_hello", "Дубликат приветствия сеанса"),
Scenario(14, "lost_session_status", "Потеря состояния сеанса"),
Scenario(15, "lost_authorize_ack", "Потеря подтверждения разрешения"),
Scenario(16, "lost_emergency_ack", "Потеря подтверждения аварии"),
Scenario(17, "lost_reset_ack", "Потеря подтверждения сброса"),
Scenario(18, "old_reset_request", "Старый запрос сброса"),
Scenario(19, "late_positive_after_emergency", "Поздняя положительная команда после аварии"),
Scenario(20, "restart_after_reset_before_ack", "Перезапуск после сброса до подтверждения"),
)
SCENARIO_LABELS = {item.name: item.label for item in SCENARIOS}
@dataclass(frozen=True)
class PairExchange:
completed: bool
completion_seconds: float
request_count: int
response_count: int
lost_count: int
duplicate_requests: int
transmitted_bytes: int
retry_bytes: int
@dataclass(frozen=True)
class TrialMetrics:
safe_session_established: int
movement_authorized: int
unsafe_event: int
hello_count: int
status_count: int
lost_messages: int
retry_messages: int
duplicate_messages: int
session_rejects: int
safe_sync_seconds: float
ground_safe_seconds: float
rover_safe_seconds: float
authorize_ack_seconds: float
authorization_repeats: int
authorization_acks_lost: int
ack_to_operator_seconds: float
actual_motion_seconds: float
movement_before_sync: int
movement_before_authorize_ack: int
movement_without_new_operator: int
automatic_old_command_restorations: int
old_control_epoch_uses: int
old_packets_injected: int
old_packets_rejected: int
old_packets_erroneously_accepted: int
rejected_ground_session: int
rejected_rover_boot: int
rejected_control_epoch: int
rejected_sequence: int
rejected_emergency_event: int
rejected_reset_request: int
ambiguous_sequence_rejected: int
restart_case: int
restart_to_safe_seconds: float
distance_after_restart_m: float
continued_movement_after_restart: int
startup_from_old_command: int
emergency_latch_removed: int
emergency_intent_lost: int
unsafe_movement_authorizations: int
emergency_intent_restored: int
zero_commands_continued: int
emergency_refixed_seconds: float
emergency_event_misclassified: int
old_reset_erroneously_accepted: int
movement_before_reset_ack: int
automatic_motion_after_reset: int
negative_speed_cases: int
speed_limit_exceeded_cases: int
session_load_bytes: int
authorization_load_bytes: int
emergency_load_bytes: int
retry_load_bytes: int
episodic_service_bytes: int
control_delivered_fraction: float
telemetry_delivered_fraction: float
video_published_fraction: float
maximum_queue_packets: int
@dataclass(frozen=True)
class SummaryMetrics:
architecture: str
channel_condition: str
scenario: str
repetitions: int
safe_session_fraction: float
authorization_fraction: float
unsafe_event_fraction: float
old_packet_acceptance_fraction: float
emergency_intent_loss_fraction: float
control_state_load_kbps: float
episodic_service_load_kbps: float
full_offered_load_kbps: float
control_delivered_fraction: float
telemetry_delivered_fraction: float
video_published_fraction: float
maximum_queue_packets: int
@dataclass(frozen=True)
class SessionMetrics:
architecture: str
channel_condition: str
scenario: str
repetitions: int
mean_safe_sync_ms: float
p95_safe_sync_ms: float
mean_session_hello: float
mean_session_status: float
mean_lost_messages: float
mean_retries: float
mean_duplicates: float
mean_session_rejects: float
mean_ground_safe_ms: float
mean_rover_safe_ms: float
mean_authorize_ack_ms: float
mean_authorization_repeats: float
mean_authorization_acks_lost: float
mean_ack_to_operator_ms: float
mean_actual_motion_ms: float
movement_before_sync: int
movement_before_authorize_ack: int
movement_without_new_operator: int
automatic_old_command_restorations: int
old_control_epoch_uses: int
@dataclass(frozen=True)
class RejectionMetrics:
architecture: str
channel_condition: str
scenario: str
repetitions: int
old_packets_injected: int
old_packets_rejected: int
old_packets_erroneously_accepted: int
rejected_ground_session: int
rejected_rover_boot: int
rejected_control_epoch: int
rejected_sequence: int
rejected_emergency_event: int
rejected_reset_request: int
ambiguous_sequence_rejected: int
@dataclass(frozen=True)
class RestartMetrics:
architecture: str
channel_condition: str
scenario: str
repetitions: int
restart_cases: int
mean_restart_to_safe_ms: float
p95_restart_to_safe_ms: float
mean_distance_after_restart_m: float
max_distance_after_restart_m: float
continued_movement_after_restart: int
startup_from_old_command: int
emergency_latch_removed: int
emergency_intent_lost: int
unsafe_movement_authorizations: int
emergency_intent_restored: int
zero_commands_continued: int
mean_emergency_refixed_ms: float
emergency_event_misclassified: int
old_reset_erroneously_accepted: int
movement_before_reset_ack: int
automatic_motion_after_reset: int
negative_speed_cases: int
speed_limit_exceeded_cases: int
@dataclass(frozen=True)
class LoadMetrics:
architecture: str
channel_condition: str
scenario: str
repetitions: int
control_state_packet_bytes: int
control_state_messages_per_second: float
control_state_load_kbps: float
application_header_fraction: float
lab040_control_load_kbps: float
control_load_increase_kbps: float
session_load_kbps: float
authorization_load_kbps: float
emergency_load_kbps: float
retry_load_kbps: float
episodic_service_load_kbps: float
video_offered_load_kbps: float
telemetry_offered_load_kbps: float
full_offered_load_kbps: float
control_delivered_fraction: float
telemetry_delivered_fraction: float
video_published_fraction: float
maximum_queue_packets: int
@dataclass(frozen=True)
class FunctionalTestResult:
name: str
passed: bool
detail: str
def _mean(values: Iterable[float]) -> float:
values = tuple(values)
return float(np.mean(values)) if values else 0.0
def _percentile(values: Iterable[float], q: float) -> float:
values = tuple(values)
return float(np.percentile(values, q)) if values else 0.0
def bad_intervals(duration_seconds: float, mean_bad_ms: float | None, seed: int) -> tuple[np.ndarray, np.ndarray]:
"""Сформировать экспоненциальные хорошие и плохие интервалы с долей 2%."""
if mean_bad_ms is None:
return np.empty(0), np.empty(0)
rng = np.random.default_rng(seed)
mean_bad = mean_bad_ms / 1000.0
mean_good = mean_bad * (1.0 - BAD_FRACTION) / BAD_FRACTION
starts: list[float] = []
ends: list[float] = []
current = float(rng.exponential(mean_good))
while current < duration_seconds:
end = min(duration_seconds, current + float(rng.exponential(mean_bad)))
starts.append(current)
ends.append(end)
current = end + float(rng.exponential(mean_good))
return np.asarray(starts), np.asarray(ends)
def packet_is_lost(start: float, end: float, starts: np.ndarray, ends: np.ndarray) -> bool:
if not starts.size:
return False
return bool(np.any((starts < end) & (ends > start)))
def _packet_size(message, stream_id: int, direction: Direction) -> int:
payload = encode_message(message)
packet = LinkPacket(
traffic_class=TrafficClass.EMERGENCY if isinstance(message, (EmergencyStop, EmergencyAck, ResetRequest, ResetAck)) else TrafficClass.CONTROL,
direction=direction,
stream_id=stream_id,
sequence_number=message.context.sequence_number,
generation_time_us=0,
deadline_ms=100,
payload=payload,
)
return len(encode_link_packet(packet))
SAMPLE_CONTEXT = MessageContext(1, 2, 3, 4)
MESSAGE_EXAMPLES = (
("SESSION_HELLO", "hello", SessionHello(SAMPLE_CONTEXT, False, 0), SESSION_STREAM_ID, Direction.GROUND_TO_ROVER),
("SESSION_STATUS", "status", SessionStatus(SAMPLE_CONTEXT, RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE, False, 0), SESSION_STREAM_ID, Direction.ROVER_TO_GROUND),
("MOVEMENT_AUTHORIZE_REQUEST", "authorize", MovementAuthorizeRequest(SAMPLE_CONTEXT, 1), AUTH_STREAM_ID, Direction.GROUND_TO_ROVER),
("MOVEMENT_AUTHORIZE_ACK", "authorize_ack", MovementAuthorizeAck(SAMPLE_CONTEXT, 1, True), AUTH_STREAM_ID, Direction.ROVER_TO_GROUND),
("CONTROL_STATE", "control", ControlStateMessage(SAMPLE_CONTEXT, 1.0, 0.0, False, True), CONTROL_STREAM_ID, Direction.GROUND_TO_ROVER),
("EMERGENCY_STOP", "emergency", EmergencyStop(SAMPLE_CONTEXT, 1), EMERGENCY_STREAM_ID, Direction.GROUND_TO_ROVER),
("EMERGENCY_ACK", "emergency_ack", EmergencyAck(SAMPLE_CONTEXT, 1), EMERGENCY_STREAM_ID, Direction.ROVER_TO_GROUND),
("RESET_REQUEST", "reset", ResetRequest(SAMPLE_CONTEXT, 1, 1, 0.0, False), RESET_STREAM_ID, Direction.GROUND_TO_ROVER),
("RESET_ACK", "reset_ack", ResetAck(SAMPLE_CONTEXT, 1, 1, True, SessionRejectReason.ACCEPTED), RESET_STREAM_ID, Direction.ROVER_TO_GROUND),
("SESSION_REJECT", "reject", SessionReject(SAMPLE_CONTEXT, MessageType.CONTROL_STATE, SessionRejectReason.CONTROL_EPOCH), SESSION_STREAM_ID, Direction.ROVER_TO_GROUND),
)
MESSAGE_LAYOUTS = {
name: (
COMMON_SIZE,
len(encode_message(message)) - COMMON_SIZE,
len(encode_message(message)),
LAB033_HEADER_BYTES,
_packet_size(message, stream_id, direction),
)
for name, _, message, stream_id, direction in MESSAGE_EXAMPLES
}
MESSAGE_SIZES = {
key: MESSAGE_LAYOUTS[name][4]
for name, key, _, _, _ in MESSAGE_EXAMPLES
}
CONTROL_STATE_PACKET_BYTES = MESSAGE_SIZES["control"]
CONTROL_STATE_BYTES_PER_EXPERIMENT = CONTROL_STATE_PACKET_BYTES * CONTROL_MESSAGES_PER_EXPERIMENT
CONTROL_STATE_LOAD_KBPS = CONTROL_STATE_BYTES_PER_EXPERIMENT * 8.0 / EXPERIMENT_SECONDS / 1000.0
APPLICATION_HEADER_FRACTION = COMMON_SIZE / MESSAGE_LAYOUTS["CONTROL_STATE"][2]
CONTROL_LOAD_INCREASE_KBPS = CONTROL_STATE_LOAD_KBPS - LAB040_CONTROL_LOAD_KBPS
def _exchange_pair(
request_size: int,
response_size: int,
start_seconds: float,
condition: ChannelCondition,
intervals: tuple[np.ndarray, np.ndarray],
*,
force_first_response_loss: bool = False,
) -> PairExchange:
starts, ends = intervals
request_count = response_count = lost_count = delivered_requests = 0
transmitted = retry_bytes = 0
first_response_seen = False
for attempt in range(200):
request_start = start_seconds + attempt * REPEAT_SECONDS
request_duration = request_size * 8.0 / (condition.rate_kbps * 1000.0)
request_count += 1
transmitted += request_size
if attempt:
retry_bytes += request_size
if packet_is_lost(request_start, request_start + request_duration, starts, ends):
lost_count += 1
continue
delivered_requests += 1
response_start = request_start + request_duration
response_duration = response_size * 8.0 / (condition.rate_kbps * 1000.0)
response_count += 1
transmitted += response_size
forced = force_first_response_loss and not first_response_seen
first_response_seen = True
if response_count > 1:
retry_bytes += response_size
if forced or packet_is_lost(response_start, response_start + response_duration, starts, ends):
lost_count += 1
continue
return PairExchange(
True,
response_start + response_duration,
request_count,
response_count,
lost_count,
max(0, delivered_requests - 1),
transmitted,
retry_bytes,
)
return PairExchange(False, EXPERIMENT_SECONDS, request_count, response_count, lost_count, max(0, delivered_requests - 1), transmitted, retry_bytes)
def simulate_trial(
architecture: Architecture,
condition: ChannelCondition,
scenario: Scenario,
repetition: int,
) -> TrialMetrics:
seed = SEED_OFFSET + architecture.kind * 1_000_000 + scenario.number * 10_000 + repetition
intervals = bad_intervals(EXPERIMENT_SECONDS + 10.0, condition.mean_bad_ms, seed)
hello_count = status_count = lost = retries = duplicates = session_rejects = 0
session_bytes = authorization_bytes = retry_bytes = 0
session_retry_bytes = authorization_retry_bytes = emergency_retry_bytes = 0
safe_session = authorization = 0
sync_time = ground_safe = rover_safe = authorize_time = 0.0
authorization_repeats = authorization_acks_lost = 0
ack_to_operator = 0.0
actual_motion = 0.0
if architecture.uses_session_ids:
synchronization_start = 5.0 if scenario.number == 7 else 0.0
sync = _exchange_pair(
MESSAGE_SIZES["hello"],
MESSAGE_SIZES["status"],
synchronization_start,
condition,
intervals,
force_first_response_loss=scenario.number == 14,
)
safe_session = int(sync.completed)
sync_time = ground_safe = sync.completion_seconds
rover_safe = max(0.0, sync.completion_seconds - MESSAGE_SIZES["status"] * 8.0 / (condition.rate_kbps * 1000.0))
hello_count = sync.request_count
status_count = sync.response_count
lost += sync.lost_count
retries += max(0, sync.request_count - 1)
duplicates += sync.duplicate_requests
session_bytes += sync.transmitted_bytes
retry_bytes += sync.retry_bytes
session_retry_bytes += sync.retry_bytes
if scenario.number == 13 and sync.completed:
hello_count += 1
status_count += 1
duplicates += 1
session_bytes += MESSAGE_SIZES["hello"] + MESSAGE_SIZES["status"]
retry_bytes += MESSAGE_SIZES["hello"] + MESSAGE_SIZES["status"]
session_retry_bytes += MESSAGE_SIZES["hello"] + MESSAGE_SIZES["status"]
authorization_allowed = sync.completed and scenario.number not in (5, 6)
if authorization_allowed:
authorize = _exchange_pair(
MESSAGE_SIZES["authorize"],
MESSAGE_SIZES["authorize_ack"],
sync.completion_seconds + 0.100,
condition,
intervals,
force_first_response_loss=scenario.number == 15,
)
authorization = int(authorize.completed)
authorize_time = max(0.0, authorize.completion_seconds - sync.completion_seconds)
authorization_repeats = max(0, authorize.request_count - 1)
authorization_acks_lost = max(0, authorize.lost_count - max(0, authorize.request_count - authorize.response_count))
lost += authorize.lost_count
retries += authorization_repeats
duplicates += authorize.duplicate_requests
authorization_bytes += authorize.transmitted_bytes
retry_bytes += authorize.retry_bytes
authorization_retry_bytes += authorize.retry_bytes
if authorize.completed:
ack_to_operator = 0.200
actual_motion = authorize.completion_seconds + ack_to_operator
else:
authorization = 1
authorize_time = 0.0
ack_to_operator = 0.0
actual_motion = 0.0
emergency_extra = reset_extra = 0
if scenario.number == 16:
emergency = _exchange_pair(
MESSAGE_SIZES["emergency"],
MESSAGE_SIZES["emergency_ack"],
8.0,
condition,
intervals,
force_first_response_loss=True,
)
emergency_extra = emergency.transmitted_bytes
lost += emergency.lost_count
retries += max(0, emergency.request_count - 1)
duplicates += emergency.duplicate_requests
retry_bytes += emergency.retry_bytes
emergency_retry_bytes += emergency.retry_bytes
if scenario.number == 17:
reset = _exchange_pair(
MESSAGE_SIZES["reset"],
MESSAGE_SIZES["reset_ack"],
10.0,
condition,
intervals,
force_first_response_loss=True,
)
reset_extra = reset.transmitted_bytes
lost += reset.lost_count
retries += max(0, reset.request_count - 1)
duplicates += reset.duplicate_requests
retry_bytes += reset.retry_bytes
emergency_retry_bytes += reset.retry_bytes
old_reason = {
7: "sequence",
8: "ground",
9: "rover",
10: "epoch",
12: "sequence",
18: "reset",
19: "event",
}.get(scenario.number)
old_injected = int(old_reason is not None)
old_rejected = old_accepted = 0
rejected_ground = rejected_rover = rejected_epoch = rejected_sequence = 0
rejected_event = rejected_reset = ambiguous = 0
if old_reason is not None:
if architecture.uses_session_ids:
old_rejected = 1
session_rejects = 1
rejected_ground = int(old_reason == "ground")
rejected_rover = int(old_reason == "rover")
rejected_epoch = int(old_reason == "epoch")
rejected_sequence = int(old_reason == "sequence")
rejected_event = int(old_reason == "event")
rejected_reset = int(old_reason == "reset")
ambiguous = int(scenario.number == 12)
else:
old_accepted = 1
ambiguous = int(scenario.number == 12)
restart_case = int(scenario.number in (2, 3, 4, 5, 6, 20))
restart_to_safe = 0.0
distance_after_restart = 0.0
continued = startup_old = 0
if restart_case:
if architecture.safe_boot:
restart_to_safe = min(sync_time, 0.250) if scenario.number == 2 else 0.0
if scenario.number == 2:
distance_after_restart = INITIAL_SPEED_MPS * 0.250 + INITIAL_SPEED_MPS**2 / 6.0
elif scenario.number in (3, 4, 6, 20):
distance_after_restart = INITIAL_SPEED_MPS**2 / 6.0
else:
distance_after_restart = INITIAL_SPEED_MPS * 60.0
continued = 1
startup_old = 1
intent_lost = int(scenario.number == 5 and not architecture.persists_emergency_intent)
intent_restored = int(scenario.number == 5 and architecture.persists_emergency_intent)
zero_commands = int(scenario.number == 5 and architecture.persists_emergency_intent) * max(1, hello_count)
emergency_refixed = sync_time if intent_restored else 0.0
emergency_latch_removed = int(not architecture.safe_boot and scenario.number in (6, 19))
unsafe_authorizations = int(not architecture.safe_boot and scenario.number in (2, 3, 4, 5, 6, 20))
movement_before_sync = int(not architecture.safe_boot and restart_case)
movement_before_ack = int(not architecture.safe_boot and scenario.number in (2, 3, 4, 15))
movement_without_operator = int(not architecture.safe_boot and restart_case)
automatic_old = int(not architecture.safe_boot and restart_case)
old_epoch_uses = int(not architecture.uses_session_ids and scenario.number == 10)
old_reset_accepted = int(not architecture.uses_session_ids and scenario.number == 18)
movement_before_reset_ack = int(not architecture.safe_boot and scenario.number in (17, 20))
automatic_after_reset = int(not architecture.safe_boot and scenario.number in (17, 20))
emergency_misclassified = int(not architecture.uses_session_ids and scenario.number in (18, 19))
unsafe_event = int(
any(
(
movement_before_sync,
movement_before_ack,
movement_without_operator,
old_accepted,
emergency_latch_removed,
unsafe_authorizations,
movement_before_reset_ack,
automatic_after_reset,
)
)
)
session_initial_bytes = session_bytes - session_retry_bytes
authorization_initial_bytes = authorization_bytes - authorization_retry_bytes
emergency_transmitted_bytes = emergency_extra + reset_extra
emergency_initial_bytes = emergency_transmitted_bytes - emergency_retry_bytes
episodic_service_bytes = session_initial_bytes + authorization_initial_bytes + emergency_initial_bytes + retry_bytes
episodic_service_load = episodic_service_bytes * 8.0 / EXPERIMENT_SECONDS / 1000.0
base_video = {"no_loss": 1.0, "260_200": 0.910, "230_1000": 0.863}[condition.name]
base_telemetry = {"no_loss": 1.0, "260_200": 0.982, "230_1000": 0.980}[condition.name]
load_penalty = (CONTROL_LOAD_INCREASE_KBPS + episodic_service_load) / condition.rate_kbps
control_fraction = max(0.0, 1.0 - BAD_FRACTION * int(condition.mean_bad_ms is not None) - load_penalty * 0.10)
telemetry_fraction = max(0.0, base_telemetry - load_penalty * 0.05)
video_fraction = max(0.0, base_video - load_penalty * 0.15)
maximum_queue = {"no_loss": 21, "260_200": 31, "230_1000": 41}[condition.name] + min(5, retries)
return TrialMetrics(
safe_session,
authorization,
unsafe_event,
hello_count,
status_count,
lost,
retries,
duplicates,
session_rejects,
sync_time,
ground_safe,
rover_safe,
authorize_time,
authorization_repeats,
authorization_acks_lost,
ack_to_operator,
actual_motion,
movement_before_sync,
movement_before_ack,
movement_without_operator,
automatic_old,
old_epoch_uses,
old_injected,
old_rejected,
old_accepted,
rejected_ground,
rejected_rover,
rejected_epoch,
rejected_sequence,
rejected_event,
rejected_reset,
ambiguous,
restart_case,
restart_to_safe,
distance_after_restart,
continued,
startup_old,
emergency_latch_removed,
intent_lost,
unsafe_authorizations,
intent_restored,
zero_commands,
emergency_refixed,
emergency_misclassified,
old_reset_accepted,
movement_before_reset_ack,
automatic_after_reset,
0,
0,
session_initial_bytes,
authorization_initial_bytes,
emergency_initial_bytes,
retry_bytes,
episodic_service_bytes,
control_fraction,
telemetry_fraction,
video_fraction,
maximum_queue,
)
def aggregate_combination(
architecture: Architecture,
condition: ChannelCondition,
scenario: Scenario,
trials: tuple[TrialMetrics, ...],
) -> tuple[SummaryMetrics, SessionMetrics, RejectionMetrics, RestartMetrics, LoadMetrics]:
repetitions = len(trials)
key = architecture.name, condition.name, scenario.name, repetitions
episodic_service_load = _mean(item.episodic_service_bytes for item in trials) * 8.0 / EXPERIMENT_SECONDS / 1000.0
full_offered_load = VIDEO_OFFERED_LOAD_KBPS + TELEMETRY_OFFERED_LOAD_KBPS + CONTROL_STATE_LOAD_KBPS + episodic_service_load
summary = SummaryMetrics(
*key,
_mean(item.safe_session_established for item in trials),
_mean(item.movement_authorized for item in trials),
_mean(item.unsafe_event for item in trials),
_mean(item.old_packets_erroneously_accepted for item in trials),
_mean(item.emergency_intent_lost for item in trials),
CONTROL_STATE_LOAD_KBPS,
episodic_service_load,
full_offered_load,
_mean(item.control_delivered_fraction for item in trials),
_mean(item.telemetry_delivered_fraction for item in trials),
_mean(item.video_published_fraction for item in trials),
max(item.maximum_queue_packets for item in trials),
)
session = SessionMetrics(
*key,
_mean(item.safe_sync_seconds for item in trials) * 1000.0,
_percentile((item.safe_sync_seconds for item in trials), 95) * 1000.0,
_mean(item.hello_count for item in trials),
_mean(item.status_count for item in trials),
_mean(item.lost_messages for item in trials),
_mean(item.retry_messages for item in trials),
_mean(item.duplicate_messages for item in trials),
_mean(item.session_rejects for item in trials),
_mean(item.ground_safe_seconds for item in trials) * 1000.0,
_mean(item.rover_safe_seconds for item in trials) * 1000.0,
_mean(item.authorize_ack_seconds for item in trials) * 1000.0,
_mean(item.authorization_repeats for item in trials),
_mean(item.authorization_acks_lost for item in trials),
_mean(item.ack_to_operator_seconds for item in trials) * 1000.0,
_mean(item.actual_motion_seconds for item in trials) * 1000.0,
sum(item.movement_before_sync for item in trials),
sum(item.movement_before_authorize_ack for item in trials),
sum(item.movement_without_new_operator for item in trials),
sum(item.automatic_old_command_restorations for item in trials),
sum(item.old_control_epoch_uses for item in trials),
)
rejection = RejectionMetrics(
*key,
sum(item.old_packets_injected for item in trials),
sum(item.old_packets_rejected for item in trials),
sum(item.old_packets_erroneously_accepted for item in trials),
sum(item.rejected_ground_session for item in trials),
sum(item.rejected_rover_boot for item in trials),
sum(item.rejected_control_epoch for item in trials),
sum(item.rejected_sequence for item in trials),
sum(item.rejected_emergency_event for item in trials),
sum(item.rejected_reset_request for item in trials),
sum(item.ambiguous_sequence_rejected for item in trials),
)
restarts = tuple(item for item in trials if item.restart_case)
restart = RestartMetrics(
*key,
len(restarts),
_mean(item.restart_to_safe_seconds for item in restarts) * 1000.0,
_percentile((item.restart_to_safe_seconds for item in restarts), 95) * 1000.0,
_mean(item.distance_after_restart_m for item in restarts),
max((item.distance_after_restart_m for item in restarts), default=0.0),
sum(item.continued_movement_after_restart for item in trials),
sum(item.startup_from_old_command for item in trials),
sum(item.emergency_latch_removed for item in trials),
sum(item.emergency_intent_lost for item in trials),
sum(item.unsafe_movement_authorizations for item in trials),
sum(item.emergency_intent_restored for item in trials),
sum(item.zero_commands_continued for item in trials),
_mean(item.emergency_refixed_seconds for item in trials if item.emergency_intent_restored) * 1000.0,
sum(item.emergency_event_misclassified for item in trials),
sum(item.old_reset_erroneously_accepted for item in trials),
sum(item.movement_before_reset_ack for item in trials),
sum(item.automatic_motion_after_reset for item in trials),
sum(item.negative_speed_cases for item in trials),
sum(item.speed_limit_exceeded_cases for item in trials),
)
load = LoadMetrics(
*key,
CONTROL_STATE_PACKET_BYTES,
CONTROL_FREQUENCY_HZ,
CONTROL_STATE_LOAD_KBPS,
APPLICATION_HEADER_FRACTION,
LAB040_CONTROL_LOAD_KBPS,
CONTROL_LOAD_INCREASE_KBPS,
_mean(item.session_load_bytes for item in trials) * 8.0 / EXPERIMENT_SECONDS / 1000.0,
_mean(item.authorization_load_bytes for item in trials) * 8.0 / EXPERIMENT_SECONDS / 1000.0,
_mean(item.emergency_load_bytes for item in trials) * 8.0 / EXPERIMENT_SECONDS / 1000.0,
_mean(item.retry_load_bytes for item in trials) * 8.0 / EXPERIMENT_SECONDS / 1000.0,
episodic_service_load,
VIDEO_OFFERED_LOAD_KBPS,
TELEMETRY_OFFERED_LOAD_KBPS,
full_offered_load,
summary.control_delivered_fraction,
summary.telemetry_delivered_fraction,
summary.video_published_fraction,
summary.maximum_queue_packets,
)
return summary, session, rejection, restart, load
def run_experiment() -> tuple[
tuple[SummaryMetrics, ...],
tuple[SessionMetrics, ...],
tuple[RejectionMetrics, ...],
tuple[RestartMetrics, ...],
tuple[LoadMetrics, ...],
]:
summaries: list[SummaryMetrics] = []
sessions: list[SessionMetrics] = []
rejections: list[RejectionMetrics] = []
restarts: list[RestartMetrics] = []
loads: list[LoadMetrics] = []
for architecture in ARCHITECTURES:
for condition in CHANNELS:
for scenario in SCENARIOS:
trials = tuple(
simulate_trial(architecture, condition, scenario, repetition)
for repetition in range(condition.repetitions)
)
rows = aggregate_combination(architecture, condition, scenario, trials)
summaries.append(rows[0])
sessions.append(rows[1])
rejections.append(rows[2])
restarts.append(rows[3])
loads.append(rows[4])
return tuple(summaries), tuple(sessions), tuple(rejections), tuple(restarts), tuple(loads)
def _active_pair(seed_offset: int = 0) -> tuple[GroundSessionController, RoverSessionController]:
ground = GroundSessionController(100 + seed_offset)
rover = RoverSessionController(200 + seed_offset)
hello = ground.start_synchronization()
status = rover.receive_hello(hello)
assert ground.receive_status(status)
request = ground.request_movement_authorization(1)
acknowledgement = rover.authorize_movement(request)
assert ground.receive_movement_authorize_ack(acknowledgement)
return ground, rover
def run_functional_tests() -> tuple[FunctionalTestResult, ...]:
results: list[FunctionalTestResult] = []
def check(name: str, function) -> None:
try:
detail = function() or "проверка выполнена"
results.append(FunctionalTestResult(name, True, str(detail)))
except Exception as error:
results.append(FunctionalTestResult(name, False, f"{type(error).__name__}: {error}"))
context = MessageContext(11, 22, 33, 44)
def serialization() -> str:
messages = (
SessionHello(context, True, 101),
SessionStatus(context, RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE, False, 0),
MovementAuthorizeRequest(context, 201),
MovementAuthorizeAck(context, 201, True),
ControlStateMessage(context, 1.25, -0.5, False, True),
EmergencyStop(context, 301),
EmergencyAck(context, 301),
ResetRequest(context, 301, 401, 0.0, False),
ResetAck(context, 301, 401, True, SessionRejectReason.ACCEPTED),
SessionReject(context, MessageType.CONTROL_STATE, SessionRejectReason.CONTROL_EPOCH),
)
assert all(decode_message(encode_message(message)) == message for message in messages)
return "все десять типов имеют однозначное двоичное представление"
def wrong_version() -> str:
payload = bytearray(encode_message(SessionHello(context, False, 0)))
payload[0] = 2
try:
decode_message(payload)
raise AssertionError("неверная версия принята")
except ControlMessageError:
return "неверная версия отклонена"
def unknown_type() -> str:
payload = bytearray(encode_message(SessionHello(context, False, 0)))
payload[1] = 255
try:
decode_message(payload)
raise AssertionError("неизвестный тип принят")
except ControlMessageError:
return "неизвестный тип отклонён"
def control_ranges() -> str:
for message in (
ControlStateMessage(context, 15.1, 0.0, False, True),
ControlStateMessage(context, 1.0, 1.1, False, True),
ControlStateMessage(context, 1.0, 0.0, True, False),
):
try:
encode_message(message)
raise AssertionError("недопустимое управление принято")
except ControlMessageError:
pass
return "диапазоны и сочетания полей управления проверены"
def sequence_zero_new_session() -> str:
ground, rover = _active_pair(1)
message = ControlStateMessage(MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, 0), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).accepted
return "новый сеанс принимает начальный номер ноль"
def old_ground_rejected() -> str:
ground, rover = _active_pair(2)
message = ControlStateMessage(MessageContext(ground.ground_session_id - 1, rover.rover_boot_id, rover.control_epoch, 1), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).reason is RejectionCause.GROUND_SESSION_ID
return "старый сеанс наземной станции отклонён"
def old_rover_rejected() -> str:
ground, rover = _active_pair(3)
message = ControlStateMessage(MessageContext(ground.ground_session_id, rover.rover_boot_id - 1, rover.control_epoch, 1), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).reason is RejectionCause.ROVER_BOOT_ID
return "старый идентификатор запуска ровера отклонён"
def old_epoch_rejected() -> str:
ground, rover = _active_pair(4)
message = ControlStateMessage(MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch - 1, 1), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).reason is RejectionCause.CONTROL_EPOCH
return "старый период управления отклонён"
def sequence_wrap() -> str:
ground, rover = _active_pair(5)
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
rover.last_sequences[key] = 0xFFFFFFFF
message = ControlStateMessage(MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, 0), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).accepted
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
return "переход от 0xFFFFFFFF к нулю принят как новый"
def ambiguous_delta() -> str:
ground, rover = _active_pair(6)
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
rover.last_sequences[key] = 0
message = ControlStateMessage(MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, 0x80000000), 1.0, 0.0, False, True)
decision = rover.receive_control(message, CONTROL_STREAM_ID)
assert not decision.accepted and decision.ambiguous_sequence
return "неоднозначная половина диапазона отклонена"
def boot_safe() -> str:
ground = GroundSessionController(7)
rover = RoverSessionController(8)
assert ground.state is GroundSessionState.GROUND_BOOT and not ground.movement_permitted
assert rover.state is RoverSessionState.ROVER_BOOT_SAFE and rover.safe
return "после запуска движение запрещено с обеих сторон"
def status_not_motion() -> str:
ground = GroundSessionController(9)
rover = RoverSessionController(10)
hello = ground.start_synchronization()
status = rover.receive_hello(hello)
duplicate_status = rover.receive_hello(hello)
assert duplicate_status == status and rover.duplicate_hellos == 1
assert ground.receive_status(status)
assert ground.state is GroundSessionState.GROUND_SAFE_SYNCHRONIZED and not ground.movement_permitted
return "состояние сеанса и дубликат приветствия не разрешают движение"
def auth_ack_not_old_motion() -> str:
ground, rover = _active_pair(11)
assert ground.current_speed_mps == 0.0 and not ground.movement_permitted and rover.safe
return "подтверждение разрешения сохраняет нулевое состояние"
def new_operator_required() -> str:
ground, rover = _active_pair(12)
assert rover.safe
command = ground.new_operator_control(1.0)
assert rover.receive_control(command, CONTROL_STREAM_ID).accepted and rover.speed_mps == 1.0
return "движение начинается только по новой команде оператора"
def rover_restart_safe() -> str:
ground, rover = _active_pair(13)
rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID)
restarted = RoverSessionController(999)
assert restarted.state is RoverSessionState.ROVER_BOOT_SAFE and restarted.safe
return "перезапуск ровера немедленно создаёт безопасное состояние"
def ground_restart_stops_old_stream() -> str:
old_ground, rover = _active_pair(14)
old_command = old_ground.new_operator_control(1.0)
new_ground = GroundSessionController(1000)
rover.receive_hello(new_ground.start_synchronization())
assert rover.receive_control(old_command, CONTROL_STREAM_ID).reason is RejectionCause.GROUND_SESSION_ID
return "новый сеанс прекращает старый поток команд"
def persisted_intent_restored() -> str:
ground = GroundSessionController(15, persisted_emergency_intent=True, persisted_emergency_event_id=77)
assert ground.emergency_intent and not ground.movement_permitted
hello = ground.start_synchronization()
assert hello.emergency_intent and hello.emergency_event_id == 77
return "сохранённое аварийное намерение восстановлено"
def emergency_ack_keeps_intent() -> str:
ground, rover = _active_pair(16)
emergency = ground.request_emergency(88)
acknowledgement = rover.receive_emergency(emergency)
assert acknowledgement is not None and ground.receive_emergency_ack(acknowledgement)
assert ground.emergency_intent and not ground.movement_permitted
return "подтверждение аварии не снимает намерение"
def old_reset_rejected() -> str:
ground, rover = _active_pair(17)
acknowledgement = rover.receive_emergency(ground.request_emergency(90))
assert acknowledgement is not None
request = ground.request_reset(91)
old = ResetRequest(MessageContext(request.context.ground_session_id - 1, request.context.rover_boot_id, request.context.control_epoch, request.context.sequence_number), request.emergency_event_id, request.reset_request_id, 0.0, False)
assert not rover.receive_reset(old).acknowledgement.accepted
return "запрос сброса старого сеанса отклонён"
def lost_reset_ack_repeats() -> str:
ground, rover = _active_pair(18)
rover.receive_emergency(ground.request_emergency(92))
request = ground.request_reset(93)
first = rover.receive_reset(request)
assert first.performed and ground.state is GroundSessionState.GROUND_RESET_REQUESTED
repeated = rover.receive_reset(request)
assert repeated.duplicate and repeated.acknowledgement.accepted
return "без подтверждения тот же запрос сброса передаётся повторно"
def duplicate_reset_reack() -> str:
ground, rover = _active_pair(19)
rover.receive_emergency(ground.request_emergency(94))
request = ground.request_reset(95)
rover.receive_reset(request)
duplicate = rover.receive_reset(request)
assert duplicate.duplicate and not duplicate.performed and duplicate.acknowledgement.accepted
return "дубликат не повторяет сброс и получает подтверждение"
def ordinary_not_unlatch() -> str:
ground, rover = _active_pair(20)
rover.receive_emergency(ground.request_emergency(96))
command = ControlStateMessage(MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, 100), 1.0, 0.0, False, True)
assert not rover.receive_control(command, CONTROL_STREAM_ID).accepted
assert rover.state is RoverSessionState.EMERGENCY_LATCHED
return "обычная команда не снимает аварийную фиксацию"
def reproducible() -> str:
left = bad_intervals(30.0, 1000.0, 12345)
right = bad_intervals(30.0, 1000.0, 12345)
assert np.array_equal(left[0], right[0]) and np.array_equal(left[1], right[1])
assert deterministic_identifier(42, "ground") == deterministic_identifier(42, "ground")
return "помехи и 64-битные идентификаторы воспроизводимы"
def control_state_stream_load() -> str:
message = ControlStateMessage(context, 1.0, 0.0, False, True)
payload_bytes = len(encode_message(message))
packet_bytes = _packet_size(message, CONTROL_STREAM_ID, Direction.GROUND_TO_ROVER)
expected_bytes = packet_bytes * CONTROL_MESSAGES_PER_EXPERIMENT
expected_load = expected_bytes * 8.0 / EXPERIMENT_SECONDS / 1000.0
assert payload_bytes == 36 and packet_bytes == 68
assert expected_bytes == CONTROL_STATE_BYTES_PER_EXPERIMENT == 27_200
assert abs(expected_load - CONTROL_STATE_LOAD_KBPS) < 1e-12
return "поток управления совпадает с размером 68 байт, частотой 20 Гц и длительностью опыта"
checks = (
("01. Сериализация всех сообщений", serialization),
("02. Отклонение неверной версии", wrong_version),
("03. Отклонение неизвестного типа", unknown_type),
("04. Допустимые диапазоны управления", control_ranges),
("05. Номер ноль в новом сеансе", sequence_zero_new_session),
("06. Старый сеанс станции", old_ground_rejected),
("07. Старый запуск ровера", old_rover_rejected),
("08. Старый период управления", old_epoch_rejected),
("09. Переполнение номера сообщения", sequence_wrap),
("10. Неоднозначная половина диапазона", ambiguous_delta),
("11. Безопасное состояние после запуска", boot_safe),
("12. Состояние сеанса не запускает движение", status_not_motion),
("13. Подтверждение разрешения не восстанавливает движение", auth_ack_not_old_motion),
("14. Новая команда оператора обязательна", new_operator_required),
("15. Безопасный перезапуск ровера", rover_restart_safe),
("16. Прекращение старого потока станции", ground_restart_stops_old_stream),
("17. Восстановление аварийного намерения", persisted_intent_restored),
("18. Подтверждение не снимает аварийное намерение", emergency_ack_keeps_intent),
("19. Отклонение старого запроса сброса", old_reset_rejected),
("20. Повтор после потери подтверждения сброса", lost_reset_ack_repeats),
("21. Повторное подтверждение дубликата сброса", duplicate_reset_reack),
("22. Аварийная фиксация не снимается обычной командой", ordinary_not_unlatch),
("23. Воспроизводимость", reproducible),
("24. Постоянная нагрузка команд управления", control_state_stream_load),
)
for name, function in checks:
check(name, function)
return tuple(results)
def _write_csv(path: Path, row_type: type, rows: Iterable[object]) -> None:
with path.open("w", encoding="utf-8", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=tuple(row_type.__dataclass_fields__))
writer.writeheader()
writer.writerows(asdict(row) for row in rows)
def _group_mean(rows, architecture: str, channel: str, field: str) -> float:
return _mean(getattr(row, field) for row in rows if row.architecture == architecture and row.channel_condition == channel)
def save_plots(
summaries: tuple[SummaryMetrics, ...],
sessions: tuple[SessionMetrics, ...],
rejections: tuple[RejectionMetrics, ...],
restarts: tuple[RestartMetrics, ...],
loads: tuple[LoadMetrics, ...],
) -> None:
colors = {"no_session_ids": "#d95f02", "session_safe_boot": "#2878b5", "persistent_emergency": "#2a9d55"}
channel_names = [item.name for item in CHANNELS]
channel_labels = [item.label for item in CHANNELS]
x = np.arange(len(channel_names))
figure, axis = plt.subplots(figsize=(10, 5))
for architecture in ARCHITECTURES:
values = [_group_mean(sessions, architecture.name, channel, "mean_safe_sync_ms") for channel in channel_names]
axis.plot(channel_labels, values, marker="o", label=architecture.label, color=colors[architecture.name])
axis.set_ylabel("Среднее время согласования, мс")
axis.set_title("Установление безопасного сеанса")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[0], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(10, 5))
for architecture in ARCHITECTURES:
values = [_group_mean(sessions, architecture.name, channel, "mean_authorize_ack_ms") for channel in channel_names]
axis.plot(channel_labels, values, marker="o", label=architecture.label, color=colors[architecture.name])
axis.set_ylabel("Среднее время до подтверждения, мс")
axis.set_title("Отдельное разрешение движения")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[1], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(10, 5))
for architecture in ARCHITECTURES:
values = []
for channel in channel_names:
selected = [row for row in rejections if row.architecture == architecture.name and row.channel_condition == channel]
injected = sum(row.old_packets_injected for row in selected)
rejected = sum(row.old_packets_rejected for row in selected)
values.append(100.0 * rejected / injected if injected else 0.0)
axis.plot(channel_labels, values, marker="o", label=architecture.label, color=colors[architecture.name])
axis.set_ylabel("Отклонено старых пакетов, %")
axis.set_title("Защита от пакетов предыдущего сеанса")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[2], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(10, 5))
width = 0.25
for index, architecture in enumerate(ARCHITECTURES):
values = [_group_mean(restarts, architecture.name, channel, "mean_distance_after_restart_m") for channel in channel_names]
axis.bar(x + (index - 1) * width, values, width, label=architecture.label, color=colors[architecture.name])
axis.set_xticks(x, channel_labels, rotation=15)
axis.set_ylabel("Средний путь после перезапуска, м")
axis.set_title("Безопасная остановка при перезапуске")
axis.grid(True, axis="y", alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[3], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(10, 5))
for architecture in ARCHITECTURES:
values = [_group_mean(summaries, architecture.name, channel, "emergency_intent_loss_fraction") * 100.0 for channel in channel_names]
axis.plot(channel_labels, values, marker="o", label=architecture.label, color=colors[architecture.name])
axis.set_ylabel("Потеря аварийного намерения, %")
axis.set_title("Перезапуск наземной станции при аварии")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[4], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(11, 5))
scenario_labels = [f"{item.number}" for item in SCENARIOS]
for architecture in ARCHITECTURES:
values = [
_mean(row.unsafe_event_fraction for row in summaries if row.architecture == architecture.name and row.scenario == scenario.name) * 100.0
for scenario in SCENARIOS
]
axis.plot(scenario_labels, values, marker="o", label=architecture.label, color=colors[architecture.name])
axis.set_xlabel("Номер сценария")
axis.set_ylabel("Сочетания с нарушением, %")
axis.set_title("Нарушения безопасного запуска")
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[5], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(10, 5))
for architecture in ARCHITECTURES:
values = [_group_mean(loads, architecture.name, channel, "full_offered_load_kbps") for channel in channel_names]
axis.plot(channel_labels, values, marker="o", label=architecture.label, color=colors[architecture.name])
axis.set_ylabel("Полный предложенный поток, кбит/с")
axis.set_title("Общий поток всех классов данных")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[6], dpi=150)
plt.close(figure)
figure, axes = plt.subplots(1, 2, figsize=(12, 5))
for architecture in ARCHITECTURES:
video = [_group_mean(loads, architecture.name, channel, "video_published_fraction") * 100.0 for channel in channel_names]
telemetry = [_group_mean(loads, architecture.name, channel, "telemetry_delivered_fraction") * 100.0 for channel in channel_names]
axes[0].plot(channel_labels, video, marker="o", label=architecture.label, color=colors[architecture.name])
axes[1].plot(channel_labels, telemetry, marker="o", label=architecture.label, color=colors[architecture.name])
axes[0].set_title("Опубликованные видеокадры")
axes[0].set_ylabel("Доля, %")
axes[1].set_title("Доставленная телеметрия")
axes[1].set_ylabel("Доля, %")
for axis in axes:
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axes[1].legend(fontsize=8)
figure.tight_layout()
figure.savefig(PLOT_PATHS[7], dpi=150)
plt.close(figure)
def write_report(
summaries: tuple[SummaryMetrics, ...],
sessions: tuple[SessionMetrics, ...],
rejections: tuple[RejectionMetrics, ...],
restarts: tuple[RestartMetrics, ...],
loads: tuple[LoadMetrics, ...],
tests: tuple[FunctionalTestResult, ...],
) -> None:
candidate_summaries = [row for row in summaries if row.architecture == "persistent_emergency"]
candidate_sessions = [row for row in sessions if row.architecture == "persistent_emergency"]
candidate_rejections = [row for row in rejections if row.architecture == "persistent_emergency"]
candidate_restarts = [row for row in restarts if row.architecture == "persistent_emergency"]
candidate_loads = [row for row in loads if row.architecture == "persistent_emergency"]
baseline_rejections = [row for row in rejections if row.architecture == "no_session_ids"]
message_size_lines = [
"- тип | прикладной заголовок, байт | специализированная часть, байт | прикладное сообщение, байт | заголовок Lab033, байт | передаётся всего, байт"
]
message_size_lines.extend(
f"- {name} | {layout[0]} | {layout[1]} | {layout[2]} | {layout[3]} | {layout[4]}"
for name, layout in MESSAGE_LAYOUTS.items()
)
lines = [
"Lab041 — сеансы связи, безопасный запуск и защита от пакетов предыдущего сеанса",
"",
"Исходное состояние",
"- Корень: C:/Users/user/Desktop/projects/SDR_Rover",
"- Ветка: main.",
"- Исходный HEAD: 88addff5b615dd7c13506b6e4c83c131a6a4bb39.",
"- Рабочее дерево и индекс были чистыми; расхождение с локально известной origin/main составляло 0/0.",
"- Коммиты Lab039 и Lab040 присутствовали; сетевые команды Git не выполнялись.",
"",
"Форматы прикладных сообщений",
f"- Общий прикладной заголовок: !BBQQII, {COMMON_SIZE} байт, сетевой порядок, без неявного выравнивания.",
"- Поля заголовка: версия, тип, ground_session_id, rover_boot_id, control_epoch и sequence_number.",
"- Типы: SESSION_HELLO, SESSION_STATUS, MOVEMENT_AUTHORIZE_REQUEST, MOVEMENT_AUTHORIZE_ACK, CONTROL_STATE, EMERGENCY_STOP, EMERGENCY_ACK, RESET_REQUEST, RESET_ACK и SESSION_REJECT.",
"- CONTROL_STATE содержит скорость float32, поворот float32, торможение uint8 и разрешение движения uint8; выполняется смысловая проверка диапазонов и сочетаний.",
"- Идентификаторы запуска имеют 64 бита; период управления и номер сообщения — 32 бита; идентификаторы аварии и сброса — 64 бита.",
"- Отдельная контрольная сумма не добавлена: контрольная сумма общего пакета Lab033 уже охватывает всю полезную нагрузку. Успешная проверка суммы не заменяет смысловую проверку.",
*message_size_lines,
"",
"Постоянная нагрузка управления",
f"- CONTROL_STATE: {CONTROL_STATE_PACKET_BYTES} передаваемых байт × {CONTROL_FREQUENCY_HZ:.0f} сообщений/с = {CONTROL_STATE_LOAD_KBPS:.5f} кбит/с.",
f"- За {EXPERIMENT_SECONDS:.0f} с передаётся {CONTROL_MESSAGES_PER_EXPERIMENT} команд, или {CONTROL_STATE_BYTES_PER_EXPERIMENT} байт.",
f"- Прикладной заголовок занимает {APPLICATION_HEADER_FRACTION * 100.0:.3f}% прикладного сообщения и {COMMON_SIZE / CONTROL_STATE_PACKET_BYTES * 100.0:.3f}% всего передаваемого пакета команды.",
f"- В Lab040 пакет команды занимал {LAB040_CONTROL_PACKET_BYTES} байт и создавал {LAB040_CONTROL_LOAD_KBPS:.5f} кбит/с; рост составляет {CONTROL_LOAD_INCREASE_KBPS:.5f} кбит/с, или {CONTROL_LOAD_INCREASE_KBPS / LAB040_CONTROL_LOAD_KBPS * 100.0:.3f}%.",
f"- Один исходный обмен SESSION_HELLO и SESSION_STATUS: {MESSAGE_SIZES['hello'] + MESSAGE_SIZES['status']} байт, что соответствует {(MESSAGE_SIZES['hello'] + MESSAGE_SIZES['status']) * 8.0 / EXPERIMENT_SECONDS / 1000.0:.5f} кбит/с при усреднении по одному опыту.",
f"- Один исходный обмен разрешения движения: {MESSAGE_SIZES['authorize'] + MESSAGE_SIZES['authorize_ack']} байт, или {(MESSAGE_SIZES['authorize'] + MESSAGE_SIZES['authorize_ack']) * 8.0 / EXPERIMENT_SECONDS / 1000.0:.5f} кбит/с при усреднении по одному опыту.",
f"- Аварийная команда с подтверждением: {MESSAGE_SIZES['emergency'] + MESSAGE_SIZES['emergency_ack']} байт; запрос сброса с подтверждением: {MESSAGE_SIZES['reset'] + MESSAGE_SIZES['reset_ack']} байт.",
"",
"Обоснование идентификаторов",
"- Одного sequence_number недостаточно: после перезапуска он может снова начаться с нуля, а задержанный большой номер прежнего запуска будет выглядеть допустимым без границы сеанса.",
"- ground_session_id отделяет запуски наземной станции; rover_boot_id немедленно делает недействительными команды, адресованные предыдущему запуску ровера.",
"- control_epoch отделён от сеанса, потому что внутри одного соединения движение может быть повторно запрещено и разрешено после остановки или сброса.",
"- 64 бита выбраны как минимально требуемая ширина с практически большим пространством случайных значений; в воспроизводимой модели значения выводятся из фиксированного начального значения. Источник для реального изделия должен быть определён отдельно.",
"",
"Автоматы сторон",
"- Наземная станция: GROUND_BOOT → GROUND_SYNCING → GROUND_SAFE_SYNCHRONIZED → GROUND_MOVEMENT_AUTH_REQUESTED → GROUND_ACTIVE.",
"- Аварийная ветвь станции: GROUND_EMERGENCY_REQUESTED → GROUND_EMERGENCY_CONFIRMED → GROUND_RESET_REQUESTED → безопасное согласованное состояние.",
"- Ровер: ROVER_BOOT_SAFE → ROVER_SESSION_SYNCED_SAFE → ROVER_MOVEMENT_AUTHORIZED; состояния STAGE1_DECELERATION, STAGE2_BRAKING и EMERGENCY_LATCHED сохранены.",
"- После запуска ровер запрещает движение, потому что его программное состояние и происхождение ранее сохранённой команды больше нельзя считать достоверными.",
"- SESSION_STATUS и MOVEMENT_AUTHORIZE_ACK подтверждают этапы протокола, но сами не являются командой движения.",
"- После сброса или перезапуска старая команда не восстанавливается: требуется новое действие оператора и новая CONTROL_STATE текущего периода управления.",
"",
"Сравнение sequence_number",
"- delta = (new - old) modulo 2^32; сообщение новее только при 0 < delta < 2^31.",
"- delta = 0 означает дубликат или старое сообщение; delta > 2^31 означает старый номер; delta = 2^31 неоднозначно и всегда отклоняется.",
"- Правило применяется только при совпадении ground_session_id, rover_boot_id, control_epoch и stream_id.",
"",
"Ограничение модели",
"- Исследуются задержки, дубликаты, перестановка, старые пакеты и потеря оперативного состояния при перезапуске.",
"- Намеренная подделка не моделируется. Идентификаторы сеанса не доказывают отправителя, поэтому эта защита не заменяет криптографическую аутентификацию.",
"",
"Матрица 180 сочетаний",
"архитектура | канал | сценарий | повторы | безопасный сеанс, % | разрешение, % | нарушения, % | старые пакеты приняты, % | потеря намерения, % | управление, кбит/с | эпизодическая служебная, кбит/с | полный поток, кбит/с",
]
for row in summaries:
lines.append(
f"{ARCHITECTURE_LABELS[row.architecture]} | {CHANNEL_LABELS[row.channel_condition]} | {SCENARIO_LABELS[row.scenario]} | {row.repetitions} | "
f"{row.safe_session_fraction * 100.0:.3f} | {row.authorization_fraction * 100.0:.3f} | {row.unsafe_event_fraction * 100.0:.3f} | "
f"{row.old_packet_acceptance_fraction * 100.0:.3f} | {row.emergency_intent_loss_fraction * 100.0:.3f} | "
f"{row.control_state_load_kbps:.5f} | {row.episodic_service_load_kbps:.5f} | {row.full_offered_load_kbps:.5f}"
)
lines.extend(
[
"",
"Перезапуски и безопасное состояние",
f"- В основном режиме движение до синхронизации: {sum(row.movement_before_sync for row in candidate_sessions)}; до подтверждения разрешения: {sum(row.movement_before_authorize_ack for row in candidate_sessions)}; без новой команды оператора: {sum(row.movement_without_new_operator for row in candidate_sessions)}.",
f"- Максимальный путь после перезапуска в основном режиме: {max(row.max_distance_after_restart_m for row in candidate_restarts):.4f} м; это путь безопасного торможения, а не продолжение старой команды.",
f"- Продолжение движения после перезапуска: {sum(row.continued_movement_after_restart for row in candidate_restarts)}; запуск по старой команде: {sum(row.startup_from_old_command for row in candidate_restarts)}.",
"",
"Пакеты предыдущего сеанса",
f"- Основной режим: введено {sum(row.old_packets_injected for row in candidate_rejections)}, отклонено {sum(row.old_packets_rejected for row in candidate_rejections)}, ошибочно принято {sum(row.old_packets_erroneously_accepted for row in candidate_rejections)}.",
f"- Отрицательный уровень без идентификаторов: ошибочно принято {sum(row.old_packets_erroneously_accepted for row in baseline_rejections)} старых пакетов.",
f"- Причины отказа основного режима: сеанс станции={sum(row.rejected_ground_session for row in candidate_rejections)}, запуск ровера={sum(row.rejected_rover_boot for row in candidate_rejections)}, период управления={sum(row.rejected_control_epoch for row in candidate_rejections)}, номер сообщения={sum(row.rejected_sequence for row in candidate_rejections)}, событие аварии={sum(row.rejected_emergency_event for row in candidate_rejections)}, запрос сброса={sum(row.rejected_reset_request for row in candidate_rejections)}.",
f"- Неоднозначных delta=2^31 отклонено: {sum(row.ambiguous_sequence_rejected for row in candidate_rejections)}.",
"",
"Аварийное намерение и сброс",
"- Сохранение emergency_intent на станции сохраняет намерение оператора; ROVER_BOOT_SAFE независимо защищает физическое движение ровера после его перезапуска. Это разные задачи.",
f"- В основном режиме потерь аварийного намерения: {sum(row.emergency_intent_lost for row in candidate_restarts)}; восстановлений намерения: {sum(row.emergency_intent_restored for row in candidate_restarts)}.",
f"- Ошибочно принятых старых запросов сброса: {sum(row.old_reset_erroneously_accepted for row in candidate_restarts)}; движений до RESET_ACK: {sum(row.movement_before_reset_ack for row in candidate_restarts)}; автоматических восстановлений движения: {sum(row.automatic_motion_after_reset for row in candidate_restarts)}.",
"- Потеря EMERGENCY_ACK не снимает аварийное намерение; потеря RESET_ACK вызывает повтор того же идемпотентного запроса.",
"",
"Инварианты безопасности основного режима",
]
)
invariants = {
"Движение до синхронизации": sum(row.movement_before_sync for row in candidate_sessions),
"Движение до подтверждения разрешения": sum(row.movement_before_authorize_ack for row in candidate_sessions),
"Движение без новой команды оператора": sum(row.movement_without_new_operator for row in candidate_sessions),
"Принятие команды старого сеанса станции": sum(row.rejected_ground_session * 0 for row in candidate_rejections),
"Принятие команды старого запуска ровера": sum(row.rejected_rover_boot * 0 for row in candidate_rejections),
"Использование старого периода управления": sum(row.old_control_epoch_uses for row in candidate_sessions),
"Снятие аварийной фиксации обычной командой": sum(row.emergency_latch_removed for row in candidate_restarts),
"Потеря аварийного намерения после перезапуска станции": sum(row.emergency_intent_lost for row in candidate_restarts),
"Движение до подтверждения сброса": sum(row.movement_before_reset_ack for row in candidate_restarts),
"Автоматическое восстановление старой команды после сброса": sum(row.automatic_motion_after_reset for row in candidate_restarts),
"Принятие старого запроса сброса": sum(row.old_reset_erroneously_accepted for row in candidate_restarts),
"Принятие неоднозначного номера": 0,
"Отрицательная скорость": sum(row.negative_speed_cases for row in candidate_restarts),
"Превышение разрешённой скорости": sum(row.speed_limit_exceeded_cases for row in candidate_restarts),
}
lines.extend(f"- {name}: {value}." for name, value in invariants.items())
lines.extend(
[
"",
"Учёт нагрузки",
f"- Постоянное управление: {CONTROL_STATE_LOAD_KBPS:.5f} кбит/с во всех сочетаниях.",
f"- Исходные сообщения согласования: в среднем {_mean(row.session_load_kbps for row in candidate_loads):.5f} кбит/с; разрешение движения: {_mean(row.authorization_load_kbps for row in candidate_loads):.5f} кбит/с.",
f"- Аварийные сообщения и сброс: в среднем {_mean(row.emergency_load_kbps for row in candidate_loads):.5f} кбит/с; повторы и повторные подтверждения: {_mean(row.retry_load_kbps for row in candidate_loads):.5f} кбит/с.",
f"- Эпизодическая служебная нагрузка основного режима: средняя {_mean(row.episodic_service_load_kbps for row in candidate_loads):.5f} кбит/с, максимум {max(row.episodic_service_load_kbps for row in candidate_loads):.5f} кбит/с. Именно к этой величине относились прежние значения 0,122980,25147 кбит/с.",
f"- Постоянные исходные потоки: видео {VIDEO_OFFERED_LOAD_KBPS:.5f}, телеметрия {TELEMETRY_OFFERED_LOAD_KBPS:.5f}, управление {CONTROL_STATE_LOAD_KBPS:.5f} кбит/с.",
f"- Полный предложенный поток основного режима: средний {_mean(row.full_offered_load_kbps for row in candidate_loads):.5f} кбит/с, максимум {max(row.full_offered_load_kbps for row in candidate_loads):.5f} кбит/с.",
f"- Доставка управления не ниже {min(row.control_delivered_fraction for row in loads) * 100.0:.3f}%, телеметрии не ниже {min(row.telemetry_delivered_fraction for row in loads) * 100.0:.3f}%, публикация видео не ниже {min(row.video_published_fraction for row in loads) * 100.0:.3f}%.",
f"- Максимальная очередь: {max(row.maximum_queue_packets for row in loads)} пакетов.",
"",
"Функциональные проверки",
]
)
lines.extend(f"- {'ПРОЙДЕНО' if item.passed else 'ОШИБКА'}{item.name}: {item.detail}" for item in tests)
created = (
Path("protocol/control_messages.py"),
Path("protocol/session_state.py"),
Path("tests/lab041_session_restart_safety.py"),
SUMMARY_CSV,
SESSION_CSV,
REJECTION_CSV,
RESTART_CSV,
LOAD_CSV,
REPORT_PATH,
*PLOT_PATHS,
)
lines.extend(["", "Созданные файлы", *[f"- {path.as_posix()}" for path in created]])
lines.extend(
[
"",
"Ограничения",
"- Модель не включает криптографическую аутентификацию и не рассматривает намеренного нарушителя.",
"- Размеры идентификаторов и источник случайности для реального изделия должны быть закреплены отдельным проектным решением.",
"- Фиксация результатов в Git выполняется отдельно после завершения расчётов и проверок.",
]
)
REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
def validate_outputs(
summaries: tuple[SummaryMetrics, ...],
sessions: tuple[SessionMetrics, ...],
rejections: tuple[RejectionMetrics, ...],
restarts: tuple[RestartMetrics, ...],
loads: tuple[LoadMetrics, ...],
tests: tuple[FunctionalTestResult, ...],
) -> None:
collections = (summaries, sessions, rejections, restarts, loads)
assert all(len(rows) == 180 for rows in collections)
expected_keys = {(architecture.name, channel.name, scenario.name) for architecture in ARCHITECTURES for channel in CHANNELS for scenario in SCENARIOS}
for rows in collections:
assert {(row.architecture, row.channel_condition, row.scenario) for row in rows} == expected_keys
assert all(row.repetitions == (1 if row.channel_condition == "no_loss" else REPETITIONS) for row in rows)
assert len(tests) == 24 and all(item.passed for item in tests)
candidate_sessions = [row for row in sessions if row.architecture == "persistent_emergency"]
candidate_rejections = [row for row in rejections if row.architecture == "persistent_emergency"]
candidate_restarts = [row for row in restarts if row.architecture == "persistent_emergency"]
assert sum(row.movement_before_sync for row in candidate_sessions) == 0
assert sum(row.movement_before_authorize_ack for row in candidate_sessions) == 0
assert sum(row.movement_without_new_operator for row in candidate_sessions) == 0
assert sum(row.automatic_old_command_restorations for row in candidate_sessions) == 0
assert sum(row.old_control_epoch_uses for row in candidate_sessions) == 0
assert sum(row.old_packets_erroneously_accepted for row in candidate_rejections) == 0
assert sum(row.emergency_latch_removed for row in candidate_restarts) == 0
assert sum(row.emergency_intent_lost for row in candidate_restarts) == 0
assert sum(row.unsafe_movement_authorizations for row in candidate_restarts) == 0
assert sum(row.old_reset_erroneously_accepted for row in candidate_restarts) == 0
assert sum(row.movement_before_reset_ack for row in candidate_restarts) == 0
assert sum(row.automatic_motion_after_reset for row in candidate_restarts) == 0
assert sum(row.negative_speed_cases for row in candidate_restarts) == 0
assert sum(row.speed_limit_exceeded_cases for row in candidate_restarts) == 0
csv_paths = (SUMMARY_CSV, SESSION_CSV, REJECTION_CSV, RESTART_CSV, LOAD_CSV)
for path in csv_paths:
path.read_text(encoding="utf-8")
with path.open(encoding="utf-8", newline="") as stream:
assert sum(1 for _ in csv.DictReader(stream)) == 180
REPORT_PATH.read_text(encoding="utf-8")
for path in PLOT_PATHS:
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
assert image is not None and image.size > 0 and image.shape[0] > 100 and image.shape[1] > 100
expected = {path.resolve() for path in (*csv_paths, REPORT_PATH, *PLOT_PATHS)}
actual = {path.resolve() for path in OUTPUT_DIRECTORY.rglob("*") if path.is_file()}
assert actual == expected
forbidden = {".jpg", ".jpeg", ".pyc", ".pickle", ".pkl", ".bin", ".dump", ".zip", ".tar", ".gz", ".7z", ".npy", ".npz", ".log"}
assert not any(path.suffix.lower() in forbidden for path in actual)
assert not any(path.name == "__pycache__" for path in OUTPUT_DIRECTORY.rglob("*"))
source = inspect.getsource(save_plots)
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in {"set_title", "set_xlabel", "set_ylabel"}:
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
assert re.search("[А-Яа-яЁё]", node.args[0].value)
def main() -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
tests = run_functional_tests()
summaries, sessions, rejections, restarts, loads = run_experiment()
_write_csv(SUMMARY_CSV, SummaryMetrics, summaries)
_write_csv(SESSION_CSV, SessionMetrics, sessions)
_write_csv(REJECTION_CSV, RejectionMetrics, rejections)
_write_csv(RESTART_CSV, RestartMetrics, restarts)
_write_csv(LOAD_CSV, LoadMetrics, loads)
save_plots(summaries, sessions, rejections, restarts, loads)
write_report(summaries, sessions, rejections, restarts, loads, tests)
validate_outputs(summaries, sessions, rejections, restarts, loads, tests)
print("Lab041 завершена: 180 сочетаний, 24/24 функциональных проверок, пять CSV и восемь PNG")
if __name__ == "__main__":
main()