"""Lab041: сеансы связи, безопасный запуск и старые пакеты.""" from __future__ import annotations import csv from dataclasses import asdict, dataclass from enum import IntEnum from pathlib import Path 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", ) 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 # Перенесённые величины. Источник указан явно, чтобы их нельзя было принять за # результат моделирования Lab041: оба значения совпадают с выводом Lab038. # data/processed/lab038/lab038_summary.csv, поле video_load_kbps. VIDEO_OFFERED_LOAD_KBPS = 243.39286666666666 # data/processed/lab038/lab038_summary.csv, поле telemetry_load_kbps. TELEMETRY_OFFERED_LOAD_KBPS = 7.68 # Параметры торможения взяты из профиля "nominal" Lab039, а не подобраны заново. # experiments/lab039_two_stage_braking.py: DecelerationProfile("nominal", 1.0, 3.0). # Оценка пути ниже одноступенчатая и потому консервативнее двухступенчатой # модели Lab039: она не учитывает предварительное замедление Stage1. LAB039_NOMINAL_STAGE2_MPS2 = 3.0 LAB039_STAGE2_THRESHOLD_SECONDS = 0.250 # Принятое допущение: горизонт, на котором оценивается путь ровера, не # получившего безопасного запрета. Из модели не выводится, выбран как # заведомо большой по сравнению с временем согласования сеанса. UNPROTECTED_RUNAWAY_SECONDS = 60.0 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 session_load_bytes: int authorization_load_bytes: int emergency_load_bytes: int retry_load_bytes: int episodic_service_bytes: 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 @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 @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 @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: braking_distance = INITIAL_SPEED_MPS**2 / (2.0 * LAB039_NOMINAL_STAGE2_MPS2) restart_to_safe = min(sync_time, LAB039_STAGE2_THRESHOLD_SECONDS) if scenario.number == 2 else 0.0 if scenario.number == 2: distance_after_restart = INITIAL_SPEED_MPS * LAB039_STAGE2_THRESHOLD_SECONDS + braking_distance elif scenario.number in (3, 4, 6, 20): distance_after_restart = braking_distance else: distance_after_restart = INITIAL_SPEED_MPS * UNPROTECTED_RUNAWAY_SECONDS 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 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, session_initial_bytes, authorization_initial_bytes, emergency_initial_bytes, retry_bytes, episodic_service_bytes, ) 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, ) 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), ) 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, ) 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, -0.1, 0.0, False, True), ControlStateMessage(context, -5.0, 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 assert encode_message(ControlStateMessage(context, 15.0, 0.0, False, True)) assert encode_message(ControlStateMessage(context, 0.0, 0.0, True, False)) return "диапазоны и сочетания полей управления проверены, включая отрицательную скорость и границу 15 м/с" 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) 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.", "", "Ограничение модели", "- Исследуются задержки, дубликаты, перестановка, старые пакеты и потеря оперативного состояния при перезапуске.", "- Намеренная подделка не моделируется. Идентификаторы сеанса не доказывают отправителя, поэтому эта защита не заменяет криптографическую аутентификацию.", "- Что именно моделируется розыгрышем: моменты потерь по экспоненциальной модели Good-Bad, доставка и повтор служебных сообщений сеанса, время до безопасного согласования и до разрешения движения, объём эпизодической служебной нагрузки. Только эти величины являются результатом моделирования Lab041.", "- Что задано архитектурой, а не разыгрывается: исход обработки старого пакета, разрешено ли движение после перезапуска и сохраняется ли аварийное намерение. Матрица перебирает эти случаи, а поведение реализации проверяется отдельно функциональными проверками.", "- Что не моделируется вовсе: транспорт видео, планировщик очередей, доставка телеметрии и доставка команд по радиоканалу. Соответствующие показатели относятся к Lab037 и Lab040 и в Lab041 не пересчитываются.", "- Оценка пути после перезапуска получена по замкнутой формуле при параметрах профиля nominal Lab039 и одинакова во всех повторах; это расчёт, а не результат розыгрыша.", "", "Матрица 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"- Величина рассчитана по формуле v*t + v^2/(2a) при v = {INITIAL_SPEED_MPS:.4f} м/с, t = {LAB039_STAGE2_THRESHOLD_SECONDS:.3f} с и a = {LAB039_NOMINAL_STAGE2_MPS2:.1f} м/с^2 из профиля nominal Lab039. Оценка одноступенчатая и не учитывает предварительное замедление Stage1, поэтому она консервативнее двухступенчатой модели Lab039.", 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)}.", "- Эти числа получены перебором таблицы сценариев: семь сценариев со старым пакетом умножаются на число повторов, а исход перебора задан архитектурой, а не розыгрышем помех. Они показывают охват перебора, а не измеренную вероятность отклонения.", "- Что старый пакет действительно отклоняется реализацией, показывают проверки 06, 07, 08, 10 и 19: они вызывают receive_control и receive_reset настоящих автоматов и сверяют причину отказа.", 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 вызывает повтор того же идемпотентного запроса.", "", "Инварианты безопасности основного режима", ] ) lines.extend( [ "- Что означают числа ниже. Счётчики матрицы показывают, сколько сочетаний сценария и канала описывают небезопасный переход. В основном режиме безопасный запуск и постоянное аварийное намерение исключают такие переходы по построению архитектуры, поэтому счётчик равен нулю как следствие выбранной архитектуры, а не как результат розыгрыша помех.", "- Поведение самой реализации подтверждается функциональными проверками; их номера указаны рядом с каждым свойством. Именно проверки, а не матрица, являются доказательством для перечисленных свойств.", ] ) invariants = { "Движение до синхронизации": (sum(row.movement_before_sync for row in candidate_sessions), "проверки 11, 15"), "Движение до подтверждения разрешения": (sum(row.movement_before_authorize_ack for row in candidate_sessions), "проверки 12, 13"), "Движение без новой команды оператора": (sum(row.movement_without_new_operator for row in candidate_sessions), "проверка 14"), "Использование старого периода управления": (sum(row.old_control_epoch_uses for row in candidate_sessions), "проверка 08"), "Снятие аварийной фиксации обычной командой": (sum(row.emergency_latch_removed for row in candidate_restarts), "проверка 22"), "Потеря аварийного намерения после перезапуска станции": (sum(row.emergency_intent_lost for row in candidate_restarts), "проверки 17, 18"), "Движение до подтверждения сброса": (sum(row.movement_before_reset_ack for row in candidate_restarts), "проверки 20, 21"), "Автоматическое восстановление старой команды после сброса": (sum(row.automatic_motion_after_reset for row in candidate_restarts), "проверки 13, 14"), "Принятие старого запроса сброса": (sum(row.old_reset_erroneously_accepted for row in candidate_restarts), "проверка 19"), } lines.extend(f"- {name}: {value} ({reference})." for name, (value, reference) in invariants.items()) lines.extend( [ "", "Свойства, которые матрица не измеряет", "- Следующие свойства зависят от разбора конкретного сообщения, а не от розыгрыша сценария, поэтому счётчика по 180 сочетаниям для них не существует. Ранее они выводились в списке инвариантов как нули; это было ошибкой представления, и такие строки удалены.", "- Принятие команды старого сеанса станции: подтверждается проверками 06 и 16.", "- Принятие команды старого запуска ровера: подтверждается проверкой 07.", "- Принятие неоднозначной разности номеров 2^31: подтверждается проверкой 10.", "- Отрицательная скорость и превышение разрешённой скорости: подтверждаются проверкой 04; диапазон 0...15 м/с проверяется при кодировании в protocol/control_messages.py.", ] ) 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,12298–0,25147 кбит/с.", f"- Постоянные исходные потоки: видео {VIDEO_OFFERED_LOAD_KBPS:.5f}, телеметрия {TELEMETRY_OFFERED_LOAD_KBPS:.5f}, управление {CONTROL_STATE_LOAD_KBPS:.5f} кбит/с.", "- Видео и телеметрия здесь не моделируются: значения перенесены из Lab038, поля video_load_kbps и telemetry_load_kbps файла data/processed/lab038/lab038_summary.csv. Управление вычислено по размеру пакета и частоте.", 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} кбит/с.", "- Доли доставки видео, телеметрии и управления, а также длина очереди в Lab041 не определяются. Модель этой лабораторной воспроизводит обмен служебными сообщениями сеанса, но не планировщик очередей и не транспорт видео. Соответствующие показатели следует брать из Lab037 и Lab040.", "", "Функциональные проверки", ] ) 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("experiments/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 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("*")) # Прежде здесь лабораторная разбирала собственный исходный код, чтобы # убедиться, что подписи на графиках русские. Проверка снята: она # относится к оформлению, а не к корректности модели, и превращала # эксперимент в анализатор своего же текста. Подписи видны на готовых # PNG и проверяются при просмотре результатов. 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()