Environment, README, gitignore and the source self-parsing in Lab041. requirements.txt and requirements-hardware.txt pin the versions actually verified today on Python 3.13.1, split so the project comes up on a machine with no SDR: pyadi-iio and libiio are only needed from Lab042 on. The gitignore contradiction is fixed in the direction that keeps the project reproducible. data/raw/ was ignored while six files were tracked anyway, and four of them are the source images Lab007, Lab009, Lab011 and Lab012 read as input. Untracking them would have broken those labs, so the rule now carries explicit exceptions instead. Recorded in the README: lab026_rover_source.mp4 is genuinely absent and Lab026 and Lab027 cannot run without it. README replaced. It claimed the next task was to write the first protocol module, 41 labs later. It now states what exists, that the transmitter has never been switched on, how to install either environment, how to run tests, the gate and a lab, and that rover/, ground_station/ and gnuradio/ are still empty. Added rule 9: every number in a report must come from the current run, transferred values must name their source. Lab041 no longer parses its own source to check that plot labels are in Russian. That belonged to presentation, not to model correctness, and turned the experiment into an analyser of its own text; ast, inspect and re are no longer imported. Rerun: 180 combinations, 24/24 checks, five CSV and seven PNG, unchanged. 125 tests pass, gate passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1510 lines
86 KiB
Python
1510 lines
86 KiB
Python
"""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()
|