Audit found seven numbers in simulate_trial that were neither modelled by Lab041 nor traceable to their claimed source. Video fraction 0.910 did not match Lab040 (0.9603); base queue length 31 did not match Lab040 (36-38). - drop video/telemetry/control delivered fractions and queue length: these belong to Lab037 and Lab040 and are not modelled here - drop lab041_traffic_impact.png, which plotted only those metrics - drop negative_speed_cases and speed_limit_exceeded_cases, never computed, along with the tautological asserts that checked them - bind braking parameters to the Lab039 nominal profile via named constants instead of the literals 0.250 and the divisor 6.0 - cite Lab038 as the source of the transferred video and telemetry loads - rewrite the invariants section: nine architecture-derived properties now carry functional-check references, five unmeasurable ones move to their own section - extend check 04 with negative speed and the 15 m/s boundary - state explicitly what the matrix rolls, what the architecture fixes and what is not modelled at all Regenerated: 180 combinations, 24/24 checks, five CSV and seven PNG. Six columns removed, no retained value changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1514 lines
86 KiB
Python
1514 lines
86 KiB
Python
"""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",
|
||
)
|
||
|
||
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, а не подобраны заново.
|
||
# tests/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("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
|
||
|
||
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()
|