Files
SDR-Rover/tests/lab040_persistent_emergency.py
2026-08-05 13:12:49 +03:00

1750 lines
86 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Lab040: persistent emergency intent and safe movement reauthorization."""
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_failsafe import ControlState, SAFE_STATE, decode_control_state, encode_control_state
from protocol.emergency_ack import EmergencyAckReceiver, EmergencyIdentity, acknowledged_identity, build_emergency_ack
from protocol.link_packet import Direction, LinkPacket, TrafficClass, decode_link_packet, encode_link_packet
from protocol.persistent_emergency import GroundState, PersistentEmergencyController
from protocol.safe_reset import (
ResetAcknowledgement,
ResetCommand,
ResetDecision,
ResetReason,
STREAM_RESET_ACK,
SafeResetReceiver,
build_reset_ack,
build_reset_packet,
decode_reset_ack,
)
from protocol.two_stage_failsafe import (
DecelerationProfile,
SafetyState,
ThresholdPolicy,
TwoStageFailsafe,
integrate_kinematics,
)
from tests.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY
from tests.lab037_lossy_full_link import bad_intervals
from tests.lab038_control_failsafe import DURATION_SECONDS, Frame, Unit, Workload, build_workload, packet_is_lost
OUTPUT_DIRECTORY = Path("data/processed/lab040")
SUMMARY_CSV = OUTPUT_DIRECTORY / "lab040_summary.csv"
EMERGENCY_CSV = OUTPUT_DIRECTORY / "lab040_emergency_metrics.csv"
RESET_CSV = OUTPUT_DIRECTORY / "lab040_reset_metrics.csv"
MOTION_CSV = OUTPUT_DIRECTORY / "lab040_motion_metrics.csv"
LOAD_CSV = OUTPUT_DIRECTORY / "lab040_load_metrics.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab040_report.txt"
PLOT_PATHS = (
OUTPUT_DIRECTORY / "lab040_emergency_delivery.png",
OUTPUT_DIRECTORY / "lab040_unsafe_recovery.png",
OUTPUT_DIRECTORY / "lab040_latch_time.png",
OUTPUT_DIRECTORY / "lab040_stop_path.png",
OUTPUT_DIRECTORY / "lab040_reset_latency.png",
OUTPUT_DIRECTORY / "lab040_protection_load.png",
OUTPUT_DIRECTORY / "lab040_video_telemetry.png",
OUTPUT_DIRECTORY / "lab040_tradeoff.png",
)
REPETITIONS = 200
CONTROL_PERIOD_SECONDS = 0.050
REPEAT_PERIOD_SECONDS = 0.050
OPERATOR_EMERGENCY_SECONDS = 30.0
OPERATOR_RESET_SECONDS = 60.0
NEW_MOTION_DELAY_SECONDS = 2.0
EVENT_ID = 1
EMERGENCY_SEQUENCE = 1
RESET_REQUEST_ID = 1
RESET_SEQUENCE = 1
SEED_OFFSET = 400_000_000
EPSILON = 1e-12
POLICY = ThresholdPolicy("two_150_250", 0.150, 0.250)
PROFILE = DecelerationProfile("nominal", 1.0, 3.0)
SPEEDS_KMH = (5.0, 15.0, 25.0)
class Mode(IntEnum):
LIMITED_LAB038 = 1
PERSISTENT_NO_RESET = 2
PERSISTENT_SAFE_RESET = 3
MODE_NAMES = {
Mode.LIMITED_LAB038: "limited_lab038",
Mode.PERSISTENT_NO_RESET: "persistent_no_reset",
Mode.PERSISTENT_SAFE_RESET: "persistent_safe_reset",
}
MODE_LABELS = {
"limited_lab038": "Ограниченное повторение",
"persistent_no_reset": "Постоянное намерение",
"persistent_safe_reset": "Постоянное намерение и сброс",
}
@dataclass(frozen=True)
class ChannelCondition:
name: str
label: str
rate_kbps: float
mean_bad_ms: float | None
repetitions: int
CHANNELS = (
ChannelCondition("no_loss", "Без потерь, 300 кбит/с", 300.0, None, 1),
ChannelCondition("300_50", "300 кбит/с, помеха 50 мс", 300.0, 50.0, REPETITIONS),
ChannelCondition("260_200", "260 кбит/с, помеха 200 мс", 260.0, 200.0, REPETITIONS),
ChannelCondition("230_200", "230 кбит/с, помеха 200 мс", 230.0, 200.0, REPETITIONS),
ChannelCondition("230_1000", "230 кбит/с, помеха 1000 мс", 230.0, 1000.0, REPETITIONS),
)
@dataclass(frozen=True)
class TransportTrial:
control_receives: tuple[tuple[float, int, ControlState], ...]
emergency_delivered: bool
emergency_first_receive_seconds: float | None
emergency_copies: int
emergency_copies_lost: int
emergency_ack_seconds: float | None
emergency_acks_lost: int
all_first_500ms_copies_lost: bool
emergency_intent_fraction: float
positive_commands_after_emergency: int
permitted_commands_after_emergency: int
time_to_motion_command_suppression_seconds: float
reset_requests_created: int
reset_requests_transmitted: int
reset_requests_accepted: int
reset_requests_rejected: int
reset_rejection_reasons: tuple[tuple[str, int], ...]
reset_first_accept_seconds: float | None
reset_ack_seconds: float | None
reset_acks_lost: int
reset_duplicates: int
reset_reacks: int
new_motion_command_seconds: float | None
automatic_old_command_restorations: int
offered_bytes: tuple[tuple[str, int], ...]
video_published: int
telemetry_delivered: int
max_queue_packets: int
negative_time_order: int
@dataclass(frozen=True)
class MotionTrial:
distance_to_braking_m: float
distance_to_stop_m: float
time_to_stop_seconds: float
maximum_speed_after_emergency_mps: float
stopped: bool
reaccelerations_before_reset: int
braking_releases_before_reset_ack: int
movement_while_intent: int
recovery_after_lost_emergency: int
watchdog_stopped_after_lost_emergency: int
unsafe_recovery: int
stop_to_resume_seconds: float
distance_after_unsafe_resume_m: float
movement_before_reset_ack: int
actual_resume_seconds: float
negative_speed_cases: int
target_speed_exceeded_cases: int
@dataclass(frozen=True)
class SummaryMetrics:
channel_condition: str
mode: str
initial_speed_kmh: float
repetitions: int
emergency_delivery_fraction: float
emergency_intent_time_fraction: float
unsafe_recovery_fraction: float
full_stop_fraction: float
movement_while_intent_cases: int
movement_before_reset_ack_cases: int
positive_commands_after_emergency_mean: float
permitted_commands_after_emergency_mean: float
total_extra_load_kbps: float
video_published_fraction: float
telemetry_delivered_fraction: float
maximum_queue_packets: int
@dataclass(frozen=True)
class EmergencyMetrics:
channel_condition: str
mode: str
initial_speed_kmh: float
repetitions: int
delivered_fraction: float
mean_latch_delay_ms: float
p95_latch_delay_ms: float
max_latch_delay_ms: float
mean_copies: float
mean_lost_copies: float
mean_ack_delay_ms: float
acknowledgements_lost_mean: float
intent_time_fraction: float
positive_commands_after_emergency_mean: float
permitted_commands_after_emergency_mean: float
time_to_command_suppression_mean_ms: float
all_first_500ms_lost: int
@dataclass(frozen=True)
class ResetMetrics:
channel_condition: str
mode: str
initial_speed_kmh: float
repetitions: int
requests_created_mean: float
requests_transmitted_mean: float
requests_accepted_mean: float
requests_rejected_mean: float
rejected_not_latched: int
rejected_moving: int
rejected_stale_link: int
rejected_event_mismatch: int
rejected_old_sequence: int
rejected_nonzero_request: int
rejected_movement_permitted: int
mean_accept_delay_ms: float
mean_reset_ack_delay_ms: float
p95_reset_ack_delay_ms: float
reset_acks_lost_mean: float
repeated_requests_mean: float
duplicates_suppressed_mean: float
reset_reacks_mean: float
mean_ack_to_new_command_seconds: float
mean_time_to_actual_resume_seconds: float
movement_before_reset_ack_cases: int
automatic_old_command_restorations: int
@dataclass(frozen=True)
class MotionMetrics:
channel_condition: str
mode: str
initial_speed_kmh: float
repetitions: int
mean_distance_to_braking_m: float
p95_distance_to_braking_m: float
max_distance_to_braking_m: float
mean_distance_to_stop_m: float
p95_distance_to_stop_m: float
max_distance_to_stop_m: float
mean_time_to_stop_seconds: float
p95_time_to_stop_seconds: float
maximum_speed_after_emergency_mps: float
full_stop_fraction: float
reaccelerations_before_reset: int
braking_releases_before_reset_ack: int
movement_while_intent: int
recovery_after_lost_emergency: int
watchdog_stops_after_lost_emergency: int
unsafe_recoveries: int
mean_stop_to_resume_seconds: float
mean_distance_after_unsafe_resume_m: float
negative_speed_cases: int
target_speed_exceeded_cases: int
@dataclass(frozen=True)
class LoadMetrics:
channel_condition: str
mode: str
initial_speed_kmh: float
repetitions: int
emergency_copy_load_kbps: float
emergency_ack_load_kbps: float
zero_control_load_kbps: float
reset_request_load_kbps: float
reset_ack_load_kbps: float
total_extra_load_kbps: float
video_published_fraction: float
telemetry_delivered_fraction: float
maximum_queue_packets: int
@dataclass(frozen=True)
class FunctionalTestResult:
name: str
passed: bool
detail: str
def _mean(values: Iterable[float]) -> float:
values = tuple(values)
return float(np.mean(values)) if values else 0.0
def _percentile(values: Iterable[float], value: float) -> float:
values = tuple(values)
return float(np.percentile(values, value)) if values else 0.0
def _packet(
traffic_class: TrafficClass,
direction: Direction,
stream_id: int,
sequence_number: int,
generation_seconds: float,
payload: bytes,
) -> LinkPacket:
packet = LinkPacket(
traffic_class=traffic_class,
direction=direction,
stream_id=stream_id,
sequence_number=sequence_number,
generation_time_us=int(round(generation_seconds * 1_000_000.0)),
deadline_ms=50 if traffic_class is TrafficClass.EMERGENCY else 100,
payload=payload,
)
return decode_link_packet(encode_link_packet(packet))
def _unit(packet: LinkPacket, available_seconds: float, order: int, priority: int, kind: str) -> Unit:
return Unit(
packet,
encode_link_packet(packet),
int(round(available_seconds * 1_000_000.0)),
order,
priority,
kind,
)
def simulate_transport(
workload: Workload,
mode: Mode,
rate_kbps: float,
bad_starts: np.ndarray,
bad_ends: np.ndarray,
) -> TransportTrial:
ready: list[Unit] = []
pending_frames: list[Frame] = []
active_frame: Frame | None = None
active_index = 0
telemetry_index = frame_index = 0
control_sequence = 0
control_next = 0.0
emergency_next: float | None = None
reset_next: float | None = None
new_motion_due: float | None = None
operator_emergency_done = operator_reset_done = new_motion_done = False
order = 80_000_000
cursor = previous_end = 0.0
negative_time_order = 0
maximum_queue = 0
emergency_acknowledged = False
emergency_first: float | None = None
emergency_ack_first: float | None = None
emergency_copies = emergency_lost = emergency_ack_lost = 0
emergency_window_transmitted = emergency_window_lost = 0
emergency_ack_sequence = 0
reset_ack_sequence = 0
reset_first_accept: float | None = None
reset_ack_first: float | None = None
reset_requests_transmitted = reset_ack_lost = 0
new_motion_command_time: float | None = None
positive_after = permitted_after = 0
first_suppressed_time: float | None = None
control_receives: list[tuple[float, int, ControlState]] = []
last_control_receive = 0.0
telemetry_delivered = video_published = 0
block_successes: dict[int, int] = {}
offered = {
"emergency": 0,
"emergency_ack": 0,
"control": 0,
"zero_control": 0,
"reset": 0,
"reset_ack": 0,
"telemetry": 0,
"video": workload.video_generated_bytes,
}
ground = PersistentEmergencyController(1.0) if mode is not Mode.LIMITED_LAB038 else None
rover = TwoStageFailsafe(POLICY, PROFILE)
reset_receiver = SafeResetReceiver()
emergency_ack_receiver = EmergencyAckReceiver()
rejection_counts = {reason.name.lower(): 0 for reason in ResetReason if reason is not ResetReason.ACCEPTED}
emergency_packet = _packet(
TrafficClass.EMERGENCY,
Direction.GROUND_TO_ROVER,
STREAM_EMERGENCY,
EMERGENCY_SEQUENCE,
OPERATOR_EMERGENCY_SECONDS,
b"E-STOP-LAB040-PERSISTENT-INTENT",
)
def remove_ready(kind: str) -> None:
ready[:] = [item for item in ready if item.kind != kind]
def queue_size() -> int:
active_remaining = len(active_frame.packets) - active_index if active_frame is not None else 0
waiting = sum(len(frame.packets) for frame in pending_frames)
return len(ready) + active_remaining + waiting
def add_ready(item: Unit) -> None:
nonlocal maximum_queue
if item.kind == "control":
remove_ready("control")
elif item.kind == "telemetry":
remove_ready("telemetry")
ready.append(item)
maximum_queue = max(maximum_queue, queue_size())
def next_generation_time() -> float | None:
candidates: list[float] = []
if control_sequence < int(DURATION_SECONDS / CONTROL_PERIOD_SECONDS):
candidates.append(control_next)
if telemetry_index < len(workload.telemetry):
candidates.append(workload.telemetry[telemetry_index].available_seconds)
if frame_index < len(workload.frames):
candidates.append(workload.frames[frame_index].generation_seconds)
if not operator_emergency_done:
candidates.append(OPERATOR_EMERGENCY_SECONDS)
if mode is Mode.PERSISTENT_SAFE_RESET and not operator_reset_done:
candidates.append(OPERATOR_RESET_SECONDS)
if emergency_next is not None and emergency_next < DURATION_SECONDS - EPSILON:
candidates.append(emergency_next)
if reset_next is not None and reset_next < DURATION_SECONDS - EPSILON:
candidates.append(reset_next)
if new_motion_due is not None and not new_motion_done and new_motion_due < DURATION_SECONDS - EPSILON:
candidates.append(new_motion_due)
return min(candidates) if candidates else None
def admit_until(limit: float, inclusive: bool) -> None:
nonlocal control_sequence, control_next, telemetry_index, frame_index, order
nonlocal operator_emergency_done, operator_reset_done, new_motion_done
nonlocal emergency_next, reset_next, new_motion_command_time
nonlocal emergency_copies, emergency_window_transmitted, reset_requests_transmitted
nonlocal positive_after, permitted_after, first_suppressed_time, new_motion_due
nonlocal maximum_queue
while True:
event_time = next_generation_time()
if event_time is None:
return
due = event_time <= limit + EPSILON if inclusive else event_time < limit - EPSILON
if not due:
return
if not operator_emergency_done and abs(event_time - OPERATOR_EMERGENCY_SECONDS) <= EPSILON:
operator_emergency_done = True
emergency_next = OPERATOR_EMERGENCY_SECONDS
if ground is not None:
ground.request_emergency(EVENT_ID, STREAM_EMERGENCY, EMERGENCY_SEQUENCE)
if (
mode is Mode.PERSISTENT_SAFE_RESET
and not operator_reset_done
and abs(event_time - OPERATOR_RESET_SECONDS) <= EPSILON
):
operator_reset_done = True
assert ground is not None
ground.request_reset(RESET_REQUEST_ID, RESET_SEQUENCE)
reset_next = OPERATOR_RESET_SECONDS
if new_motion_due is not None and not new_motion_done and abs(event_time - new_motion_due) <= EPSILON:
new_motion_done = True
assert ground is not None
ground.new_operator_motion(1, 1.0)
new_motion_command_time = event_time
if (
control_sequence < int(DURATION_SECONDS / CONTROL_PERIOD_SECONDS)
and abs(event_time - control_next) <= EPSILON
):
sequence = control_sequence
state = ControlState(1.0, 0.0, False, True) if ground is None else ground.control_state()
packet = _packet(
TrafficClass.CONTROL,
Direction.GROUND_TO_ROVER,
STREAM_CONTROL,
sequence,
control_next,
encode_control_state(state),
)
item = _unit(packet, control_next, order, 3, "control")
order += 1
offered["control"] += item.size
if state == SAFE_STATE:
offered["zero_control"] += item.size
if control_next >= OPERATOR_EMERGENCY_SECONDS and first_suppressed_time is None:
first_suppressed_time = control_next
intent_active = (
control_next >= OPERATOR_EMERGENCY_SECONDS
and (
ground is None
or ground.emergency_intent
)
)
if intent_active and state.desired_speed_mps > 0.0:
positive_after += 1
if intent_active and state.movement_allowed:
permitted_after += 1
add_ready(item)
control_sequence += 1
control_next = control_sequence * CONTROL_PERIOD_SECONDS
if emergency_next is not None and abs(event_time - emergency_next) <= EPSILON:
allowed = not emergency_acknowledged and (
mode is not Mode.LIMITED_LAB038
or emergency_next <= OPERATOR_EMERGENCY_SECONDS + 0.500 + EPSILON
)
current_tick = emergency_next
emergency_next += REPEAT_PERIOD_SECONDS
if mode is Mode.LIMITED_LAB038 and emergency_next > OPERATOR_EMERGENCY_SECONDS + 0.500 + EPSILON:
emergency_next = None
if emergency_acknowledged:
emergency_next = None
if allowed:
item = _unit(emergency_packet, current_tick, order, 1, "emergency")
order += 1
emergency_copies += 1
offered["emergency"] += item.size
if current_tick <= OPERATOR_EMERGENCY_SECONDS + 0.500 + EPSILON:
emergency_window_transmitted += 1
add_ready(item)
if reset_next is not None and abs(event_time - reset_next) <= EPSILON:
assert ground is not None
current_tick = reset_next
if ground.state is GroundState.GROUND_RESET_REQUESTED:
command = ResetCommand(EVENT_ID, RESET_REQUEST_ID, 0.0, False)
packet = build_reset_packet(command, RESET_SEQUENCE, int(round(current_tick * 1_000_000.0)))
item = _unit(packet, current_tick, order, 1, "reset")
order += 1
reset_requests_transmitted += 1
offered["reset"] += item.size
add_ready(item)
reset_next += REPEAT_PERIOD_SECONDS
else:
reset_next = None
while (
telemetry_index < len(workload.telemetry)
and abs(workload.telemetry[telemetry_index].available_seconds - event_time) <= EPSILON
):
old = workload.telemetry[telemetry_index]
item = Unit(old.packet, old.wire_packet, old.available_time_us, order, 4, "telemetry")
order += 1
telemetry_index += 1
offered["telemetry"] += item.size
add_ready(item)
while (
frame_index < len(workload.frames)
and abs(workload.frames[frame_index].generation_seconds - event_time) <= EPSILON
):
frame = workload.frames[frame_index]
frame_index += 1
pending_frames[:] = [frame]
maximum_queue = max(maximum_queue, queue_size())
while True:
if not ready and active_frame is None and not pending_frames:
next_time = next_generation_time()
if next_time is None:
break
cursor = max(cursor, next_time)
admit_until(cursor, True)
if ready:
item = min(ready, key=lambda candidate: (candidate.priority, candidate.order))
ready.remove(item)
else:
if active_frame is None and pending_frames:
active_frame = pending_frames.pop(0)
active_index = 0
if active_frame is None:
continue
item = active_frame.packets[active_index]
start = max(cursor, item.available_seconds)
end = start + item.size * 8.0 / (rate_kbps * 1000.0)
negative_time_order += int(start < previous_end - EPSILON)
previous_end = end
lost = packet_is_lost(start, end, bad_starts, bad_ends)
admit_until(end, False)
cursor = end
if item.kind == "control":
if not lost:
state = decode_control_state(item.packet.payload)
if rover.receive_command(item.packet.sequence_number, end):
control_receives.append((end, item.packet.sequence_number, state))
last_control_receive = end
elif item.kind == "emergency":
if lost:
emergency_lost += 1
if item.available_seconds <= OPERATOR_EMERGENCY_SECONDS + 0.500 + EPSILON:
emergency_window_lost += 1
else:
if emergency_first is None:
emergency_first = end
rover.receive_emergency(item.packet.stream_id, item.packet.sequence_number)
ack_packet = build_emergency_ack(item.packet, emergency_ack_sequence, int(round(end * 1_000_000.0)))
emergency_ack_sequence += 1
ack_item = _unit(ack_packet, end, order, 2, "emergency_ack")
order += 1
offered["emergency_ack"] += ack_item.size
add_ready(ack_item)
elif item.kind == "emergency_ack":
if lost:
emergency_ack_lost += 1
else:
identity = acknowledged_identity(item.packet)
if emergency_ack_receiver.accept(item.packet):
emergency_acknowledged = True
emergency_ack_first = end
remove_ready("emergency")
emergency_next = None
if ground is not None:
ground.receive_emergency_ack(identity)
elif item.kind == "reset":
if not lost:
decision = reset_receiver.process(
item.packet,
rover=rover,
actual_speed_mps=0.0,
last_link_age_seconds=max(0.0, end - last_control_receive),
emergency_event_id=EVENT_ID if emergency_first is not None else None,
)
if decision.performed and reset_first_accept is None:
reset_first_accept = end
if not decision.acknowledgement.accepted:
rejection_counts[decision.acknowledgement.reason.name.lower()] += 1
ack_packet = build_reset_ack(decision, reset_ack_sequence, int(round(end * 1_000_000.0)))
reset_ack_sequence += 1
ack_item = _unit(ack_packet, end, order, 2, "reset_ack")
order += 1
offered["reset_ack"] += ack_item.size
add_ready(ack_item)
elif item.kind == "reset_ack":
if lost:
reset_ack_lost += 1
else:
acknowledgement = decode_reset_ack(item.packet.payload)
assert item.packet.stream_id == STREAM_RESET_ACK
assert ground is not None
accepted = ground.receive_reset_ack(
acknowledgement.emergency_event_id,
acknowledgement.reset_request_id,
acknowledgement.accepted,
)
if accepted:
reset_ack_first = end
reset_next = None
remove_ready("reset")
new_motion_due = end + NEW_MOTION_DELAY_SECONDS
elif item.kind == "telemetry":
telemetry_delivered += int(not lost)
elif item.kind == "video":
if not lost and item.block_id is not None:
block_successes[item.block_id] = block_successes.get(item.block_id, 0) + 1
active_index += 1
assert active_frame is not None
if active_index == len(active_frame.packets):
block_requirements = {
unit.block_id: unit.source_count
for unit in active_frame.packets
if unit.block_id is not None
}
if all(block_successes.get(block_id, 0) >= source_count for block_id, source_count in block_requirements.items()):
video_published += 1
active_frame = None
active_index = 0
admit_until(end, True)
maximum_queue = max(maximum_queue, queue_size())
if ground is None:
intent_end = min(DURATION_SECONDS, OPERATOR_EMERGENCY_SECONDS + 0.500)
elif ground.emergency_intent:
intent_end = DURATION_SECONDS
else:
intent_end = reset_ack_first if reset_ack_first is not None else DURATION_SECONDS
intent_fraction = max(0.0, intent_end - OPERATOR_EMERGENCY_SECONDS) / DURATION_SECONDS
suppression = (
max(0.0, first_suppressed_time - OPERATOR_EMERGENCY_SECONDS)
if first_suppressed_time is not None
else DURATION_SECONDS - OPERATOR_EMERGENCY_SECONDS
)
all_window_lost = (
emergency_window_transmitted > 0
and emergency_window_lost == emergency_window_transmitted
and (emergency_first is None or emergency_first > OPERATOR_EMERGENCY_SECONDS + 0.500)
)
return TransportTrial(
tuple(control_receives),
emergency_first is not None,
emergency_first,
emergency_copies,
emergency_lost,
emergency_ack_first,
emergency_ack_lost,
all_window_lost,
intent_fraction,
positive_after,
permitted_after,
suppression,
int(mode is Mode.PERSISTENT_SAFE_RESET),
reset_requests_transmitted,
reset_receiver.accepted,
reset_receiver.rejected,
tuple(sorted(rejection_counts.items())),
reset_first_accept,
reset_ack_first,
reset_ack_lost,
reset_receiver.duplicates,
reset_receiver.reacknowledgements,
new_motion_command_time,
0,
tuple(sorted(offered.items())),
video_published,
telemetry_delivered,
maximum_queue,
negative_time_order,
)
def simulate_motion(trial: TransportTrial, mode: Mode, initial_speed_kmh: float) -> MotionTrial:
initial_speed = initial_speed_kmh / 3.6
controls = tuple(item for item in trial.control_receives if item[0] <= DURATION_SECONDS + EPSILON)
control_index = 0
current = 0.0
speed = target_speed = initial_speed
distance = 0.0
operator_distance = 0.0
fsm = TwoStageFailsafe(POLICY, PROFILE)
emergency_done = reset_done = False
emergency_time = trial.emergency_first_receive_seconds
reset_time = trial.reset_first_accept_seconds
braking_start_time: float | None = None
braking_start_distance: float | None = None
stop_time: float | None = None
stop_distance: float | None = None
resume_time: float | None = None
resume_distance: float | None = None
max_speed_after = initial_speed
reaccelerations = braking_releases = movement_intent = movement_before_ack = 0
recovery_after_lost = unsafe_recovery = 0
negative_speed_cases = target_exceeded_cases = 0
positive_segment_active = False
intent_end = (
OPERATOR_EMERGENCY_SECONDS + 0.500
if mode is Mode.LIMITED_LAB038
else trial.reset_ack_seconds if mode is Mode.PERSISTENT_SAFE_RESET and trial.reset_ack_seconds is not None
else DURATION_SECONDS
)
while current < DURATION_SECONDS - EPSILON:
candidates: list[tuple[float, int, str]] = [(DURATION_SECONDS, 9, "end")]
if control_index < len(controls):
candidates.append((controls[control_index][0], 1, "control"))
if emergency_time is not None and not emergency_done and emergency_time <= DURATION_SECONDS + EPSILON:
candidates.append((emergency_time, 0, "emergency"))
if reset_time is not None and not reset_done and reset_time <= DURATION_SECONDS + EPSILON:
candidates.append((reset_time, 0, "reset"))
if current < OPERATOR_EMERGENCY_SECONDS - EPSILON:
candidates.append((OPERATOR_EMERGENCY_SECONDS, 0, "operator"))
if fsm.state is SafetyState.NORMAL:
assert POLICY.stage1_seconds is not None
first = fsm.last_fresh_time_seconds + POLICY.stage1_seconds
second = fsm.last_fresh_time_seconds + POLICY.stage2_seconds
if first > current + EPSILON:
candidates.append((first, 3, "stage1"))
if second > current + EPSILON:
candidates.append((second, 2, "stage2"))
elif fsm.state is SafetyState.STAGE1_DECELERATION:
second = fsm.last_fresh_time_seconds + POLICY.stage2_seconds
if second > current + EPSILON:
candidates.append((second, 2, "stage2"))
next_time, _, event_type = min(candidates, key=lambda item: (item[0], item[1]))
duration = max(0.0, next_time - current)
acceleration = fsm.acceleration_mps2(speed, target_speed)
if acceleration > 0.0 and current >= OPERATOR_EMERGENCY_SECONDS - EPSILON and braking_start_time is not None:
if not positive_segment_active:
reaccelerations += 1
if resume_time is None:
resume_time = current
resume_distance = distance
if current < intent_end - EPSILON:
movement_intent += 1
if mode is Mode.PERSISTENT_SAFE_RESET and (
trial.reset_ack_seconds is None or current < trial.reset_ack_seconds - EPSILON
):
movement_before_ack += 1
braking_releases += 1
if trial.all_first_500ms_copies_lost:
recovery_after_lost = 1
unsafe_recovery = int(mode is Mode.LIMITED_LAB038)
positive_segment_active = True
else:
positive_segment_active = False
step = integrate_kinematics(
speed,
acceleration,
duration,
target_speed_mps=target_speed if acceleration > 0.0 else None,
)
if current >= OPERATOR_EMERGENCY_SECONDS - EPSILON:
max_speed_after = max(max_speed_after, speed, step.final_speed_mps)
if step.zero_after_seconds is not None and stop_time is None and current >= OPERATOR_EMERGENCY_SECONDS - EPSILON:
stop_time = current + step.zero_after_seconds
stop_distance = distance + step.distance_m
distance += step.distance_m
speed = step.final_speed_mps
current = next_time
negative_speed_cases += int(speed < -EPSILON)
target_exceeded_cases += int(speed > initial_speed + 1e-9)
if event_type == "end":
break
if event_type == "operator":
operator_distance = distance
if fsm.state in (
SafetyState.STAGE1_DECELERATION,
SafetyState.STAGE2_BRAKING,
SafetyState.EMERGENCY_LATCHED,
):
braking_start_time = current
braking_start_distance = distance
continue
if event_type == "emergency":
emergency_done = True
target_speed = 0.0
fsm.receive_emergency(STREAM_EMERGENCY, EMERGENCY_SEQUENCE)
if braking_start_time is None:
braking_start_time = current
braking_start_distance = distance
continue
if event_type == "reset":
reset_done = True
fsm.state = SafetyState.NORMAL
target_speed = 0.0
continue
if event_type == "control":
receive_time, sequence, state = controls[control_index]
control_index += 1
accepted = fsm.receive_command(sequence, receive_time)
if not accepted:
continue
if fsm.state is SafetyState.EMERGENCY_LATCHED:
target_speed = 0.0
elif state.braking or not state.movement_allowed or state.desired_speed_mps <= 0.0:
target_speed = 0.0
fsm.state = SafetyState.STAGE2_BRAKING
if current >= OPERATOR_EMERGENCY_SECONDS - EPSILON and braking_start_time is None:
braking_start_time = current
braking_start_distance = distance
else:
target_speed = initial_speed
fsm.state = SafetyState.NORMAL
continue
if event_type in ("stage1", "stage2"):
fsm.apply_watchdog(current)
if current >= OPERATOR_EMERGENCY_SECONDS - EPSILON and braking_start_time is None:
braking_start_time = current
braking_start_distance = distance
stopped = stop_time is not None and stop_distance is not None
distance_to_braking = (
max(0.0, braking_start_distance - operator_distance)
if braking_start_distance is not None
else 0.0
)
distance_to_stop = max(0.0, stop_distance - operator_distance) if stop_distance is not None else 0.0
time_to_stop = max(0.0, stop_time - OPERATOR_EMERGENCY_SECONDS) if stop_time is not None else 0.0
distance_after_resume = max(0.0, distance - resume_distance) if unsafe_recovery and resume_distance is not None else 0.0
stop_to_resume = (
max(0.0, resume_time - stop_time)
if unsafe_recovery and resume_time is not None and stop_time is not None
else 0.0
)
actual_resume = (
max(0.0, resume_time - OPERATOR_RESET_SECONDS)
if mode is Mode.PERSISTENT_SAFE_RESET and resume_time is not None
else 0.0
)
return MotionTrial(
distance_to_braking,
distance_to_stop,
time_to_stop,
max_speed_after,
stopped,
reaccelerations if mode is not Mode.PERSISTENT_SAFE_RESET else 0,
braking_releases,
movement_intent,
recovery_after_lost,
int(trial.all_first_500ms_copies_lost and stopped and emergency_time is None),
unsafe_recovery,
stop_to_resume,
distance_after_resume,
movement_before_ack,
actual_resume,
negative_speed_cases,
target_exceeded_cases,
)
def aggregate_combination(
workload: Workload,
condition: ChannelCondition,
mode: Mode,
speed_kmh: float,
transports: tuple[TransportTrial, ...],
) -> tuple[SummaryMetrics, EmergencyMetrics, ResetMetrics, MotionMetrics, LoadMetrics]:
motions = tuple(simulate_motion(trial, mode, speed_kmh) for trial in transports)
mode_name = MODE_NAMES[mode]
latch_delays = tuple(
(trial.emergency_first_receive_seconds - OPERATOR_EMERGENCY_SECONDS) * 1000.0
for trial in transports
if trial.emergency_first_receive_seconds is not None
)
emergency_ack_delays = tuple(
(trial.emergency_ack_seconds - OPERATOR_EMERGENCY_SECONDS) * 1000.0
for trial in transports
if trial.emergency_ack_seconds is not None
)
reset_accept_delays = tuple(
(trial.reset_first_accept_seconds - OPERATOR_RESET_SECONDS) * 1000.0
for trial in transports
if trial.reset_first_accept_seconds is not None
)
reset_ack_delays = tuple(
(trial.reset_ack_seconds - OPERATOR_RESET_SECONDS) * 1000.0
for trial in transports
if trial.reset_ack_seconds is not None
)
ack_to_motion = tuple(
trial.new_motion_command_seconds - trial.reset_ack_seconds
for trial in transports
if trial.new_motion_command_seconds is not None and trial.reset_ack_seconds is not None
)
reason_totals = {reason.name.lower(): 0 for reason in ResetReason if reason is not ResetReason.ACCEPTED}
for trial in transports:
for reason, count in trial.reset_rejection_reasons:
reason_totals[reason] += count
mean_bytes = {
kind: _mean(dict(trial.offered_bytes)[kind] for trial in transports)
for kind in dict(transports[0].offered_bytes)
}
loads = {kind: value * 8.0 / DURATION_SECONDS / 1000.0 for kind, value in mean_bytes.items()}
extra_load = loads["emergency"] + loads["emergency_ack"] + loads["reset"] + loads["reset_ack"]
video_fraction = _mean(trial.video_published for trial in transports) / len(workload.frames)
telemetry_fraction = _mean(trial.telemetry_delivered for trial in transports) / len(workload.telemetry)
summary = SummaryMetrics(
condition.name,
mode_name,
speed_kmh,
len(transports),
_mean(float(trial.emergency_delivered) for trial in transports),
_mean(trial.emergency_intent_fraction for trial in transports),
_mean(motion.unsafe_recovery for motion in motions),
_mean(float(motion.stopped) for motion in motions),
sum(motion.movement_while_intent for motion in motions),
sum(motion.movement_before_reset_ack for motion in motions),
_mean(trial.positive_commands_after_emergency for trial in transports),
_mean(trial.permitted_commands_after_emergency for trial in transports),
extra_load,
video_fraction,
telemetry_fraction,
max(trial.max_queue_packets for trial in transports),
)
emergency = EmergencyMetrics(
condition.name,
mode_name,
speed_kmh,
len(transports),
summary.emergency_delivery_fraction,
_mean(latch_delays),
_percentile(latch_delays, 95),
max(latch_delays, default=0.0),
_mean(trial.emergency_copies for trial in transports),
_mean(trial.emergency_copies_lost for trial in transports),
_mean(emergency_ack_delays),
_mean(trial.emergency_acks_lost for trial in transports),
summary.emergency_intent_time_fraction,
summary.positive_commands_after_emergency_mean,
summary.permitted_commands_after_emergency_mean,
_mean(trial.time_to_motion_command_suppression_seconds for trial in transports) * 1000.0,
sum(trial.all_first_500ms_copies_lost for trial in transports),
)
reset = ResetMetrics(
condition.name,
mode_name,
speed_kmh,
len(transports),
_mean(trial.reset_requests_created for trial in transports),
_mean(trial.reset_requests_transmitted for trial in transports),
_mean(trial.reset_requests_accepted for trial in transports),
_mean(trial.reset_requests_rejected for trial in transports),
reason_totals["not_latched"],
reason_totals["moving"],
reason_totals["stale_link"],
reason_totals["event_mismatch"],
reason_totals["old_sequence"],
reason_totals["nonzero_request"],
reason_totals["movement_permitted"],
_mean(reset_accept_delays),
_mean(reset_ack_delays),
_percentile(reset_ack_delays, 95),
_mean(trial.reset_acks_lost for trial in transports),
_mean(max(0, trial.reset_requests_transmitted - 1) for trial in transports),
_mean(trial.reset_duplicates for trial in transports),
_mean(trial.reset_reacks for trial in transports),
_mean(ack_to_motion),
_mean(motion.actual_resume_seconds for motion in motions),
sum(motion.movement_before_reset_ack for motion in motions),
sum(trial.automatic_old_command_restorations for trial in transports),
)
distance_braking = tuple(motion.distance_to_braking_m for motion in motions)
distance_stop = tuple(motion.distance_to_stop_m for motion in motions if motion.stopped)
time_stop = tuple(motion.time_to_stop_seconds for motion in motions if motion.stopped)
motion = MotionMetrics(
condition.name,
mode_name,
speed_kmh,
len(transports),
_mean(distance_braking),
_percentile(distance_braking, 95),
max(distance_braking, default=0.0),
_mean(distance_stop),
_percentile(distance_stop, 95),
max(distance_stop, default=0.0),
_mean(time_stop),
_percentile(time_stop, 95),
max(item.maximum_speed_after_emergency_mps for item in motions),
summary.full_stop_fraction,
sum(item.reaccelerations_before_reset for item in motions),
sum(item.braking_releases_before_reset_ack for item in motions),
sum(item.movement_while_intent for item in motions),
sum(item.recovery_after_lost_emergency for item in motions),
sum(item.watchdog_stopped_after_lost_emergency for item in motions),
sum(item.unsafe_recovery for item in motions),
_mean(item.stop_to_resume_seconds for item in motions if item.unsafe_recovery),
_mean(item.distance_after_unsafe_resume_m for item in motions if item.unsafe_recovery),
sum(item.negative_speed_cases for item in motions),
sum(item.target_speed_exceeded_cases for item in motions),
)
load = LoadMetrics(
condition.name,
mode_name,
speed_kmh,
len(transports),
loads["emergency"],
loads["emergency_ack"],
loads["zero_control"],
loads["reset"],
loads["reset_ack"],
extra_load,
video_fraction,
telemetry_fraction,
summary.maximum_queue_packets,
)
return summary, emergency, reset, motion, load
def run_experiment(
workload: Workload,
) -> tuple[
tuple[SummaryMetrics, ...],
tuple[EmergencyMetrics, ...],
tuple[ResetMetrics, ...],
tuple[MotionMetrics, ...],
tuple[LoadMetrics, ...],
dict[tuple[str, str], tuple[TransportTrial, ...]],
]:
transport_sets: dict[tuple[str, str], tuple[TransportTrial, ...]] = {}
for condition_index, condition in enumerate(CHANNELS):
for mode in Mode:
rows = []
for repetition in range(condition.repetitions):
if condition.mean_bad_ms is None:
starts = ends = np.asarray([], dtype=float)
else:
seed = SEED_OFFSET + condition_index * 100_000 + repetition
starts, ends = bad_intervals(DURATION_SECONDS + 2.0, condition.mean_bad_ms, seed)
rows.append(simulate_transport(workload, mode, condition.rate_kbps, starts, ends))
transport_sets[(condition.name, MODE_NAMES[mode])] = tuple(rows)
print(f"transport={condition.name}/{MODE_NAMES[mode]} repetitions={condition.repetitions}")
summaries: list[SummaryMetrics] = []
emergency_rows: list[EmergencyMetrics] = []
reset_rows: list[ResetMetrics] = []
motion_rows: list[MotionMetrics] = []
load_rows: list[LoadMetrics] = []
for condition in CHANNELS:
for mode in Mode:
transports = transport_sets[(condition.name, MODE_NAMES[mode])]
for speed in SPEEDS_KMH:
aggregate = aggregate_combination(workload, condition, mode, speed, transports)
summaries.append(aggregate[0])
emergency_rows.append(aggregate[1])
reset_rows.append(aggregate[2])
motion_rows.append(aggregate[3])
load_rows.append(aggregate[4])
print(f"aggregate={condition.name} combinations=9")
return (
tuple(summaries),
tuple(emergency_rows),
tuple(reset_rows),
tuple(motion_rows),
tuple(load_rows),
transport_sets,
)
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}"))
def ground_controller() -> PersistentEmergencyController:
controller = PersistentEmergencyController(2.0)
controller.request_emergency(EVENT_ID, STREAM_EMERGENCY, EMERGENCY_SEQUENCE)
return controller
def latched_rover() -> TwoStageFailsafe:
rover = TwoStageFailsafe(POLICY, PROFILE)
rover.receive_emergency(STREAM_EMERGENCY, EMERGENCY_SEQUENCE)
return rover
def reset_packet(
*,
event_id: int = EVENT_ID,
request_id: int = RESET_REQUEST_ID,
sequence: int = RESET_SEQUENCE,
speed: float = 0.0,
permitted: bool = False,
) -> LinkPacket:
return build_reset_packet(
ResetCommand(event_id, request_id, speed, permitted),
sequence,
int(OPERATOR_RESET_SECONDS * 1_000_000),
)
def emergency_intent_latches() -> str:
controller = ground_controller()
assert controller.emergency_intent and controller.state is GroundState.GROUND_EMERGENCY_REQUESTED
return "действие оператора фиксирует аварийное намерение наземной станции"
def no_positive_after_intent() -> str:
state = ground_controller().control_state()
assert state.desired_speed_mps == 0.0 and state.braking
return "аварийное намерение заменяет движение безопасным нулевым состоянием с торможением"
def movement_false_until_reset() -> str:
controller = ground_controller()
assert not controller.control_state().movement_allowed
controller.receive_emergency_ack(EmergencyIdentity(STREAM_EMERGENCY, EMERGENCY_SEQUENCE))
assert not controller.control_state().movement_allowed
return "после подтверждения аварийной команды движение остаётся запрещённым"
def emergency_loss_keeps_intent() -> str:
controller = ground_controller()
starts = np.asarray([OPERATOR_EMERGENCY_SECONDS - 0.001])
ends = np.asarray([OPERATOR_EMERGENCY_SECONDS + 0.510])
size = len(encode_link_packet(_packet(TrafficClass.EMERGENCY, Direction.GROUND_TO_ROVER, STREAM_EMERGENCY, 1, 30.0, b"x")))
duration = size * 8.0 / 300_000.0
assert all(
packet_is_lost(30.0 + offset, 30.0 + offset + duration, starts, ends)
for offset in np.arange(0.0, 0.501, 0.050)
)
assert controller.emergency_intent
return "управляемая помеха теряет все копии за первые 500 мс, не снимая аварийное намерение"
def emergency_ack_keeps_intent() -> str:
controller = ground_controller()
rover = latched_rover()
command = _packet(TrafficClass.EMERGENCY, Direction.GROUND_TO_ROVER, STREAM_EMERGENCY, 1, 30.0, b"x")
ack = build_emergency_ack(command, 0, 30_001_000)
starts = np.asarray([30.001])
ends = np.asarray([30.010])
assert packet_is_lost(30.001, 30.003, starts, ends)
assert controller.emergency_intent and rover.state is SafetyState.EMERGENCY_LATCHED
controller.receive_emergency_ack(acknowledged_identity(ack))
assert controller.emergency_intent and controller.state is GroundState.GROUND_EMERGENCY_CONFIRMED
return "потеря первого подтверждения и доставка повтора сохраняют аварийное намерение"
def first_emergency_latches_rover() -> str:
rover = latched_rover()
assert rover.state is SafetyState.EMERGENCY_LATCHED and rover.emergency_actions == 1
return "первая аварийная команда фиксирует аварийное состояние ровера"
def ordinary_does_not_unlatch() -> str:
rover = latched_rover()
rover.receive_command(1, 0.1)
assert rover.state is SafetyState.EMERGENCY_LATCHED
return "обычная команда не снимает аварийное состояние ровера"
def late_old_command_rejected() -> str:
rover = latched_rover()
rover.receive_command(10, 0.1)
assert not rover.receive_command(9, 0.2)
assert rover.state is SafetyState.EMERGENCY_LATCHED
return "запоздалая команда движения со старым номером последовательности отклоняется"
def reject_moving() -> str:
receiver = SafeResetReceiver()
decision = receiver.process(reset_packet(), rover=latched_rover(), actual_speed_mps=0.1, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert decision.acknowledgement.reason is ResetReason.MOVING
return "движущийся ровер отклоняет запрос сброса"
def reject_stale_link() -> str:
receiver = SafeResetReceiver()
decision = receiver.process(reset_packet(), rover=latched_rover(), actual_speed_mps=0.0, last_link_age_seconds=0.251, emergency_event_id=EVENT_ID)
assert decision.acknowledgement.reason is ResetReason.STALE_LINK
return "при устаревшем состоянии связи запрос сброса отклоняется"
def reject_wrong_event() -> str:
receiver = SafeResetReceiver()
decision = receiver.process(reset_packet(event_id=2), rover=latched_rover(), actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert decision.acknowledgement.reason is ResetReason.EVENT_MISMATCH
return "запрос сброса с неверным идентификатором аварии отклоняется"
def reject_old_sequence() -> str:
receiver = SafeResetReceiver()
receiver.process(reset_packet(sequence=2), rover=latched_rover(), actual_speed_mps=1.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
decision = receiver.process(reset_packet(sequence=1), rover=latched_rover(), actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert decision.acknowledgement.reason is ResetReason.OLD_SEQUENCE
return "запрос сброса со старым номером последовательности отклоняется"
def reject_nonzero_speed_request() -> str:
receiver = SafeResetReceiver()
decision = receiver.process(reset_packet(speed=0.1), rover=latched_rover(), actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert decision.acknowledgement.reason is ResetReason.NONZERO_REQUEST
permitted = SafeResetReceiver().process(
reset_packet(permitted=True),
rover=latched_rover(),
actual_speed_mps=0.0,
last_link_age_seconds=0.0,
emergency_event_id=EVENT_ID,
)
assert permitted.acknowledgement.reason is ResetReason.MOVEMENT_PERMITTED
return "ненулевая скорость или разрешение движения приводят к отклонению сброса"
def successful_reset_is_zero() -> str:
rover = latched_rover()
receiver = SafeResetReceiver()
decision = receiver.process(reset_packet(), rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert decision.performed and rover.state is SafetyState.NORMAL
assert decode_reset_ack(build_reset_ack(decision, 0, 60_001_000).payload).accepted
return "принятый сброс выводит из аварийного состояния только в состояние нулевой скорости"
def no_movement_before_reset_ack() -> str:
controller = ground_controller()
controller.request_reset(RESET_REQUEST_ID, RESET_SEQUENCE)
assert controller.state is GroundState.GROUND_RESET_REQUESTED
assert controller.control_state() == SAFE_STATE
return "до подтверждения сброса наземная станция сохраняет безопасное нулевое задание"
def lost_reset_ack_repeats() -> str:
rover = latched_rover()
receiver = SafeResetReceiver()
packet = reset_packet()
first = receiver.process(packet, rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
starts = np.asarray([60.001])
ends = np.asarray([60.010])
assert packet_is_lost(60.001, 60.003, starts, ends)
repeated = receiver.process(packet, rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert first.performed and repeated.duplicate and repeated.acknowledgement.accepted
return "управляемая потеря первого подтверждения сброса вызывает повтор запроса"
def duplicate_does_not_reset_twice() -> str:
rover = latched_rover()
receiver = SafeResetReceiver()
packet = reset_packet()
receiver.process(packet, rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
duplicate = receiver.process(packet, rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert not duplicate.performed and receiver.accepted == 1
return "принятый дубликат не выполняет сброс повторно"
def duplicate_reacks() -> str:
rover = latched_rover()
receiver = SafeResetReceiver()
packet = reset_packet()
receiver.process(packet, rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
duplicate = receiver.process(packet, rover=rover, actual_speed_mps=0.0, last_link_age_seconds=0.0, emergency_event_id=EVENT_ID)
assert duplicate.acknowledgement.accepted and receiver.reacknowledgements == 1
return "принятый дубликат вызывает повторное подтверждение сброса"
def no_automatic_old_motion() -> str:
controller = ground_controller()
controller.request_reset(RESET_REQUEST_ID, RESET_SEQUENCE)
controller.receive_reset_ack(EVENT_ID, RESET_REQUEST_ID, True)
assert controller.control_state() == SAFE_STATE
assert controller.reject_old_operator_motion(0)
return "подтверждение сброса не восстанавливает сохранённую команду движения"
def new_operator_command_required() -> str:
controller = ground_controller()
controller.request_reset(RESET_REQUEST_ID, RESET_SEQUENCE)
controller.receive_reset_ack(EVENT_ID, RESET_REQUEST_ID, True)
try:
controller.new_operator_motion(0, 1.0)
raise AssertionError("принят старый номер последовательности команды оператора")
except ValueError:
pass
state = controller.new_operator_motion(1, 1.0)
assert state.movement_allowed and state.desired_speed_mps > 0.0
return "для движения требуется новый номер последовательности команды оператора"
def emergency_latch_against_ordinary() -> str:
rover = latched_rover()
rover.receive_command(100, 1.0)
assert rover.state is SafetyState.EMERGENCY_LATCHED
return "состояние EMERGENCY_LATCHED не снимается обычной командой управления"
def watchdog_without_emergency() -> str:
rover = TwoStageFailsafe(POLICY, PROFILE)
assert rover.apply_watchdog(0.150) is SafetyState.STAGE1_DECELERATION
assert rover.apply_watchdog(0.250) is SafetyState.STAGE2_BRAKING
return "локальный двухступенчатый сторожевой таймер работает без доставки аварийной команды"
def reproducible() -> str:
left = bad_intervals(122.0, 1000.0, SEED_OFFSET + 123)
right = bad_intervals(122.0, 1000.0, SEED_OFFSET + 123)
assert np.array_equal(left[0], right[0]) and np.array_equal(left[1], right[1])
return "фиксированное начальное значение генератора воспроизводит интервалы помех"
def operator_during_braking_has_zero_reaction_distance() -> str:
trial = TransportTrial(
control_receives=((29.800, 1, ControlState(1.0, 0.0, False, True)),),
emergency_delivered=False,
emergency_first_receive_seconds=None,
emergency_copies=0,
emergency_copies_lost=0,
emergency_ack_seconds=None,
emergency_acks_lost=0,
all_first_500ms_copies_lost=False,
emergency_intent_fraction=0.0,
positive_commands_after_emergency=0,
permitted_commands_after_emergency=0,
time_to_motion_command_suppression_seconds=0.0,
reset_requests_created=0,
reset_requests_transmitted=0,
reset_requests_accepted=0,
reset_requests_rejected=0,
reset_rejection_reasons=(),
reset_first_accept_seconds=None,
reset_ack_seconds=None,
reset_acks_lost=0,
reset_duplicates=0,
reset_reacks=0,
new_motion_command_seconds=None,
automatic_old_command_restorations=0,
offered_bytes=(),
video_published=0,
telemetry_delivered=0,
max_queue_packets=0,
negative_time_order=0,
)
motion = simulate_motion(trial, Mode.LIMITED_LAB038, 25.0)
assert motion.distance_to_braking_m == 0.0
return "действие оператора при уже активном торможении даёт нулевой путь до начала реакции"
checks = (
("01. Оператор фиксирует аварийное намерение", emergency_intent_latches),
("02. После аварии нет положительной заданной скорости", no_positive_after_intent),
("03. Движение запрещено до сброса", movement_false_until_reset),
("04. Потеря аварийной команды сохраняет намерение", emergency_loss_keeps_intent),
("05. Подтверждение аварии сохраняет намерение", emergency_ack_keeps_intent),
("06. Первая аварийная команда фиксирует состояние ровера", first_emergency_latches_rover),
("07. Обычная команда не снимает аварийное состояние", ordinary_does_not_unlatch),
("08. Старая команда движения отклоняется", late_old_command_rejected),
("09. Сброс при движении отклоняется", reject_moving),
("10. Сброс при устаревшей связи отклоняется", reject_stale_link),
("11. Сброс для неверного события отклоняется", reject_wrong_event),
("12. Сброс со старым номером последовательности отклоняется", reject_old_sequence),
("13. Сброс с ненулевой скоростью отклоняется", reject_nonzero_speed_request),
("14. После успешного сброса скорость остаётся нулевой", successful_reset_is_zero),
("15. До подтверждения сброса движение отсутствует", no_movement_before_reset_ack),
("16. Потеря подтверждения сброса вызывает повтор", lost_reset_ack_repeats),
("17. Дубликат не выполняет сброс дважды", duplicate_does_not_reset_twice),
("18. На дубликат повторно отправляется подтверждение", duplicate_reacks),
("19. Старая команда движения не восстанавливается", no_automatic_old_motion),
("20. Для движения требуется новая команда оператора", new_operator_command_required),
("21. Аварийное состояние устойчиво к обычным командам", emergency_latch_against_ordinary),
("22. Сторожевой таймер работает при потере аварийной команды", watchdog_without_emergency),
("23. Интервалы помех воспроизводимы", reproducible),
("24. Действие оператора во время торможения", operator_during_braking_has_zero_reaction_distance),
)
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 file:
writer = csv.DictWriter(file, fieldnames=tuple(row_type.__dataclass_fields__))
writer.writeheader()
writer.writerows(asdict(row) for row in rows)
def save_plots(
summaries: tuple[SummaryMetrics, ...],
emergencies: tuple[EmergencyMetrics, ...],
resets: tuple[ResetMetrics, ...],
motions: tuple[MotionMetrics, ...],
loads: tuple[LoadMetrics, ...],
) -> None:
colors = {
"limited_lab038": "#d95f02",
"persistent_no_reset": "#2878b5",
"persistent_safe_reset": "#2a9d55",
}
channel_names = [condition.name for condition in CHANNELS]
channel_labels = [condition.label for condition in CHANNELS]
x = np.arange(len(CHANNELS))
figure, axis = plt.subplots(figsize=(10, 5))
for index, mode in enumerate(MODE_NAMES.values()):
rows = [row for row in emergencies if row.mode == mode and row.initial_speed_kmh == 5.0]
axis.bar(x + (index - 1) * 0.24, [row.delivered_fraction * 100.0 for row in rows], 0.24, label=MODE_LABELS[mode], color=colors[mode])
axis.set_xticks(x, channel_labels, rotation=15)
axis.set_ylabel("Доставка аварийной команды, %")
axis.set_title("Итоговая доставка аварийного намерения")
axis.set_ylim(90, 101)
axis.grid(True, axis="y", alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[0], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 5))
for speed in SPEEDS_KMH:
rows = [row for row in motions if row.mode == "limited_lab038" and row.initial_speed_kmh == speed]
axis.plot(channel_labels, [row.unsafe_recoveries / row.repetitions * 100.0 for row in rows], marker="o", label=f"{speed:.0f} км/ч")
axis.set_ylabel("Небезопасные восстановления, %")
axis.set_title("Риск ограниченного представления аварийного намерения")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend(title="Начальная скорость")
figure.tight_layout()
figure.savefig(PLOT_PATHS[1], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 5))
for mode in MODE_NAMES.values():
rows = [row for row in emergencies if row.mode == mode and row.initial_speed_kmh == 25.0]
axis.plot(channel_labels, [row.p95_latch_delay_ms for row in rows], marker="o", label=MODE_LABELS[mode], color=colors[mode])
axis.set_ylabel("95-й процентиль времени фиксации, мс")
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=(9, 5))
for mode in MODE_NAMES.values():
rows = [row for row in motions if row.mode == mode and row.initial_speed_kmh == 25.0]
axis.plot(channel_labels, [row.max_distance_to_stop_m for row in rows], marker="o", label=MODE_LABELS[mode], color=colors[mode])
axis.set_ylabel("Максимальный путь до остановки, м")
axis.set_title("Движение после действия оператора при 25 км/ч")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[3], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(9, 5))
rows = [row for row in resets if row.mode == "persistent_safe_reset" and row.initial_speed_kmh == 25.0]
axis.bar(channel_labels, [row.p95_reset_ack_delay_ms for row in rows], color="#2a9d55")
axis.set_ylabel("95-й процентиль времени подтверждения, мс")
axis.set_title("Безопасное подтверждение сброса")
axis.tick_params(axis="x", rotation=15)
axis.grid(True, axis="y", alpha=0.3)
figure.tight_layout()
figure.savefig(PLOT_PATHS[4], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(10, 5))
for index, mode in enumerate(MODE_NAMES.values()):
rows = [row for row in loads if row.mode == mode and row.initial_speed_kmh == 5.0]
axis.bar(x + (index - 1) * 0.24, [row.total_extra_load_kbps for row in rows], 0.24, label=MODE_LABELS[mode], color=colors[mode])
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[5], dpi=150)
plt.close(figure)
figure, axes = plt.subplots(1, 2, figsize=(12, 5))
for mode in MODE_NAMES.values():
rows = [row for row in loads if row.mode == mode and row.initial_speed_kmh == 5.0]
axes[0].plot(channel_labels, [row.video_published_fraction * 100.0 for row in rows], marker="o", label=MODE_LABELS[mode], color=colors[mode])
axes[1].plot(channel_labels, [row.telemetry_delivered_fraction * 100.0 for row in rows], marker="o", label=MODE_LABELS[mode], color=colors[mode])
axes[0].set_title("Опубликованные видеокадры")
axes[0].set_ylabel("Доля, %")
axes[1].set_title("Доставленная телеметрия")
axes[1].set_ylabel("Доля, %")
for axis in axes:
axis.tick_params(axis="x", rotation=20)
axis.grid(True, alpha=0.3)
axes[1].legend(fontsize=8)
figure.tight_layout()
figure.savefig(PLOT_PATHS[6], dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(8, 5))
for mode in MODE_NAMES.values():
rows = [row for row in summaries if row.mode == mode]
axis.scatter(
_mean(row.total_extra_load_kbps for row in rows),
_mean(row.unsafe_recovery_fraction for row in rows) * 100.0,
s=100,
color=colors[mode],
label=MODE_LABELS[mode],
)
axis.set_xlabel("Средняя дополнительная нагрузка, кбит/с")
axis.set_ylabel("Небезопасные восстановления, %")
axis.set_title("Компромисс нагрузки и сохранения аварийного намерения")
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(PLOT_PATHS[7], dpi=150)
plt.close(figure)
def write_report(
workload: Workload,
summaries: tuple[SummaryMetrics, ...],
emergencies: tuple[EmergencyMetrics, ...],
resets: tuple[ResetMetrics, ...],
motions: tuple[MotionMetrics, ...],
loads: tuple[LoadMetrics, ...],
tests: tuple[FunctionalTestResult, ...],
) -> None:
channel_labels = {condition.name: condition.label for condition in CHANNELS}
lines = [
"Lab040 — постоянное аварийное намерение и безопасное повторное разрешение движения",
"",
"Состояние Git до начала",
"- Корень: C:/Users/user/Desktop/projects/SDR_Rover",
"- Ветка: main",
"- HEAD: b3ebe1637fbb401198cdbb43a862e94614be9cb0",
"- Рабочее дерево и индекс были чистыми; относительно локально известной origin/main расхождение составляло 0/0.",
"- Коммиты Lab038 и Lab039 присутствовали; сетевые команды Git не выполнялись.",
"",
"Автоматы наземной станции и ровера",
"- Наземная станция: GROUND_NORMAL → GROUND_EMERGENCY_REQUESTED → GROUND_EMERGENCY_CONFIRMED → GROUND_RESET_REQUESTED → GROUND_MOVEMENT_REAUTHORIZED.",
"- Подтверждение аварийной команды прекращает её копии, но не снимает аварийное намерение и не разрешает положительные команды движения.",
"- Ровер сохраняет состояния NORMAL, STAGE1_DECELERATION, STAGE2_BRAKING и EMERGENCY_LATCHED из Lab039.",
"- Обычная или поздняя старая команда не снимает EMERGENCY_LATCHED.",
"",
"Безопасный сброс",
"- Сброс использует отдельные идентификаторы потоков и прикладные данные внутри неизменного пакета LinkPacket из Lab033.",
"- Проверяются зафиксированное аварийное состояние, нулевая фактическая скорость, свежесть связи 250 мс, идентификатор события, номер последовательности, нулевая требуемая скорость и запрет движения.",
"- Потерянное подтверждение сброса вызывает повтор запроса; принятый дубликат не повторяет действие, но вызывает новое подтверждение.",
"- После подтверждения сброса сохраняется нулевая скорость; новая команда оператора формируется только через 2 секунды и имеет новый номер последовательности.",
"",
"Параметры",
f"- Опыт {DURATION_SECONDS:.0f} с; аварийная команда на 30-й секунде; сброс на 60-й секунде; команды 20 Гц; повторы каждые 50 мс.",
f"- Матрица: 5 каналов × 3 режима × 3 скорости = {len(summaries)} сочетаний; условия с потерями по {REPETITIONS} повторов.",
"- Сторожевой таймер 150/250 мс; замедление первой ступени 1,0 м/с², второй ступени 3,0 м/с², ускорение восстановления 1,0 м/с².",
f"- Исходная нагрузка проверена контрольной суммой и помехоустойчивым кодированием: {workload.crc_packets_checked}/{workload.fec_blocks_checked}.",
"",
"Компактная таблица 45 сочетаний",
"канал | режим | скорость | доставка аварийной команды, % | время аварийного намерения, % | небезопасное восстановление, % | остановка, % | нагрузка, кбит/с | видео, % | телеметрия, % | очередь",
]
for row in summaries:
lines.append(
f"{channel_labels[row.channel_condition]} | {MODE_LABELS[row.mode]} | {row.initial_speed_kmh:.0f} | "
f"{row.emergency_delivery_fraction * 100.0:.3f} | {row.emergency_intent_time_fraction * 100.0:.3f} | "
f"{row.unsafe_recovery_fraction * 100.0:.3f} | {row.full_stop_fraction * 100.0:.3f} | "
f"{row.total_extra_load_kbps:.5f} | {row.video_published_fraction * 100.0:.3f} | "
f"{row.telemetry_delivered_fraction * 100.0:.3f} | {row.maximum_queue_packets}"
)
baseline = [row for row in motions if row.mode == "limited_lab038"]
persistent = [row for row in summaries if row.mode in ("persistent_no_reset", "persistent_safe_reset")]
reset_only = [row for row in resets if row.mode == "persistent_safe_reset"]
corrected_motion = [row for row in motions if row.channel_condition == "230_1000"]
lines.extend(
[
"",
"Отрицательный исходный режим",
f"- Полностью потерянные первые 500 мс аварийной передачи: {max(row.all_first_500ms_lost for row in emergencies if row.mode == 'limited_lab038')} на одно сочетание.",
f"- Остановки по сторожевому таймеру после такой потери: {sum(row.watchdog_stops_after_lost_emergency for row in baseline)}.",
f"- Небезопасные восстановления движения: {sum(row.unsafe_recoveries for row in baseline)}; максимальная доля {max(row.unsafe_recoveries / row.repetitions for row in baseline) * 100.0:.3f}%.",
f"- Среднее время от остановки до повторного движения: {max(row.mean_stop_to_resume_seconds for row in baseline):.4f} с; путь после восстановления до {max(row.mean_distance_after_unsafe_resume_m for row in baseline):.4f} м.",
"- Ограниченный режим Lab038 служит отрицательным исходным уровнем и не принимается для рабочей архитектуры.",
"",
"Постоянное аварийное намерение",
"- Итоговая доставка 100% в режимах постоянного намерения получена в конечной выборке и не является математической гарантией.",
"- Безопасность не зависит от гарантированной доставки удалённой команды: локальный сторожевой таймер продолжает действовать.",
"- Подтверждение аварийной команды (Emergency ACK) прекращает копии, но не снимает аварийное намерение emergency_intent.",
f"- Положительные задания при активном аварийном намерении: {sum(row.positive_commands_after_emergency_mean for row in persistent):.0f}.",
f"- Команды с разрешением движения при активном аварийном намерении: {sum(row.permitted_commands_after_emergency_mean for row in persistent):.0f}.",
f"- Небезопасные восстановления в режимах 2 и 3: {sum(row.unsafe_recovery_fraction * row.repetitions for row in persistent):.0f}.",
"",
"Подтверждение сброса",
f"- Принятые запросы сброса: {_mean(row.requests_accepted_mean for row in reset_only):.4f} на опыт; отклонённые: {_mean(row.requests_rejected_mean for row in reset_only):.4f}.",
f"- Причины отказов: аварийное состояние не зафиксировано={sum(row.rejected_not_latched for row in reset_only)}, движение={sum(row.rejected_moving for row in reset_only)}, устаревшая связь={sum(row.rejected_stale_link for row in reset_only)}, неверное событие={sum(row.rejected_event_mismatch for row in reset_only)}, старый номер последовательности={sum(row.rejected_old_sequence for row in reset_only)}, ненулевая скорость={sum(row.rejected_nonzero_request for row in reset_only)}, разрешение движения={sum(row.rejected_movement_permitted for row in reset_only)}.",
f"- Среднее число повторов сброса: {_mean(row.repeated_requests_mean for row in reset_only):.4f}; потерянных подтверждений сброса: {_mean(row.reset_acks_lost_mean for row in reset_only):.4f}.",
f"- 95-й процентиль времени от запроса оператора до подтверждения сброса: до {max(row.p95_reset_ack_delay_ms for row in reset_only):.3f} мс.",
f"- Движение до подтверждения сброса: {sum(row.movement_before_reset_ack_cases for row in reset_only)}; автоматическое восстановление старой команды: {sum(row.automatic_old_command_restorations for row in reset_only)}.",
"- Подтверждение сброса (RESET_ACK) разрешает перейти только в безопасное состояние с нулевой скоростью.",
"- Для движения после сброса требуется новая команда оператора.",
"",
"Точечная проверка пути до начала безопасной реакции",
"- Канал: 230 кбит/с, средняя помеха 1000 мс; транспортный эксперимент повторно не запускался.",
"- Если в момент действия оператора уже активно состояние STAGE1_DECELERATION, STAGE2_BRAKING или EMERGENCY_LATCHED, путь до начала реакции равен нулю и не измеряется до будущего перехода в торможение.",
"- режим | скорость, км/ч | среднее, м | 95-й процентиль, м | максимум, м | полные остановки, % | небезопасные возобновления",
]
)
for row in corrected_motion:
summary_row = next(
item
for item in summaries
if item.channel_condition == row.channel_condition
and item.mode == row.mode
and item.initial_speed_kmh == row.initial_speed_kmh
)
lines.append(
f"- {MODE_LABELS[row.mode]} | {row.initial_speed_kmh:.0f} | "
f"{row.mean_distance_to_braking_m:.6f} | {row.p95_distance_to_braking_m:.6f} | "
f"{row.max_distance_to_braking_m:.6f} | {summary_row.full_stop_fraction * 100.0:.3f} | "
f"{row.unsafe_recoveries}"
)
lines.extend(
[
"- Сводная и двигательная таблицы согласованы по числу повторов, доле полных остановок и небезопасным возобновлениям.",
"- Ни один график Lab040 напрямую не использует путь до начала реакции; график пути до остановки использует отдельный показатель полного пути и не требует изменения.",
"",
"Движение и нагрузка",
f"- Максимальный путь до полной остановки: {max(row.max_distance_to_stop_m for row in motions):.4f} м; максимальное время: {max(row.p95_time_to_stop_seconds for row in motions):.4f} с.",
f"- Дополнительная нагрузка защиты: {_mean(row.total_extra_load_kbps for row in loads):.5f}{max(row.total_extra_load_kbps for row in loads):.5f} кбит/с.",
f"- Нагрузка нулевых состояний: до {max(row.zero_control_load_kbps for row in loads):.5f} кбит/с; максимальная очередь {max(row.maximum_queue_packets for row in loads)} пакетов.",
f"- Видео опубликовано: {min(row.video_published_fraction for row in loads) * 100.0:.3f}{max(row.video_published_fraction for row in loads) * 100.0:.3f}%; телеметрия: {min(row.telemetry_delivered_fraction for row in loads) * 100.0:.3f}{max(row.telemetry_delivered_fraction for row in loads) * 100.0:.3f}%.",
"",
"Функциональные проверки",
]
)
lines.extend(f"- {'ПРОЙДЕНО' if item.passed else 'ОШИБКА'}{item.name}: {item.detail}" for item in tests)
created = (
Path("protocol/persistent_emergency.py"),
Path("protocol/safe_reset.py"),
Path("tests/lab040_persistent_emergency.py"),
SUMMARY_CSV,
EMERGENCY_CSV,
RESET_CSV,
MOTION_CSV,
LOAD_CSV,
REPORT_PATH,
*PLOT_PATHS,
)
lines.extend(["", "Созданные файлы", *[f"- {path.as_posix()}" for path in created]])
lines.extend(
[
"",
"Ограничения",
"- Параметры замедления предварительные и требуют измерения на реальном ровере.",
"- Модель одномерная и не заменяет испытания тормозов, привода и процедуры операторского сброса.",
"- Lab040 не добавлена в Git и не закоммичена; Lab028Lab039 не изменены.",
]
)
REPORT_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
def validate_outputs(
summaries: tuple[SummaryMetrics, ...],
emergencies: tuple[EmergencyMetrics, ...],
resets: tuple[ResetMetrics, ...],
motions: tuple[MotionMetrics, ...],
loads: tuple[LoadMetrics, ...],
tests: tuple[FunctionalTestResult, ...],
transport_sets: dict[tuple[str, str], tuple[TransportTrial, ...]],
) -> None:
assert len(summaries) == len(emergencies) == len(resets) == len(motions) == len(loads) == 45
assert len(tests) == 24 and all(item.passed for item in tests), [item for item in tests if not item.passed]
assert all(
len(transport_sets[(condition.name, MODE_NAMES[mode])]) == condition.repetitions
for condition in CHANNELS
for mode in Mode
)
assert all(row.repetitions == (1 if row.channel_condition == "no_loss" else REPETITIONS) for row in summaries)
assert all(trial.negative_time_order == 0 for trials in transport_sets.values() for trial in trials)
persistent_summaries = [
row for row in summaries if row.mode in ("persistent_no_reset", "persistent_safe_reset")
]
assert sum(row.positive_commands_after_emergency_mean for row in persistent_summaries) == 0.0
assert sum(row.permitted_commands_after_emergency_mean for row in persistent_summaries) == 0.0
persistent_motions = [
row for row in motions if row.mode in ("persistent_no_reset", "persistent_safe_reset")
]
assert sum(row.reaccelerations_before_reset for row in persistent_motions) == 0
assert sum(row.braking_releases_before_reset_ack for row in persistent_motions) == 0
assert sum(row.movement_while_intent for row in persistent_motions) == 0
assert sum(row.unsafe_recoveries for row in persistent_motions) == 0
safe_resets = [row for row in resets if row.mode == "persistent_safe_reset"]
assert sum(row.movement_before_reset_ack_cases for row in safe_resets) == 0
assert sum(row.automatic_old_command_restorations for row in safe_resets) == 0
assert sum(row.negative_speed_cases for row in motions) == 0
assert sum(row.target_speed_exceeded_cases for row in motions) == 0
csv_paths = (SUMMARY_CSV, EMERGENCY_CSV, RESET_CSV, MOTION_CSV, LOAD_CSV)
for path in csv_paths:
assert path.is_file() and path.stat().st_size > 0
path.read_text(encoding="utf-8")
with path.open(encoding="utf-8", newline="") as file:
assert sum(1 for _ in csv.DictReader(file)) == 45
assert REPORT_PATH.is_file() and REPORT_PATH.stat().st_size > 0
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, sorted(str(path) for path in actual ^ expected)
forbidden = {".jpg", ".jpeg", ".pyc", ".pickle", ".pkl", ".bin", ".zip", ".tar", ".gz", ".7z"}
assert not any(path.suffix.lower() in forbidden for path in actual)
assert not any(path.name == "__pycache__" for path in OUTPUT_DIRECTORY.rglob("*"))
def main() -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
workload = build_workload()
tests = run_functional_tests()
summaries, emergencies, resets, motions, loads, transport_sets = run_experiment(workload)
_write_csv(SUMMARY_CSV, SummaryMetrics, summaries)
_write_csv(EMERGENCY_CSV, EmergencyMetrics, emergencies)
_write_csv(RESET_CSV, ResetMetrics, resets)
_write_csv(MOTION_CSV, MotionMetrics, motions)
_write_csv(LOAD_CSV, LoadMetrics, loads)
save_plots(summaries, emergencies, resets, motions, loads)
write_report(workload, summaries, emergencies, resets, motions, loads, tests)
validate_outputs(summaries, emergencies, resets, motions, loads, tests, transport_sets)
print(f"Lab040 complete: combinations={len(summaries)}, lossy repetitions={REPETITIONS}")
for item in tests:
print(f"{'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}")
if __name__ == "__main__":
main()