diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 44e2782..dede328 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -336,3 +336,28 @@ git version 2.51.1.windows.1 - Фактические замедления должны быть измерены на реальном ровере. - Локальный сторожевой таймер сохранил безопасную реакцию в опытах, где аварийная команда не была доставлена. - Следующий этап исследует постоянное аварийное намерение наземной станции и безопасное разрешение повторного движения. + +--- + +# Запись 015 + +## Дата + +5 августа 2026 года + +## Тема + +Завершение Lab040: постоянное аварийное намерение и безопасное повторное разрешение движения. + +## Выполнено + +- Lab040 реализует постоянное аварийное намерение наземной станции. +- Ограниченное окно повторения 500 мс признано небезопасным. +- При полностью потерянной аварийной передаче ограниченный режим допускал последующее возобновление движения. +- В одном из тяжёлых условий путь после такого восстановления достигал в среднем около 490 м при 25 км/ч. +- Постоянное аварийное намерение исключило положительные задания скорости и небезопасные возобновления движения. +- Подтверждение аварийной команды не снимает аварийное намерение. +- Сброс принимается только после остановки, при свежей связи и корректных идентификаторах. +- Подтверждение сброса не восстанавливает старую команду движения. +- Движение требует новой явной команды оператора. +- Следующий этап посвящён сеансам связи, перезапускам устройств и защите от пакетов предыдущего сеанса. diff --git a/data/processed/lab040/lab040_emergency_delivery.png b/data/processed/lab040/lab040_emergency_delivery.png new file mode 100644 index 0000000..a844f3b Binary files /dev/null and b/data/processed/lab040/lab040_emergency_delivery.png differ diff --git a/data/processed/lab040/lab040_emergency_metrics.csv b/data/processed/lab040/lab040_emergency_metrics.csv new file mode 100644 index 0000000..95b2383 --- /dev/null +++ b/data/processed/lab040/lab040_emergency_metrics.csv @@ -0,0 +1,46 @@ +channel_condition,mode,initial_speed_kmh,repetitions,delivered_fraction,mean_latch_delay_ms,p95_latch_delay_ms,max_latch_delay_ms,mean_copies,mean_lost_copies,mean_ack_delay_ms,acknowledgements_lost_mean,intent_time_fraction,positive_commands_after_emergency_mean,permitted_commands_after_emergency_mean,time_to_command_suppression_mean_ms,all_first_500ms_lost +no_loss,limited_lab038,5.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +no_loss,limited_lab038,15.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +no_loss,limited_lab038,25.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +no_loss,persistent_no_reset,5.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.75,0.0,0.0,0.0,0 +no_loss,persistent_no_reset,15.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.75,0.0,0.0,0.0,0 +no_loss,persistent_no_reset,25.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.75,0.0,0.0,0.0,0 +no_loss,persistent_safe_reset,5.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.25002111388888887,0.0,0.0,0.0,0 +no_loss,persistent_safe_reset,15.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.25002111388888887,0.0,0.0,0.0,0 +no_loss,persistent_safe_reset,25.0,1,1.0,1.6800000000003479,1.6800000000003479,1.6800000000003479,1.0,0.0,2.80000000000058,0.0,0.25002111388888887,0.0,0.0,0.0,0 +300_50,limited_lab038,5.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +300_50,limited_lab038,15.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +300_50,limited_lab038,25.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +300_50,persistent_no_reset,5.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.75,0.0,0.0,0.0,0 +300_50,persistent_no_reset,15.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.75,0.0,0.0,0.0,0 +300_50,persistent_no_reset,25.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.75,0.0,0.0,0.0,0 +300_50,persistent_safe_reset,5.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.25003919386111106,0.0,0.0,0.0,0 +300_50,persistent_safe_reset,15.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.25003919386111106,0.0,0.0,0.0,0 +300_50,persistent_safe_reset,25.0,200,1.0,2.9917333333336593,1.6800000000003479,155.81333333333092,1.025,0.025,4.111736666667216,0.0,0.25003919386111106,0.0,0.0,0.0,0 +260_200,limited_lab038,5.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +260_200,limited_lab038,15.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +260_200,limited_lab038,25.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +260_200,persistent_no_reset,5.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.75,0.0,0.0,0.0,0 +260_200,persistent_no_reset,15.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.75,0.0,0.0,0.0,0 +260_200,persistent_no_reset,25.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.75,0.0,0.0,0.0,0 +260_200,persistent_safe_reset,5.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.2501198999711538,0.0,0.0,0.0,0 +260_200,persistent_safe_reset,15.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.2501198999711538,0.0,0.0,0.0,0 +260_200,persistent_safe_reset,25.0,200,1.0,19.55153846217634,18.64615384679169,157.53846153910445,1.02,0.02,20.843847308325895,0.0,0.2501198999711538,0.0,0.0,0.0,0 +230_200,limited_lab038,5.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +230_200,limited_lab038,15.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +230_200,limited_lab038,25.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.004166666666666667,1800.0,1800.0,90000.0,0 +230_200,persistent_no_reset,5.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.75,0.0,0.0,0.0,0 +230_200,persistent_no_reset,15.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.75,0.0,0.0,0.0,0 +230_200,persistent_no_reset,25.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.75,0.0,0.0,0.0,0 +230_200,persistent_safe_reset,5.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.25013972127536394,0.0,0.0,0.0,0 +230_200,persistent_safe_reset,15.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.25013972127536394,0.0,0.0,0.0,0 +230_200,persistent_safe_reset,25.0,200,1.0,5.925913042961427,3.860869564700664,258.1217391299049,1.04,0.04,7.386914565218402,0.0,0.25013972127536394,0.0,0.0,0.0,0 +230_1000,limited_lab038,5.0,200,0.99,3.860869564700665,3.860869564700664,3.860869564700664,1.1,0.11,5.321869565218408,0.0,0.004166666666666667,1800.0,1800.0,90000.0,2 +230_1000,limited_lab038,15.0,200,0.99,3.860869564700665,3.860869564700664,3.860869564700664,1.1,0.11,5.321869565218408,0.0,0.004166666666666667,1800.0,1800.0,90000.0,2 +230_1000,limited_lab038,25.0,200,0.99,3.860869564700665,3.860869564700664,3.860869564700664,1.1,0.11,5.321869565218408,0.0,0.004166666666666667,1800.0,1800.0,90000.0,2 +230_1000,persistent_no_reset,5.0,200,1.0,17.288521738613035,3.860869564700664,1371.3391304341976,1.265,0.265,18.74952043477778,0.0,0.75,0.0,0.0,0.0,2 +230_1000,persistent_no_reset,15.0,200,1.0,17.288521738613035,3.860869564700664,1371.3391304341976,1.265,0.265,18.74952043477778,0.0,0.75,0.0,0.0,0.0,2 +230_1000,persistent_no_reset,25.0,200,1.0,17.288521738613035,3.860869564700664,1371.3391304341976,1.265,0.265,18.74952043477778,0.0,0.75,0.0,0.0,0.0,2 +230_1000,persistent_safe_reset,5.0,200,1.0,17.288521738613035,3.860869564700664,1371.3391304341976,1.265,0.265,18.74952043477778,0.0,0.2500872242282613,0.0,0.0,0.0,2 +230_1000,persistent_safe_reset,15.0,200,1.0,17.288521738613035,3.860869564700664,1371.3391304341976,1.265,0.265,18.74952043477778,0.0,0.2500872242282613,0.0,0.0,0.0,2 +230_1000,persistent_safe_reset,25.0,200,1.0,17.288521738613035,3.860869564700664,1371.3391304341976,1.265,0.265,18.74952043477778,0.0,0.2500872242282613,0.0,0.0,0.0,2 diff --git a/data/processed/lab040/lab040_latch_time.png b/data/processed/lab040/lab040_latch_time.png new file mode 100644 index 0000000..739c1d0 Binary files /dev/null and b/data/processed/lab040/lab040_latch_time.png differ diff --git a/data/processed/lab040/lab040_load_metrics.csv b/data/processed/lab040/lab040_load_metrics.csv new file mode 100644 index 0000000..f346c92 --- /dev/null +++ b/data/processed/lab040/lab040_load_metrics.csv @@ -0,0 +1,46 @@ +channel_condition,mode,initial_speed_kmh,repetitions,emergency_copy_load_kbps,emergency_ack_load_kbps,zero_control_load_kbps,reset_request_load_kbps,reset_ack_load_kbps,total_extra_load_kbps,video_published_fraction,telemetry_delivered_fraction,maximum_queue_packets +no_loss,limited_lab038,5.0,1,0.004200000000000001,0.0028,0.0,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,limited_lab038,15.0,1,0.004200000000000001,0.0028,0.0,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,limited_lab038,25.0,1,0.004200000000000001,0.0028,0.0,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_no_reset,5.0,1,0.004200000000000001,0.0028,5.52,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_no_reset,15.0,1,0.004200000000000001,0.0028,5.52,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_no_reset,25.0,1,0.004200000000000001,0.0028,5.52,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_safe_reset,5.0,1,0.004200000000000001,0.0028,1.9657333333333333,0.0032666666666666664,0.0030666666666666668,0.013333333333333334,1.0,1.0,21 +no_loss,persistent_safe_reset,15.0,1,0.004200000000000001,0.0028,1.9657333333333333,0.0032666666666666664,0.0030666666666666668,0.013333333333333334,1.0,1.0,21 +no_loss,persistent_safe_reset,25.0,1,0.004200000000000001,0.0028,1.9657333333333333,0.0032666666666666664,0.0030666666666666668,0.013333333333333334,1.0,1.0,21 +300_50,limited_lab038,5.0,200,0.004305000000000001,0.0028,0.0,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,limited_lab038,15.0,200,0.004305000000000001,0.0028,0.0,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,limited_lab038,25.0,200,0.004305000000000001,0.0028,0.0,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_no_reset,5.0,200,0.004305000000000001,0.0028,5.52,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_no_reset,15.0,200,0.004305000000000001,0.0028,5.52,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_no_reset,25.0,200,0.004305000000000001,0.0028,5.52,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_safe_reset,5.0,200,0.004305000000000001,0.0028,1.965856,0.0033973333333333334,0.0030973333333333334,0.013599666666666668,0.9416388888888889,0.9791875000000001,21 +300_50,persistent_safe_reset,15.0,200,0.004305000000000001,0.0028,1.965856,0.0033973333333333334,0.0030973333333333334,0.013599666666666668,0.9416388888888889,0.9791875000000001,21 +300_50,persistent_safe_reset,25.0,200,0.004305000000000001,0.0028,1.965856,0.0033973333333333334,0.0030973333333333334,0.013599666666666668,0.9416388888888889,0.9791875000000001,21 +260_200,limited_lab038,5.0,200,0.0042840000000000005,0.0028,0.0,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,limited_lab038,15.0,200,0.0042840000000000005,0.0028,0.0,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,limited_lab038,25.0,200,0.0042840000000000005,0.0028,0.0,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_no_reset,5.0,200,0.0042840000000000005,0.0028,5.52,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_no_reset,15.0,200,0.0042840000000000005,0.0028,5.52,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_no_reset,25.0,200,0.0042840000000000005,0.0028,5.52,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_safe_reset,5.0,200,0.0042840000000000005,0.0028,1.9661473333333332,0.003707666666666667,0.003128,0.013919666666666667,0.9603055555555555,0.9797708333333333,38 +260_200,persistent_safe_reset,15.0,200,0.0042840000000000005,0.0028,1.9661473333333332,0.003707666666666667,0.003128,0.013919666666666667,0.9603055555555555,0.9797708333333333,38 +260_200,persistent_safe_reset,25.0,200,0.0042840000000000005,0.0028,1.9661473333333332,0.003707666666666667,0.003128,0.013919666666666667,0.9603055555555555,0.9797708333333333,38 +230_200,limited_lab038,5.0,200,0.0043679999999999995,0.0028,0.0,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,limited_lab038,15.0,200,0.0043679999999999995,0.0028,0.0,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,limited_lab038,25.0,200,0.0043679999999999995,0.0028,0.0,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_no_reset,5.0,200,0.0043679999999999995,0.0028,5.52,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_no_reset,15.0,200,0.0043679999999999995,0.0028,5.52,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_no_reset,25.0,200,0.0043679999999999995,0.0028,5.52,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_safe_reset,5.0,200,0.0043679999999999995,0.0028,1.966408,0.003985333333333334,0.003143333333333333,0.014296666666666666,0.8491805555555555,0.9798749999999999,40 +230_200,persistent_safe_reset,15.0,200,0.0043679999999999995,0.0028,1.966408,0.003985333333333334,0.003143333333333333,0.014296666666666666,0.8491805555555555,0.9798749999999999,40 +230_200,persistent_safe_reset,25.0,200,0.0043679999999999995,0.0028,1.966408,0.003985333333333334,0.003143333333333333,0.014296666666666666,0.8491805555555555,0.9798749999999999,40 +230_1000,limited_lab038,5.0,200,0.00462,0.0027719999999999997,0.0,0.0,0.0,0.007391999999999999,0.8631388888888889,0.9797125,40 +230_1000,limited_lab038,15.0,200,0.00462,0.0027719999999999997,0.0,0.0,0.0,0.007391999999999999,0.8631388888888889,0.9797125,40 +230_1000,limited_lab038,25.0,200,0.00462,0.0027719999999999997,0.0,0.0,0.0,0.007391999999999999,0.8631388888888889,0.9797125,40 +230_1000,persistent_no_reset,5.0,200,0.005313,0.0028,5.52,0.0,0.0,0.008113,0.8631388888888889,0.9797083333333334,40 +230_1000,persistent_no_reset,15.0,200,0.005313,0.0028,5.52,0.0,0.0,0.008113,0.8631388888888889,0.9797083333333334,40 +230_1000,persistent_no_reset,25.0,200,0.005313,0.0028,5.52,0.0,0.0,0.008113,0.8631388888888889,0.9797083333333334,40 +230_1000,persistent_safe_reset,5.0,200,0.005313,0.0028,1.96604,0.0035933333333333334,0.0030819999999999997,0.014788333333333334,0.8631805555555556,0.97975,39 +230_1000,persistent_safe_reset,15.0,200,0.005313,0.0028,1.96604,0.0035933333333333334,0.0030819999999999997,0.014788333333333334,0.8631805555555556,0.97975,39 +230_1000,persistent_safe_reset,25.0,200,0.005313,0.0028,1.96604,0.0035933333333333334,0.0030819999999999997,0.014788333333333334,0.8631805555555556,0.97975,39 diff --git a/data/processed/lab040/lab040_motion_metrics.csv b/data/processed/lab040/lab040_motion_metrics.csv new file mode 100644 index 0000000..7d2b255 --- /dev/null +++ b/data/processed/lab040/lab040_motion_metrics.csv @@ -0,0 +1,46 @@ +channel_condition,mode,initial_speed_kmh,repetitions,mean_distance_to_braking_m,p95_distance_to_braking_m,max_distance_to_braking_m,mean_distance_to_stop_m,p95_distance_to_stop_m,max_distance_to_stop_m,mean_time_to_stop_seconds,p95_time_to_stop_seconds,maximum_speed_after_emergency_mps,full_stop_fraction,reaccelerations_before_reset,braking_releases_before_reset_ack,movement_while_intent,recovery_after_lost_emergency,watchdog_stops_after_lost_emergency,unsafe_recoveries,mean_stop_to_resume_seconds,mean_distance_after_unsafe_resume_m,negative_speed_cases,target_speed_exceeded_cases +no_loss,limited_lab038,5.0,1,0.0023333333333326323,0.0023333333333326323,0.0023333333333326323,0.32383539094650615,0.32383539094650615,0.32383539094650615,0.46464296296296226,0.46464296296296226,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,limited_lab038,15.0,1,0.007000000000005002,0.007000000000005002,0.007000000000005002,2.900518518518524,2.900518518518524,2.900518518518524,1.3905688888888896,1.3905688888888896,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,limited_lab038,25.0,1,0.011666666666656056,0.011666666666656056,0.011666666666656056,8.049218106995795,8.049218106995795,8.049218106995795,2.316494814814817,2.316494814814817,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,persistent_no_reset,5.0,1,0.0023333333333326323,0.0023333333333326323,0.0023333333333326323,0.32383539094650615,0.32383539094650615,0.32383539094650615,0.46464296296296226,0.46464296296296226,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,persistent_no_reset,15.0,1,0.007000000000005002,0.007000000000005002,0.007000000000005002,2.900518518518524,2.900518518518524,2.900518518518524,1.3905688888888896,1.3905688888888896,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,persistent_no_reset,25.0,1,0.011666666666656056,0.011666666666656056,0.011666666666656056,8.049218106995795,8.049218106995795,8.049218106995795,2.316494814814817,2.316494814814817,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,persistent_safe_reset,5.0,1,0.0023333333333326323,0.0023333333333326323,0.0023333333333326323,0.32383539094650615,0.32383539094650615,0.32383539094650615,0.46464296296296226,0.46464296296296226,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,persistent_safe_reset,15.0,1,0.007000000000005002,0.007000000000005002,0.007000000000005002,2.900518518518524,2.900518518518524,2.900518518518524,1.3905688888888896,1.3905688888888896,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +no_loss,persistent_safe_reset,25.0,1,0.011666666666656056,0.011666666666656056,0.011666666666656056,8.049218106995795,8.049218106995795,8.049218106995795,2.316494814814817,2.316494814814817,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,limited_lab038,5.0,200,0.003523520833332583,0.0023333333333326323,0.09007453703703305,0.32543289857947344,0.32383539094650615,0.49304062124373615,0.4658030968518511,0.46464296296296226,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,limited_lab038,15.0,200,0.010570562500004854,0.007000000000005002,0.27022361111110627,2.9053386202872944,2.9005185185185383,3.413649983383692,1.3917290227777783,1.3905688888888896,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,limited_lab038,25.0,200,0.017617604166656094,0.011666666666656056,0.45037268518515816,8.05726080290037,8.049218106995797,8.906275806428908,2.3176549487037055,2.316494814814817,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,persistent_no_reset,5.0,200,0.003523520833332583,0.0023333333333326323,0.09007453703703305,0.32543289857947344,0.32383539094650615,0.49304062124373615,0.4658030968518511,0.46464296296296226,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,persistent_no_reset,15.0,200,0.010570562500004854,0.007000000000005002,0.27022361111110627,2.9053386202872944,2.9005185185185383,3.413649983383692,1.3917290227777783,1.3905688888888896,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,persistent_no_reset,25.0,200,0.017617604166656094,0.011666666666656056,0.45037268518515816,8.05726080290037,8.049218106995797,8.906275806428908,2.3176549487037055,2.316494814814817,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,persistent_safe_reset,5.0,200,0.003523520833332583,0.0023333333333326323,0.09007453703703305,0.32543289857947344,0.32383539094650615,0.49304062124373615,0.4658030968518511,0.46464296296296226,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,persistent_safe_reset,15.0,200,0.010570562500004854,0.007000000000005002,0.27022361111110627,2.9053386202872944,2.9005185185185383,3.413649983383692,1.3917290227777783,1.3905688888888896,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +300_50,persistent_safe_reset,25.0,200,0.017617604166656094,0.011666666666656056,0.45037268518515816,8.05726080290037,8.049218106995797,8.906275806428908,2.3176549487037055,2.316494814814817,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,limited_lab038,5.0,200,0.02659741546859795,0.025897435898322385,0.15572649572737873,0.3445953708476268,0.3473994935114959,0.5185923163385837,0.4789198347584711,0.4816091168097536,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,limited_lab038,15.0,200,0.0801952787182271,0.07769230769496005,0.4671794871821362,2.96093647562938,2.971210826213479,3.4861643328012093,1.4048457606843985,1.407535042735681,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,limited_lab038,25.0,200,0.1337931419678634,0.12948717949160482,0.7786324786368937,8.149294041316582,8.167038619820914,9.025752810169308,2.3307716866103227,2.333460968661605,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,persistent_no_reset,5.0,200,0.026314295810478294,0.025897435898322385,0.15572649572737873,0.3443122511895071,0.3473994935114959,0.5185923163385837,0.478715988604625,0.4816091168097536,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,persistent_no_reset,15.0,200,0.07934591974386812,0.07769230769496005,0.4671794871821362,2.9600871166550213,2.971210826213479,3.4861643328012093,1.4046419145305524,1.407535042735681,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,persistent_no_reset,25.0,200,0.1323775436772651,0.12948717949160482,0.7786324786368937,8.147878443025984,8.167038619820914,9.025752810169308,2.3305678404564762,2.333460968661605,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,persistent_safe_reset,5.0,200,0.026314295810478294,0.025897435898322385,0.15572649572737873,0.3443122511895071,0.3473994935114959,0.5185923163385837,0.478715988604625,0.4816091168097536,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,persistent_safe_reset,15.0,200,0.07934591974386812,0.07769230769496005,0.4671794871821362,2.9600871166550213,2.971210826213479,3.4861643328012093,1.4046419145305524,1.407535042735681,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +260_200,persistent_safe_reset,25.0,200,0.1323775436772651,0.12948717949160482,0.7786324786368937,8.147878443025984,8.167038619820914,9.025752810169308,2.3305678404564762,2.333460968661605,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,limited_lab038,5.0,200,0.006844621233567239,0.005362318839864599,0.1541304347818837,0.32576159827607626,0.3268643764530381,0.5648917516543079,0.4657324702088222,0.4668238325276626,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,limited_lab038,15.0,200,0.020596070507492143,0.016086956519586693,0.4623913043456582,2.905471134864391,2.909605475038134,3.63035426730859,1.3916583961347495,1.39274975845359,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,limited_lab038,25.0,200,0.03434751978141037,0.02681159419930168,0.7706521739094399,8.057197132357981,8.064363034528526,9.267833243868353,2.3175843220606733,2.3186756843795138,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,persistent_no_reset,5.0,200,0.006844621233567239,0.005362318839864599,0.1541304347818837,0.32576159827607626,0.3268643764530381,0.5648917516543079,0.4657324702088222,0.4668238325276626,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,persistent_no_reset,15.0,200,0.020596070507492143,0.016086956519586693,0.4623913043456582,2.905471134864391,2.909605475038134,3.63035426730859,1.3916583961347495,1.39274975845359,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,persistent_no_reset,25.0,200,0.03434751978141037,0.02681159419930168,0.7706521739094399,8.057197132357981,8.064363034528526,9.267833243868353,2.3175843220606733,2.3186756843795138,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,persistent_safe_reset,5.0,200,0.006844621233567239,0.005362318839864599,0.1541304347818837,0.32576159827607626,0.3268643764530381,0.5648917516543079,0.4657324702088222,0.4668238325276626,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,persistent_safe_reset,15.0,200,0.020596070507492143,0.016086956519586693,0.4623913043456582,2.905471134864391,2.909605475038134,3.63035426730859,1.3916583961347495,1.39274975845359,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_200,persistent_safe_reset,25.0,200,0.03434751978141037,0.02681159419930168,0.7706521739094399,8.057197132357981,8.064363034528526,9.267833243868353,2.3175843220606733,2.3186756843795138,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_1000,limited_lab038,5.0,200,0.005283552251343249,0.005362318839864599,0.005362318839864599,0.3218591033962589,0.3268643764530381,0.3268643764530381,0.461660666666149,0.4668238325276626,1.3888888888888888,1.0,10,0,0,2,2,2,1.1665964573268308,120.24802134182156,0,0 +230_1000,limited_lab038,15.0,200,0.015719344103756895,0.016086956519586693,0.016086956519586693,2.848456321603509,2.909605475038134,2.909605475038134,1.3719143961347493,1.39274975845359,4.166666666666667,1.0,10,0,0,2,2,2,0.240670531400907,356.3939634279401,0,0 +230_1000,limited_lab038,25.0,200,0.02619747596775227,0.02681159419930168,0.02681159419930168,7.9154550932807,8.064363034528498,8.064363034528526,2.288520870540642,2.3186756843795138,6.944444444444445,0.99,10,0,0,2,0,2,0.0,599.4073675629743,0,0 +230_1000,persistent_no_reset,5.0,200,0.005283552251343249,0.005362318839864599,0.005362318839864599,0.3218591033962589,0.3268643764530381,0.3268643764530381,0.461660666666149,0.4668238325276626,1.3888888888888888,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_1000,persistent_no_reset,15.0,200,0.015719344103756895,0.016086956519586693,0.016086956519586693,2.848456321603509,2.909605475038134,2.909605475038134,1.3719143961347493,1.39274975845359,4.166666666666667,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_1000,persistent_no_reset,25.0,200,0.02619747596775227,0.02681159419930168,0.02681159419930168,7.897429893107608,8.064363034528498,8.064363034528526,2.285820389693524,2.3186756843795138,6.944444444444445,1.0,0,0,0,0,0,0,0.0,0.0,0,0 +230_1000,persistent_safe_reset,5.0,200,0.005283552251343249,0.005362318839864599,0.005362318839864599,0.3218591033962589,0.3268643764530381,0.3268643764530381,0.461660666666149,0.4668238325276626,1.3888888888888888,1.0,0,0,0,2,0,0,0.0,0.0,0,0 +230_1000,persistent_safe_reset,15.0,200,0.015719344103756895,0.016086956519586693,0.016086956519586693,2.848456321603509,2.909605475038134,2.909605475038134,1.3719143961347493,1.39274975845359,4.166666666666667,1.0,0,0,0,2,0,0,0.0,0.0,0,0 +230_1000,persistent_safe_reset,25.0,200,0.02619747596775227,0.02681159419930168,0.02681159419930168,7.897429893107608,8.064363034528498,8.064363034528526,2.285820389693524,2.3186756843795138,6.944444444444445,1.0,0,0,0,2,0,0,0.0,0.0,0,0 diff --git a/data/processed/lab040/lab040_protection_load.png b/data/processed/lab040/lab040_protection_load.png new file mode 100644 index 0000000..ef810af Binary files /dev/null and b/data/processed/lab040/lab040_protection_load.png differ diff --git a/data/processed/lab040/lab040_report.txt b/data/processed/lab040/lab040_report.txt new file mode 100644 index 0000000..7f1c8ed --- /dev/null +++ b/data/processed/lab040/lab040_report.txt @@ -0,0 +1,170 @@ +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 секунды и имеет новый номер последовательности. + +Параметры +- Опыт 120 с; аварийная команда на 30-й секунде; сброс на 60-й секунде; команды 20 Гц; повторы каждые 50 мс. +- Матрица: 5 каналов × 3 режима × 3 скорости = 45 сочетаний; условия с потерями по 200 повторов. +- Сторожевой таймер 150/250 мс; замедление первой ступени 1,0 м/с², второй ступени 3,0 м/с², ускорение восстановления 1,0 м/с². +- Исходная нагрузка проверена контрольной суммой и помехоустойчивым кодированием: 12356/714. + +Компактная таблица 45 сочетаний +канал | режим | скорость | доставка аварийной команды, % | время аварийного намерения, % | небезопасное восстановление, % | остановка, % | нагрузка, кбит/с | видео, % | телеметрия, % | очередь +Без потерь, 300 кбит/с | Ограниченное повторение | 5 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00700 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Ограниченное повторение | 15 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00700 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Ограниченное повторение | 25 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00700 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Постоянное намерение | 5 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00700 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Постоянное намерение | 15 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00700 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Постоянное намерение | 25 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00700 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Постоянное намерение и сброс | 5 | 100.000 | 25.002 | 0.000 | 100.000 | 0.01333 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Постоянное намерение и сброс | 15 | 100.000 | 25.002 | 0.000 | 100.000 | 0.01333 | 100.000 | 100.000 | 21 +Без потерь, 300 кбит/с | Постоянное намерение и сброс | 25 | 100.000 | 25.002 | 0.000 | 100.000 | 0.01333 | 100.000 | 100.000 | 21 +300 кбит/с, помеха 50 мс | Ограниченное повторение | 5 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00711 | 94.165 | 97.920 | 21 +300 кбит/с, помеха 50 мс | Ограниченное повторение | 15 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00711 | 94.165 | 97.920 | 21 +300 кбит/с, помеха 50 мс | Ограниченное повторение | 25 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00711 | 94.165 | 97.920 | 21 +300 кбит/с, помеха 50 мс | Постоянное намерение | 5 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00711 | 94.165 | 97.920 | 21 +300 кбит/с, помеха 50 мс | Постоянное намерение | 15 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00711 | 94.165 | 97.920 | 21 +300 кбит/с, помеха 50 мс | Постоянное намерение | 25 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00711 | 94.165 | 97.920 | 21 +300 кбит/с, помеха 50 мс | Постоянное намерение и сброс | 5 | 100.000 | 25.004 | 0.000 | 100.000 | 0.01360 | 94.164 | 97.919 | 21 +300 кбит/с, помеха 50 мс | Постоянное намерение и сброс | 15 | 100.000 | 25.004 | 0.000 | 100.000 | 0.01360 | 94.164 | 97.919 | 21 +300 кбит/с, помеха 50 мс | Постоянное намерение и сброс | 25 | 100.000 | 25.004 | 0.000 | 100.000 | 0.01360 | 94.164 | 97.919 | 21 +260 кбит/с, помеха 200 мс | Ограниченное повторение | 5 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00708 | 96.028 | 97.976 | 36 +260 кбит/с, помеха 200 мс | Ограниченное повторение | 15 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00708 | 96.028 | 97.976 | 36 +260 кбит/с, помеха 200 мс | Ограниченное повторение | 25 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00708 | 96.028 | 97.976 | 36 +260 кбит/с, помеха 200 мс | Постоянное намерение | 5 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00708 | 96.028 | 97.976 | 36 +260 кбит/с, помеха 200 мс | Постоянное намерение | 15 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00708 | 96.028 | 97.976 | 36 +260 кбит/с, помеха 200 мс | Постоянное намерение | 25 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00708 | 96.028 | 97.976 | 36 +260 кбит/с, помеха 200 мс | Постоянное намерение и сброс | 5 | 100.000 | 25.012 | 0.000 | 100.000 | 0.01392 | 96.031 | 97.977 | 38 +260 кбит/с, помеха 200 мс | Постоянное намерение и сброс | 15 | 100.000 | 25.012 | 0.000 | 100.000 | 0.01392 | 96.031 | 97.977 | 38 +260 кбит/с, помеха 200 мс | Постоянное намерение и сброс | 25 | 100.000 | 25.012 | 0.000 | 100.000 | 0.01392 | 96.031 | 97.977 | 38 +230 кбит/с, помеха 200 мс | Ограниченное повторение | 5 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00717 | 84.913 | 97.985 | 40 +230 кбит/с, помеха 200 мс | Ограниченное повторение | 15 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00717 | 84.913 | 97.985 | 40 +230 кбит/с, помеха 200 мс | Ограниченное повторение | 25 | 100.000 | 0.417 | 0.000 | 100.000 | 0.00717 | 84.913 | 97.985 | 40 +230 кбит/с, помеха 200 мс | Постоянное намерение | 5 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00717 | 84.913 | 97.985 | 40 +230 кбит/с, помеха 200 мс | Постоянное намерение | 15 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00717 | 84.913 | 97.985 | 40 +230 кбит/с, помеха 200 мс | Постоянное намерение | 25 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00717 | 84.913 | 97.985 | 40 +230 кбит/с, помеха 200 мс | Постоянное намерение и сброс | 5 | 100.000 | 25.014 | 0.000 | 100.000 | 0.01430 | 84.918 | 97.987 | 40 +230 кбит/с, помеха 200 мс | Постоянное намерение и сброс | 15 | 100.000 | 25.014 | 0.000 | 100.000 | 0.01430 | 84.918 | 97.987 | 40 +230 кбит/с, помеха 200 мс | Постоянное намерение и сброс | 25 | 100.000 | 25.014 | 0.000 | 100.000 | 0.01430 | 84.918 | 97.987 | 40 +230 кбит/с, помеха 1000 мс | Ограниченное повторение | 5 | 99.000 | 0.417 | 1.000 | 100.000 | 0.00739 | 86.314 | 97.971 | 40 +230 кбит/с, помеха 1000 мс | Ограниченное повторение | 15 | 99.000 | 0.417 | 1.000 | 100.000 | 0.00739 | 86.314 | 97.971 | 40 +230 кбит/с, помеха 1000 мс | Ограниченное повторение | 25 | 99.000 | 0.417 | 1.000 | 99.000 | 0.00739 | 86.314 | 97.971 | 40 +230 кбит/с, помеха 1000 мс | Постоянное намерение | 5 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00811 | 86.314 | 97.971 | 40 +230 кбит/с, помеха 1000 мс | Постоянное намерение | 15 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00811 | 86.314 | 97.971 | 40 +230 кбит/с, помеха 1000 мс | Постоянное намерение | 25 | 100.000 | 75.000 | 0.000 | 100.000 | 0.00811 | 86.314 | 97.971 | 40 +230 кбит/с, помеха 1000 мс | Постоянное намерение и сброс | 5 | 100.000 | 25.009 | 0.000 | 100.000 | 0.01479 | 86.318 | 97.975 | 39 +230 кбит/с, помеха 1000 мс | Постоянное намерение и сброс | 15 | 100.000 | 25.009 | 0.000 | 100.000 | 0.01479 | 86.318 | 97.975 | 39 +230 кбит/с, помеха 1000 мс | Постоянное намерение и сброс | 25 | 100.000 | 25.009 | 0.000 | 100.000 | 0.01479 | 86.318 | 97.975 | 39 + +Отрицательный исходный режим +- Полностью потерянные первые 500 мс аварийной передачи: 2 на одно сочетание. +- Остановки по сторожевому таймеру после такой потери: 4. +- Небезопасные восстановления движения: 6; максимальная доля 1.000%. +- Среднее время от остановки до повторного движения: 1.1666 с; путь после восстановления до 599.4074 м. +- Ограниченный режим Lab038 служит отрицательным исходным уровнем и не принимается для рабочей архитектуры. + +Постоянное аварийное намерение +- Итоговая доставка 100% в режимах постоянного намерения получена в конечной выборке и не является математической гарантией. +- Безопасность не зависит от гарантированной доставки удалённой команды: локальный сторожевой таймер продолжает действовать. +- Подтверждение аварийной команды (Emergency ACK) прекращает копии, но не снимает аварийное намерение emergency_intent. +- Положительные задания при активном аварийном намерении: 0. +- Команды с разрешением движения при активном аварийном намерении: 0. +- Небезопасные восстановления в режимах 2 и 3: 0. + +Подтверждение сброса +- Принятые запросы сброса: 1.0000 на опыт; отклонённые: 0.0120. +- Причины отказов: аварийное состояние не зафиксировано=0, движение=0, устаревшая связь=36, неверное событие=0, старый номер последовательности=0, ненулевая скорость=0, разрешение движения=0. +- Среднее число повторов сброса: 0.0990; потерянных подтверждений сброса: 0.0000. +- 95-й процентиль времени от запроса оператора до подтверждения сброса: до 7.477 мс. +- Движение до подтверждения сброса: 0; автоматическое восстановление старой команды: 0. +- Подтверждение сброса (RESET_ACK) разрешает перейти только в безопасное состояние с нулевой скоростью. +- Для движения после сброса требуется новая команда оператора. + +Точечная проверка пути до начала безопасной реакции +- Канал: 230 кбит/с, средняя помеха 1000 мс; транспортный эксперимент повторно не запускался. +- Если в момент действия оператора уже активно состояние STAGE1_DECELERATION, STAGE2_BRAKING или EMERGENCY_LATCHED, путь до начала реакции равен нулю и не измеряется до будущего перехода в торможение. +- режим | скорость, км/ч | среднее, м | 95-й процентиль, м | максимум, м | полные остановки, % | небезопасные возобновления +- Ограниченное повторение | 5 | 0.005284 | 0.005362 | 0.005362 | 100.000 | 2 +- Ограниченное повторение | 15 | 0.015719 | 0.016087 | 0.016087 | 100.000 | 2 +- Ограниченное повторение | 25 | 0.026197 | 0.026812 | 0.026812 | 99.000 | 2 +- Постоянное намерение | 5 | 0.005284 | 0.005362 | 0.005362 | 100.000 | 0 +- Постоянное намерение | 15 | 0.015719 | 0.016087 | 0.016087 | 100.000 | 0 +- Постоянное намерение | 25 | 0.026197 | 0.026812 | 0.026812 | 100.000 | 0 +- Постоянное намерение и сброс | 5 | 0.005284 | 0.005362 | 0.005362 | 100.000 | 0 +- Постоянное намерение и сброс | 15 | 0.015719 | 0.016087 | 0.016087 | 100.000 | 0 +- Постоянное намерение и сброс | 25 | 0.026197 | 0.026812 | 0.026812 | 100.000 | 0 +- Сводная и двигательная таблицы согласованы по числу повторов, доле полных остановок и небезопасным возобновлениям. +- Ни один график Lab040 напрямую не использует путь до начала реакции; график пути до остановки использует отдельный показатель полного пути и не требует изменения. + +Движение и нагрузка +- Максимальный путь до полной остановки: 9.2678 м; максимальное время: 2.3335 с. +- Дополнительная нагрузка защиты: 0.00948…0.01479 кбит/с. +- Нагрузка нулевых состояний: до 5.52000 кбит/с; максимальная очередь 40 пакетов. +- Видео опубликовано: 84.913…100.000%; телеметрия: 97.919…100.000%. + +Функциональные проверки +- ПРОЙДЕНО — 01. Оператор фиксирует аварийное намерение: действие оператора фиксирует аварийное намерение наземной станции +- ПРОЙДЕНО — 02. После аварии нет положительной заданной скорости: аварийное намерение заменяет движение безопасным нулевым состоянием с торможением +- ПРОЙДЕНО — 03. Движение запрещено до сброса: после подтверждения аварийной команды движение остаётся запрещённым +- ПРОЙДЕНО — 04. Потеря аварийной команды сохраняет намерение: управляемая помеха теряет все копии за первые 500 мс, не снимая аварийное намерение +- ПРОЙДЕНО — 05. Подтверждение аварии сохраняет намерение: потеря первого подтверждения и доставка повтора сохраняют аварийное намерение +- ПРОЙДЕНО — 06. Первая аварийная команда фиксирует состояние ровера: первая аварийная команда фиксирует аварийное состояние ровера +- ПРОЙДЕНО — 07. Обычная команда не снимает аварийное состояние: обычная команда не снимает аварийное состояние ровера +- ПРОЙДЕНО — 08. Старая команда движения отклоняется: запоздалая команда движения со старым номером последовательности отклоняется +- ПРОЙДЕНО — 09. Сброс при движении отклоняется: движущийся ровер отклоняет запрос сброса +- ПРОЙДЕНО — 10. Сброс при устаревшей связи отклоняется: при устаревшем состоянии связи запрос сброса отклоняется +- ПРОЙДЕНО — 11. Сброс для неверного события отклоняется: запрос сброса с неверным идентификатором аварии отклоняется +- ПРОЙДЕНО — 12. Сброс со старым номером последовательности отклоняется: запрос сброса со старым номером последовательности отклоняется +- ПРОЙДЕНО — 13. Сброс с ненулевой скоростью отклоняется: ненулевая скорость или разрешение движения приводят к отклонению сброса +- ПРОЙДЕНО — 14. После успешного сброса скорость остаётся нулевой: принятый сброс выводит из аварийного состояния только в состояние нулевой скорости +- ПРОЙДЕНО — 15. До подтверждения сброса движение отсутствует: до подтверждения сброса наземная станция сохраняет безопасное нулевое задание +- ПРОЙДЕНО — 16. Потеря подтверждения сброса вызывает повтор: управляемая потеря первого подтверждения сброса вызывает повтор запроса +- ПРОЙДЕНО — 17. Дубликат не выполняет сброс дважды: принятый дубликат не выполняет сброс повторно +- ПРОЙДЕНО — 18. На дубликат повторно отправляется подтверждение: принятый дубликат вызывает повторное подтверждение сброса +- ПРОЙДЕНО — 19. Старая команда движения не восстанавливается: подтверждение сброса не восстанавливает сохранённую команду движения +- ПРОЙДЕНО — 20. Для движения требуется новая команда оператора: для движения требуется новый номер последовательности команды оператора +- ПРОЙДЕНО — 21. Аварийное состояние устойчиво к обычным командам: состояние EMERGENCY_LATCHED не снимается обычной командой управления +- ПРОЙДЕНО — 22. Сторожевой таймер работает при потере аварийной команды: локальный двухступенчатый сторожевой таймер работает без доставки аварийной команды +- ПРОЙДЕНО — 23. Интервалы помех воспроизводимы: фиксированное начальное значение генератора воспроизводит интервалы помех +- ПРОЙДЕНО — 24. Действие оператора во время торможения: действие оператора при уже активном торможении даёт нулевой путь до начала реакции + +Созданные файлы +- protocol/persistent_emergency.py +- protocol/safe_reset.py +- tests/lab040_persistent_emergency.py +- data/processed/lab040/lab040_summary.csv +- data/processed/lab040/lab040_emergency_metrics.csv +- data/processed/lab040/lab040_reset_metrics.csv +- data/processed/lab040/lab040_motion_metrics.csv +- data/processed/lab040/lab040_load_metrics.csv +- data/processed/lab040/lab040_report.txt +- data/processed/lab040/lab040_emergency_delivery.png +- data/processed/lab040/lab040_unsafe_recovery.png +- data/processed/lab040/lab040_latch_time.png +- data/processed/lab040/lab040_stop_path.png +- data/processed/lab040/lab040_reset_latency.png +- data/processed/lab040/lab040_protection_load.png +- data/processed/lab040/lab040_video_telemetry.png +- data/processed/lab040/lab040_tradeoff.png + +Ограничения +- Параметры замедления предварительные и требуют измерения на реальном ровере. +- Модель одномерная и не заменяет испытания тормозов, привода и процедуры операторского сброса. +- Lab040 не добавлена в Git и не закоммичена; Lab028–Lab039 не изменены. diff --git a/data/processed/lab040/lab040_reset_latency.png b/data/processed/lab040/lab040_reset_latency.png new file mode 100644 index 0000000..a111c7b Binary files /dev/null and b/data/processed/lab040/lab040_reset_latency.png differ diff --git a/data/processed/lab040/lab040_reset_metrics.csv b/data/processed/lab040/lab040_reset_metrics.csv new file mode 100644 index 0000000..f75db38 --- /dev/null +++ b/data/processed/lab040/lab040_reset_metrics.csv @@ -0,0 +1,46 @@ +channel_condition,mode,initial_speed_kmh,repetitions,requests_created_mean,requests_transmitted_mean,requests_accepted_mean,requests_rejected_mean,rejected_not_latched,rejected_moving,rejected_stale_link,rejected_event_mismatch,rejected_old_sequence,rejected_nonzero_request,rejected_movement_permitted,mean_accept_delay_ms,mean_reset_ack_delay_ms,p95_reset_ack_delay_ms,reset_acks_lost_mean,repeated_requests_mean,duplicates_suppressed_mean,reset_reacks_mean,mean_ack_to_new_command_seconds,mean_time_to_actual_resume_seconds,movement_before_reset_ack_cases,automatic_old_command_restorations +no_loss,limited_lab038,5.0,1,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +no_loss,limited_lab038,15.0,1,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +no_loss,limited_lab038,25.0,1,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +no_loss,persistent_no_reset,5.0,1,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +no_loss,persistent_no_reset,15.0,1,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +no_loss,persistent_no_reset,25.0,1,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +no_loss,persistent_safe_reset,5.0,1,1.0,1.0,1.0,0.0,0,0,0,0,0,0,0,1.3066666666645688,2.533666666664658,2.533666666664658,0.0,0.0,0.0,0.0,2.0,2.0536533333333367,0,0 +no_loss,persistent_safe_reset,15.0,1,1.0,1.0,1.0,0.0,0,0,0,0,0,0,0,1.3066666666645688,2.533666666664658,2.533666666664658,0.0,0.0,0.0,0.0,2.0,2.0536533333333367,0,0 +no_loss,persistent_safe_reset,25.0,1,1.0,1.0,1.0,0.0,0,0,0,0,0,0,0,1.3066666666645688,2.533666666664658,2.533666666664658,0.0,0.0,0.0,0.0,2.0,2.0536533333333367,0,0 +300_50,limited_lab038,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +300_50,limited_lab038,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +300_50,limited_lab038,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +300_50,persistent_no_reset,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +300_50,persistent_no_reset,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +300_50,persistent_no_reset,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +300_50,persistent_safe_reset,5.0,200,1.0,1.04,1.0,0.01,0,0,2,0,0,0,0,3.476268333331234,4.703263333331371,2.533666666664658,0.0,0.04,0.0,0.0,2.0,2.057250933333337,0,0 +300_50,persistent_safe_reset,15.0,200,1.0,1.04,1.0,0.01,0,0,2,0,0,0,0,3.476268333331234,4.703263333331371,2.533666666664658,0.0,0.04,0.0,0.0,2.0,2.057250933333337,0,0 +300_50,persistent_safe_reset,25.0,200,1.0,1.04,1.0,0.01,0,0,2,0,0,0,0,3.476268333331234,4.703263333331371,2.533666666664658,0.0,0.04,0.0,0.0,2.0,2.057250933333337,0,0 +260_200,limited_lab038,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +260_200,limited_lab038,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +260_200,limited_lab038,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +260_200,persistent_no_reset,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +260_200,persistent_no_reset,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +260_200,persistent_no_reset,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +260_200,persistent_safe_reset,5.0,200,1.0,1.135,1.0,0.02,0,0,4,0,0,0,0,12.97215884617376,14.387996538461607,7.477384615384608,0.0,0.135,0.0,0.0,2.0,2.0636698426923186,0,0 +260_200,persistent_safe_reset,15.0,200,1.0,1.135,1.0,0.02,0,0,4,0,0,0,0,12.97215884617376,14.387996538461607,7.477384615384608,0.0,0.135,0.0,0.0,2.0,2.0636698426923186,0,0 +260_200,persistent_safe_reset,25.0,200,1.0,1.135,1.0,0.02,0,0,4,0,0,0,0,12.97215884617376,14.387996538461607,7.477384615384608,0.0,0.135,0.0,0.0,2.0,2.0636698426923186,0,0 +230_200,limited_lab038,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_200,limited_lab038,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_200,limited_lab038,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_200,persistent_no_reset,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_200,persistent_no_reset,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_200,persistent_no_reset,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_200,persistent_safe_reset,5.0,200,1.0,1.22,1.0,0.025,0,0,5,0,0,0,0,15.166219570442614,16.766553043666867,5.668300000003406,0.0,0.22,0.0,0.0,2.0,2.0674834226092775,0,0 +230_200,persistent_safe_reset,15.0,200,1.0,1.22,1.0,0.025,0,0,5,0,0,0,0,15.166219570442614,16.766553043666867,5.668300000003406,0.0,0.22,0.0,0.0,2.0,2.0674834226092775,0,0 +230_200,persistent_safe_reset,25.0,200,1.0,1.22,1.0,0.025,0,0,5,0,0,0,0,15.166219570442614,16.766553043666867,5.668300000003406,0.0,0.22,0.0,0.0,2.0,2.0674834226092775,0,0 +230_1000,limited_lab038,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_1000,limited_lab038,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_1000,limited_lab038,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_1000,persistent_no_reset,5.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_1000,persistent_no_reset,15.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_1000,persistent_no_reset,25.0,200,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,0 +230_1000,persistent_safe_reset,5.0,200,1.0,1.1,1.0,0.005,0,0,1,0,0,0,0,8.86656478782701,10.466907391354853,5.357000000003609,0.0,0.1,0.0,0.0,2.0,2.073877863913489,0,0 +230_1000,persistent_safe_reset,15.0,200,1.0,1.1,1.0,0.005,0,0,1,0,0,0,0,8.86656478782701,10.466907391354853,5.357000000003609,0.0,0.1,0.0,0.0,2.0,2.073877863913489,0,0 +230_1000,persistent_safe_reset,25.0,200,1.0,1.1,1.0,0.005,0,0,1,0,0,0,0,8.86656478782701,10.466907391354853,5.357000000003609,0.0,0.1,0.0,0.0,2.0,2.073877863913489,0,0 diff --git a/data/processed/lab040/lab040_stop_path.png b/data/processed/lab040/lab040_stop_path.png new file mode 100644 index 0000000..35b1d6f Binary files /dev/null and b/data/processed/lab040/lab040_stop_path.png differ diff --git a/data/processed/lab040/lab040_summary.csv b/data/processed/lab040/lab040_summary.csv new file mode 100644 index 0000000..2b0a660 --- /dev/null +++ b/data/processed/lab040/lab040_summary.csv @@ -0,0 +1,46 @@ +channel_condition,mode,initial_speed_kmh,repetitions,emergency_delivery_fraction,emergency_intent_time_fraction,unsafe_recovery_fraction,full_stop_fraction,movement_while_intent_cases,movement_before_reset_ack_cases,positive_commands_after_emergency_mean,permitted_commands_after_emergency_mean,total_extra_load_kbps,video_published_fraction,telemetry_delivered_fraction,maximum_queue_packets +no_loss,limited_lab038,5.0,1,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007000000000000001,1.0,1.0,21 +no_loss,limited_lab038,15.0,1,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007000000000000001,1.0,1.0,21 +no_loss,limited_lab038,25.0,1,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_no_reset,5.0,1,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_no_reset,15.0,1,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_no_reset,25.0,1,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007000000000000001,1.0,1.0,21 +no_loss,persistent_safe_reset,5.0,1,1.0,0.25002111388888887,0.0,1.0,0,0,0.0,0.0,0.013333333333333334,1.0,1.0,21 +no_loss,persistent_safe_reset,15.0,1,1.0,0.25002111388888887,0.0,1.0,0,0,0.0,0.0,0.013333333333333334,1.0,1.0,21 +no_loss,persistent_safe_reset,25.0,1,1.0,0.25002111388888887,0.0,1.0,0,0,0.0,0.0,0.013333333333333334,1.0,1.0,21 +300_50,limited_lab038,5.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007105,0.9416527777777778,0.9792,21 +300_50,limited_lab038,15.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007105,0.9416527777777778,0.9792,21 +300_50,limited_lab038,25.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_no_reset,5.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_no_reset,15.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_no_reset,25.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007105,0.9416527777777778,0.9792,21 +300_50,persistent_safe_reset,5.0,200,1.0,0.25003919386111106,0.0,1.0,0,0,0.0,0.0,0.013599666666666668,0.9416388888888889,0.9791875000000001,21 +300_50,persistent_safe_reset,15.0,200,1.0,0.25003919386111106,0.0,1.0,0,0,0.0,0.0,0.013599666666666668,0.9416388888888889,0.9791875000000001,21 +300_50,persistent_safe_reset,25.0,200,1.0,0.25003919386111106,0.0,1.0,0,0,0.0,0.0,0.013599666666666668,0.9416388888888889,0.9791875000000001,21 +260_200,limited_lab038,5.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,limited_lab038,15.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,limited_lab038,25.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_no_reset,5.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_no_reset,15.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_no_reset,25.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007084,0.9602777777777778,0.9797583333333334,36 +260_200,persistent_safe_reset,5.0,200,1.0,0.2501198999711538,0.0,1.0,0,0,0.0,0.0,0.013919666666666667,0.9603055555555555,0.9797708333333333,38 +260_200,persistent_safe_reset,15.0,200,1.0,0.2501198999711538,0.0,1.0,0,0,0.0,0.0,0.013919666666666667,0.9603055555555555,0.9797708333333333,38 +260_200,persistent_safe_reset,25.0,200,1.0,0.2501198999711538,0.0,1.0,0,0,0.0,0.0,0.013919666666666667,0.9603055555555555,0.9797708333333333,38 +230_200,limited_lab038,5.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007167999999999999,0.849125,0.97985,40 +230_200,limited_lab038,15.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007167999999999999,0.849125,0.97985,40 +230_200,limited_lab038,25.0,200,1.0,0.004166666666666667,0.0,1.0,0,0,1800.0,1800.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_no_reset,5.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_no_reset,15.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_no_reset,25.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.007167999999999999,0.849125,0.97985,40 +230_200,persistent_safe_reset,5.0,200,1.0,0.25013972127536394,0.0,1.0,0,0,0.0,0.0,0.014296666666666666,0.8491805555555555,0.9798749999999999,40 +230_200,persistent_safe_reset,15.0,200,1.0,0.25013972127536394,0.0,1.0,0,0,0.0,0.0,0.014296666666666666,0.8491805555555555,0.9798749999999999,40 +230_200,persistent_safe_reset,25.0,200,1.0,0.25013972127536394,0.0,1.0,0,0,0.0,0.0,0.014296666666666666,0.8491805555555555,0.9798749999999999,40 +230_1000,limited_lab038,5.0,200,0.99,0.004166666666666667,0.01,1.0,0,0,1800.0,1800.0,0.007391999999999999,0.8631388888888889,0.9797125,40 +230_1000,limited_lab038,15.0,200,0.99,0.004166666666666667,0.01,1.0,0,0,1800.0,1800.0,0.007391999999999999,0.8631388888888889,0.9797125,40 +230_1000,limited_lab038,25.0,200,0.99,0.004166666666666667,0.01,0.99,0,0,1800.0,1800.0,0.007391999999999999,0.8631388888888889,0.9797125,40 +230_1000,persistent_no_reset,5.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.008113,0.8631388888888889,0.9797083333333334,40 +230_1000,persistent_no_reset,15.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.008113,0.8631388888888889,0.9797083333333334,40 +230_1000,persistent_no_reset,25.0,200,1.0,0.75,0.0,1.0,0,0,0.0,0.0,0.008113,0.8631388888888889,0.9797083333333334,40 +230_1000,persistent_safe_reset,5.0,200,1.0,0.2500872242282613,0.0,1.0,0,0,0.0,0.0,0.014788333333333334,0.8631805555555556,0.97975,39 +230_1000,persistent_safe_reset,15.0,200,1.0,0.2500872242282613,0.0,1.0,0,0,0.0,0.0,0.014788333333333334,0.8631805555555556,0.97975,39 +230_1000,persistent_safe_reset,25.0,200,1.0,0.2500872242282613,0.0,1.0,0,0,0.0,0.0,0.014788333333333334,0.8631805555555556,0.97975,39 diff --git a/data/processed/lab040/lab040_tradeoff.png b/data/processed/lab040/lab040_tradeoff.png new file mode 100644 index 0000000..d1f8513 Binary files /dev/null and b/data/processed/lab040/lab040_tradeoff.png differ diff --git a/data/processed/lab040/lab040_unsafe_recovery.png b/data/processed/lab040/lab040_unsafe_recovery.png new file mode 100644 index 0000000..9510346 Binary files /dev/null and b/data/processed/lab040/lab040_unsafe_recovery.png differ diff --git a/data/processed/lab040/lab040_video_telemetry.png b/data/processed/lab040/lab040_video_telemetry.png new file mode 100644 index 0000000..6a96762 Binary files /dev/null and b/data/processed/lab040/lab040_video_telemetry.png differ diff --git a/protocol/persistent_emergency.py b/protocol/persistent_emergency.py new file mode 100644 index 0000000..b30a12b --- /dev/null +++ b/protocol/persistent_emergency.py @@ -0,0 +1,147 @@ +"""Постоянное аварийное намерение наземной станции для Lab040. + +Автомат состояний намеренно отделяет подтверждение аварийного пакета от +снятия намерения оператора. Только принятое подтверждение сброса разрешает +сформировать новую команду движения. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from protocol.control_failsafe import ControlState, SAFE_STATE +from protocol.emergency_ack import EmergencyIdentity + + +class GroundState(str, Enum): + GROUND_NORMAL = "ground_normal" + GROUND_EMERGENCY_REQUESTED = "ground_emergency_requested" + GROUND_EMERGENCY_CONFIRMED = "ground_emergency_confirmed" + GROUND_RESET_REQUESTED = "ground_reset_requested" + GROUND_MOVEMENT_REAUTHORIZED = "ground_movement_reauthorized" + + +@dataclass(frozen=True) +class GroundSnapshot: + state: GroundState + emergency_intent: bool + movement_permitted: bool + emergency_acknowledged: bool + reset_acknowledged: bool + + +class PersistentEmergencyController: + """Автомат наземной станции, не снимающий намерение по подтверждению аварии.""" + + def __init__(self, initial_speed_mps: float) -> None: + if initial_speed_mps < 0.0: + raise ValueError("начальная скорость не может быть отрицательной") + self.state = GroundState.GROUND_NORMAL + self.emergency_intent = False + self.emergency_event_id: int | None = None + self.emergency_stream_id: int | None = None + self.emergency_sequence: int | None = None + self.emergency_acknowledged = False + self.reset_request_id: int | None = None + self.reset_sequence: int | None = None + self.reset_acknowledged = False + self.current_motion_speed_mps = initial_speed_mps + self.last_operator_sequence = 0 + self._sequence_at_emergency = -1 + + @property + def snapshot(self) -> GroundSnapshot: + control = self.control_state() + return GroundSnapshot( + self.state, + self.emergency_intent, + control.movement_allowed, + self.emergency_acknowledged, + self.reset_acknowledged, + ) + + def request_emergency( + self, + event_id: int, + stream_id: int, + sequence_number: int, + ) -> None: + if self.state is not GroundState.GROUND_NORMAL: + raise RuntimeError("аварийную команду можно запросить только из нормального состояния") + if event_id < 0: + raise ValueError("идентификатор события не может быть отрицательным") + self.emergency_event_id = event_id + self.emergency_stream_id = stream_id + self.emergency_sequence = sequence_number + self.emergency_intent = True + self.emergency_acknowledged = False + self.reset_acknowledged = False + self._sequence_at_emergency = self.last_operator_sequence + self.state = GroundState.GROUND_EMERGENCY_REQUESTED + + def receive_emergency_ack(self, identity: EmergencyIdentity) -> bool: + expected = EmergencyIdentity( + self.emergency_stream_id if self.emergency_stream_id is not None else -1, + self.emergency_sequence if self.emergency_sequence is not None else -1, + ) + if identity != expected: + return False + first = not self.emergency_acknowledged + self.emergency_acknowledged = True + if self.state is GroundState.GROUND_EMERGENCY_REQUESTED: + self.state = GroundState.GROUND_EMERGENCY_CONFIRMED + return first + + def request_reset(self, reset_request_id: int, sequence_number: int) -> None: + if not self.emergency_intent: + raise RuntimeError("для сброса требуется постоянное аварийное намерение") + if self.state not in ( + GroundState.GROUND_EMERGENCY_REQUESTED, + GroundState.GROUND_EMERGENCY_CONFIRMED, + ): + raise RuntimeError("сброс уже ожидается или завершён") + if reset_request_id < 0 or sequence_number < 0: + raise ValueError("идентификаторы сброса не могут быть отрицательными") + self.reset_request_id = reset_request_id + self.reset_sequence = sequence_number + self.reset_acknowledged = False + self.state = GroundState.GROUND_RESET_REQUESTED + + def receive_reset_ack( + self, + event_id: int, + reset_request_id: int, + accepted: bool, + ) -> bool: + if self.state is not GroundState.GROUND_RESET_REQUESTED: + return False + if event_id != self.emergency_event_id or reset_request_id != self.reset_request_id: + return False + if not accepted: + return False + self.reset_acknowledged = True + self.emergency_intent = False + self.current_motion_speed_mps = 0.0 + self.state = GroundState.GROUND_MOVEMENT_REAUTHORIZED + return True + + def control_state(self) -> ControlState: + if self.state is GroundState.GROUND_NORMAL: + return ControlState(self.current_motion_speed_mps, 0.0, False, True) + return SAFE_STATE + + def new_operator_motion(self, sequence_number: int, speed_mps: float) -> ControlState: + if self.state is not GroundState.GROUND_MOVEMENT_REAUTHORIZED: + raise RuntimeError("повторное движение не разрешено") + if sequence_number <= self._sequence_at_emergency or sequence_number <= self.last_operator_sequence: + raise ValueError("требуется новый номер последовательности команды оператора") + if speed_mps <= 0.0: + raise ValueError("новая скорость движения должна быть положительной") + self.last_operator_sequence = sequence_number + self.current_motion_speed_mps = speed_mps + self.state = GroundState.GROUND_NORMAL + return self.control_state() + + def reject_old_operator_motion(self, sequence_number: int) -> bool: + return sequence_number <= self._sequence_at_emergency diff --git a/protocol/safe_reset.py b/protocol/safe_reset.py new file mode 100644 index 0000000..7976d88 --- /dev/null +++ b/protocol/safe_reset.py @@ -0,0 +1,192 @@ +"""Команда безопасного аварийного сброса и её подтверждение для Lab040. + +Оба прикладных блока данных передаются внутри неизменного пакета LinkPacket +из Lab033. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +import struct + +from protocol.link_packet import Direction, LinkPacket, TrafficClass +from protocol.two_stage_failsafe import SafetyState, TwoStageFailsafe + + +RESET_MAGIC = b"RST1" +RESET_ACK_MAGIC = b"RAK1" +RESET_FORMAT = "!4sIIfB" +RESET_ACK_FORMAT = "!4sIIBB" +RESET_SIZE = struct.calcsize(RESET_FORMAT) +RESET_ACK_SIZE = struct.calcsize(RESET_ACK_FORMAT) +STREAM_RESET = 6 +STREAM_RESET_ACK = 7 + + +class ResetReason(IntEnum): + ACCEPTED = 0 + NOT_LATCHED = 1 + MOVING = 2 + STALE_LINK = 3 + EVENT_MISMATCH = 4 + OLD_SEQUENCE = 5 + NONZERO_REQUEST = 6 + MOVEMENT_PERMITTED = 7 + + +@dataclass(frozen=True) +class ResetCommand: + emergency_event_id: int + reset_request_id: int + requested_speed_mps: float + movement_permitted: bool + + +@dataclass(frozen=True) +class ResetAcknowledgement: + emergency_event_id: int + reset_request_id: int + accepted: bool + reason: ResetReason + + +@dataclass(frozen=True) +class ResetDecision: + acknowledgement: ResetAcknowledgement + performed: bool + duplicate: bool + + +def encode_reset(command: ResetCommand) -> bytes: + if command.emergency_event_id < 0 or command.reset_request_id < 0: + raise ValueError("идентификаторы сброса не могут быть отрицательными") + return struct.pack( + RESET_FORMAT, + RESET_MAGIC, + command.emergency_event_id, + command.reset_request_id, + float(command.requested_speed_mps), + int(bool(command.movement_permitted)), + ) + + +def decode_reset(payload: bytes) -> ResetCommand: + if len(payload) != RESET_SIZE: + raise ValueError("неверная длина данных запроса сброса") + magic, event_id, request_id, speed, permitted = struct.unpack(RESET_FORMAT, payload) + if magic != RESET_MAGIC or permitted not in (0, 1): + raise ValueError("неверные данные запроса сброса") + return ResetCommand(event_id, request_id, speed, bool(permitted)) + + +def encode_reset_ack(ack: ResetAcknowledgement) -> bytes: + return struct.pack( + RESET_ACK_FORMAT, + RESET_ACK_MAGIC, + ack.emergency_event_id, + ack.reset_request_id, + int(ack.accepted), + int(ack.reason), + ) + + +def decode_reset_ack(payload: bytes) -> ResetAcknowledgement: + if len(payload) != RESET_ACK_SIZE: + raise ValueError("неверная длина подтверждения сброса") + magic, event_id, request_id, accepted, reason = struct.unpack(RESET_ACK_FORMAT, payload) + if magic != RESET_ACK_MAGIC or accepted not in (0, 1): + raise ValueError("неверное подтверждение сброса") + return ResetAcknowledgement(event_id, request_id, bool(accepted), ResetReason(reason)) + + +def build_reset_packet( + command: ResetCommand, + sequence_number: int, + generation_time_us: int, +) -> LinkPacket: + return LinkPacket( + traffic_class=TrafficClass.EMERGENCY, + direction=Direction.GROUND_TO_ROVER, + stream_id=STREAM_RESET, + sequence_number=sequence_number, + generation_time_us=generation_time_us, + deadline_ms=50, + payload=encode_reset(command), + ) + + +def build_reset_ack( + decision: ResetDecision, + sequence_number: int, + generation_time_us: int, +) -> LinkPacket: + return LinkPacket( + traffic_class=TrafficClass.EMERGENCY, + direction=Direction.ROVER_TO_GROUND, + stream_id=STREAM_RESET_ACK, + sequence_number=sequence_number, + generation_time_us=generation_time_us, + deadline_ms=50, + payload=encode_reset_ack(decision.acknowledgement), + ) + + +class SafeResetReceiver: + """Проверяет условия сброса и повторно подтверждает принятые дубликаты.""" + + def __init__(self) -> None: + self.last_sequence = -1 + self.accepted_keys: set[tuple[int, int, int]] = set() + self.accepted = 0 + self.rejected = 0 + self.duplicates = 0 + self.reacknowledgements = 0 + + def process( + self, + packet: LinkPacket, + *, + rover: TwoStageFailsafe, + actual_speed_mps: float, + last_link_age_seconds: float, + emergency_event_id: int | None, + ) -> ResetDecision: + if packet.traffic_class is not TrafficClass.EMERGENCY: + raise ValueError("запрос сброса должен использовать аварийный класс трафика") + if packet.direction is not Direction.GROUND_TO_ROVER or packet.stream_id != STREAM_RESET: + raise ValueError("неверная оболочка запроса сброса") + command = decode_reset(packet.payload) + key = command.emergency_event_id, command.reset_request_id, packet.sequence_number + if key in self.accepted_keys: + self.duplicates += 1 + self.reacknowledgements += 1 + ack = ResetAcknowledgement(command.emergency_event_id, command.reset_request_id, True, ResetReason.ACCEPTED) + return ResetDecision(ack, False, True) + + reason = ResetReason.ACCEPTED + if rover.state is not SafetyState.EMERGENCY_LATCHED: + reason = ResetReason.NOT_LATCHED + elif actual_speed_mps > 1e-9: + reason = ResetReason.MOVING + elif last_link_age_seconds > 0.250 + 1e-12: + reason = ResetReason.STALE_LINK + elif command.emergency_event_id != emergency_event_id: + reason = ResetReason.EVENT_MISMATCH + elif packet.sequence_number < self.last_sequence: + reason = ResetReason.OLD_SEQUENCE + elif abs(command.requested_speed_mps) > 1e-9: + reason = ResetReason.NONZERO_REQUEST + elif command.movement_permitted: + reason = ResetReason.MOVEMENT_PERMITTED + + self.last_sequence = max(self.last_sequence, packet.sequence_number) + accepted = reason is ResetReason.ACCEPTED + if accepted: + self.accepted_keys.add(key) + self.accepted += 1 + rover.state = SafetyState.NORMAL + else: + self.rejected += 1 + ack = ResetAcknowledgement(command.emergency_event_id, command.reset_request_id, accepted, reason) + return ResetDecision(ack, accepted, False) diff --git a/tests/lab040_persistent_emergency.py b/tests/lab040_persistent_emergency.py new file mode 100644 index 0000000..8230d5d --- /dev/null +++ b/tests/lab040_persistent_emergency.py @@ -0,0 +1,1749 @@ +"""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 и не закоммичена; Lab028–Lab039 не изменены.", + ] + ) + 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()