diff --git a/Makefile.all b/Makefile.all index d296ec6b9..12bea219b 100644 --- a/Makefile.all +++ b/Makefile.all @@ -140,8 +140,9 @@ CONAN_OPTIONS += -o cloe:with_engine=False endif ifeq (${WITH_VTD},1) -ALL_PKGS := $(filter-out plugins/vtd, ${ALL_PKGS}) CONAN_OPTIONS += -o cloe:with_vtd=True +else +ALL_PKGS := $(filter-out plugins/vtd, ${ALL_PKGS}) endif ifeq (${BUILD_TESTS},0) @@ -249,7 +250,7 @@ help:: echo " Options:" echo " USE_NPROC=(0|1) to build $(shell nproc) packages simultaneously (default=0)" echo " WITH_ENGINE=(0|1) to build and deploy cloe-engine (default=1)" - echo " WITH_VTD=(0|1) to build and deploy cloe-plugin-vtd (default=1)" + echo " WITH_VTD=(0|1) to build and deploy cloe-plugin-vtd (default=0)" echo " BUILD_TESTS=(0|1) to build and run unit tests (default=1)" echo echo " Defines:" diff --git a/plugins/vtd/src/rdb_codec.cpp b/plugins/vtd/src/rdb_codec.cpp index ab99515ae..116bbdced 100644 --- a/plugins/vtd/src/rdb_codec.cpp +++ b/plugins/vtd/src/rdb_codec.cpp @@ -288,10 +288,6 @@ void RdbCodec::process(RDB_MSG_t* msg, bool& restart, cloe::Duration& sim_time) } void RdbCodec::step(uint64_t frame_number, bool& restart, cloe::Duration& sim_time) { - // TODO(ben): For some reason, this loop here goes round and round with zero - // messages received. Either we should stop dumping the log message when - // there are no messages to process, or we should have the receive function - // only return when there are messages? while (processing_frame_ || frame_number_ < frame_number || restart) { auto messages = rdb_->receive(); rdb_logger()->trace("RdbCodec: processing {} messages [frame={}]", messages.size(), diff --git a/plugins/vtd/src/rdb_transceiver_tcp.hpp b/plugins/vtd/src/rdb_transceiver_tcp.hpp index 32a212734..92e8e989c 100644 --- a/plugins/vtd/src/rdb_transceiver_tcp.hpp +++ b/plugins/vtd/src/rdb_transceiver_tcp.hpp @@ -35,6 +35,8 @@ #include "rdb_transceiver.hpp" // for RdbTransceiver #include "vtd_logger.hpp" // for rdb_logger +#define VTD_RDB_WAIT_SLEEP_MS 1 + namespace vtd { /** @@ -50,6 +52,9 @@ class RdbTransceiverTcp : public RdbTransceiver, public cloe::utility::TcpTransc std::vector> receive() override { std::vector> msgs; + while (!this->has()) { + std::this_thread::sleep_for(cloe::Milliseconds{VTD_RDB_WAIT_SLEEP_MS}); + } while (this->has()) { num_received_++; msgs.push_back(this->receive_wait()); diff --git a/plugins/vtd/src/scp_messages.cpp b/plugins/vtd/src/scp_messages.cpp index d2a795014..4fd479b91 100644 --- a/plugins/vtd/src/scp_messages.cpp +++ b/plugins/vtd/src/scp_messages.cpp @@ -34,6 +34,9 @@ const char* Pause = ""; const char* Restart = ""; const char* Apply = ""; const char* Config = ""; +const char* QueryInit = ""; +const char* AckInit = ""; +const char* InitOperation = ""; std::string ParamServerConfig::to_scp() const { std::string tc_config = fmt::format(R"SCP( @@ -64,12 +67,11 @@ std::string ParamServerConfig::to_scp() const { )SCP", tc_config); } -std::string ScenarioStartConfig::to_scp() const { +std::string ScenarioConfig::to_scp() const { return fmt::format(R"SCP( - )SCP", filename); } @@ -153,6 +155,15 @@ std::string SensorConfiguration::to_scp() const { )SCP", sensor_id, port, player_id); } +std::string DynamicsPluginConfig::to_scp() const { + return fmt::format(R"SCP( + + + + + )SCP", name); +} + std::string LabelVehicle::to_scp() const { return fmt::format(R"SCP( @@ -169,6 +180,13 @@ std::string RecordDat::to_scp() const { )SCP", datfile_path.parent_path().string(), datfile_path.filename().string()); } +std::string QueryScenario::to_scp() const { + return fmt::format(R"SCP( + + + )SCP", scenario); +} + // clang-format on } // namespace scp } // namespace vtd diff --git a/plugins/vtd/src/scp_messages.hpp b/plugins/vtd/src/scp_messages.hpp index 4e6a5270f..6f796b6f1 100644 --- a/plugins/vtd/src/scp_messages.hpp +++ b/plugins/vtd/src/scp_messages.hpp @@ -40,6 +40,9 @@ extern const char* Pause; extern const char* Restart; extern const char* Apply; extern const char* Config; +extern const char* QueryInit; +extern const char* AckInit; +extern const char* InitOperation; struct ParamServerConfig : public ScpMessage { std::string sync_source = "RDB"; @@ -47,7 +50,7 @@ struct ParamServerConfig : public ScpMessage { std::string to_scp() const override; }; -struct ScenarioStartConfig : public ScpMessage { +struct ScenarioConfig : public ScpMessage { std::string filename; std::string to_scp() const override; }; @@ -66,6 +69,11 @@ struct SensorConfiguration : public ScpMessage { std::string to_scp() const override; }; +struct DynamicsPluginConfig : public ScpMessage { + std::string name; + std::string to_scp() const override; +}; + struct LabelVehicle : public ScpMessage { std::string tethered_to_player; std::string text; @@ -85,5 +93,10 @@ struct RecordDat : public ScpMessage { std::string to_scp() const override; }; +struct QueryScenario : public ScpMessage { + std::string scenario; + std::string to_scp() const override; +}; + } // namespace scp } // namespace vtd diff --git a/plugins/vtd/src/vtd_binding.cpp b/plugins/vtd/src/vtd_binding.cpp index db68d8db2..2c5db8bde 100644 --- a/plugins/vtd/src/vtd_binding.cpp +++ b/plugins/vtd/src/vtd_binding.cpp @@ -22,11 +22,14 @@ #include // for istringstream #include // for unique_ptr<>, shared_ptr<> #include // for accumulate +#include // for set<> #include // for string #include // for this_thread::sleep_for #include // for move #include // for vector<> +#include // for fmt::format +#include // for path #include // for optional<> #include // for ptree #include // for read_xml @@ -42,33 +45,35 @@ #include // for Vehicle #include "rdb_transceiver_tcp.hpp" // for RdbTransceiverTcpFactory -#include "scp_messages.hpp" // for ScenarioStartConfig +#include "scp_messages.hpp" // for ScenarioConfig #include "scp_transceiver.hpp" // for ScpTransceiver #include "task_control.hpp" // for TaskControl #include "vtd_conf.hpp" // for VtdConfiguration #include "vtd_vehicle.hpp" // for VtdVehicle +namespace fs = boost::filesystem; + namespace vtd { /** - * The VtdStatistics struct contains all nominal statistics of the VTD binding. - */ + * The VtdStatistics struct contains all nominal statistics of the VTD binding. + */ struct VtdStatistics { cloe::utility::Accumulator frame_time_ms; cloe::utility::Accumulator clock_drift_ns; /** - * Writes the JSON representation into j. - * - * # JSON Output - * ```json - * { - * {"connection_tries": number}, - * {"frame_time_ms", Accumulator}, - * {"clock_drift_ns", Accumulator} - * } - * ``` - */ + * Writes the JSON representation into j. + * + * # JSON Output + * ```json + * { + * {"connection_tries": number}, + * {"frame_time_ms", Accumulator}, + * {"clock_drift_ns", Accumulator} + * } + * ``` + */ friend void to_json(cloe::Json& j, const VtdStatistics& s) { j = cloe::Json{ {"frame_time_ms", s.frame_time_ms}, @@ -124,7 +129,7 @@ class VtdBinding : public cloe::Simulator { } logger()->info("Connected."); - assert(is_operational()); + assert(operational_); } void disconnect() final { @@ -160,20 +165,23 @@ class VtdBinding : public cloe::Simulator { throw cloe::ModelError("cannot connect to VTD"); } - // Try to configure VTD. + // Try to configure VTD and start a simulation. { - // FIXME(henning): Replace sleep with waiting for a proper handshake. - // That requires a reliable signal from VTD that acknowledges Apply - // being successful and done. Currently this is only indicated by - // debug level info messages, containing taskcontrol and modulemanager - // state indication. Debug messages are not reliable as they might be - // configured away. Second indication available is Messages from the - // ImageGenerator. These are not available in headless mode. - // So currently sleeping is the only available workaround. + // Ensure VTD configure mode (required by param server config) // - // NOTE: This workaround might fail in situations with heavy load when - // 'apply' takes much longer than usual. + // Note: If the simulation is already in apply-mode, the following config + // command will reset the SCP connection. This will cause an exception + // and we will re-try. As VTD is now in the config state, the next try + // will work because the config command has no effect in config state, so + // it's not shutting down the SCP connection. scp_client_->send(scp::Config); + + // Configure VTD parameters after sleeping a while + // + // Note: There's no way to be sure we're in configure state, so we need + // to give VTD some time for state switching. Be aware that this could + // result in a race condition and thus indeterministically fail depending + // on the amount of time we sleep and system performance and load! scp::ParamServerConfig pc; pc.sync_source = "RDB"; pc.no_image_generator = (!config_.image_generator || config_.setup == "Cloe.noGUInoIG" || @@ -186,55 +194,73 @@ class VtdBinding : public cloe::Simulator { sleep_awhile(); paramserver_client_->send(pc); + + // Apply the configuration scp_client_->send(scp::Apply); - if (config_.dat_file.size()) { - logger()->info("Recording data file: {}", config_.dat_file); - sleep_awhile(); - scp::RecordDat recdat; - recdat.datfile_path = config_.dat_file; - scp_client_->send(recdat); + // Lock initialization so VTD waits with the Run state until we're ready + scp_client_->send(scp::QueryInit); + + // Wait for creation of TaskControl client + logger()->info("Wait for task control..."); + // Expect task_control_ to be initialized in VtdBinding::apply_scp_rdb + scp_try_read_until([this]() { return task_control_ != nullptr; }); + + // Wait for selection of scenario (by GUI if not configured) + if (config_.scenario == "") { + logger()->info("Wait for scenario..."); + // Expect the scenario to be initialized in VtdBinding::apply_scenario_filename() + scp_try_read_until([this]() { return config_.scenario != ""; }); + // Stop to neutralize GUI's Init command sent along with LoadScenario + scp_client_->send(scp::Stop); } - if (config_.scenario.size()) { + // Get agents from scenario (works only before LoadScenario!) + scp::QueryScenario query; + query.scenario = config_.scenario; + scp_client_->send(query); + // Expect the agents_expected_ array to be initialized in + // VtdBinding::apply_scp_scenario_response() + scp_try_read_until([this]() { return !agents_expected_.empty(); }); + + // Load the scenario + if (config_.scenario != "") { logger()->info("Starting scenario: {}", config_.scenario); sleep_awhile(); - scp::ScenarioStartConfig vtd_scenario; + scp::ScenarioConfig vtd_scenario; vtd_scenario.filename = config_.scenario; scp_client_->send(vtd_scenario); } - } - { - logger()->info("Wait for scenario..."); - int tries_left = VTD_INIT_WAIT_RETRIES; - - while (!is_operational() || !task_control_) { - // Expect following to happen: - // 1. TaskControl client created - // 2. Vehicles created and connected - // 3. Simulator starts running - - // FIXME(ben): This could fail with a runtime error: - // - // terminate called after throwing an instance of 'std::runtime_error' - // what(): ScpTransceiver: error during read: Broken pipe - // - // In this case, we probably need to reconnect to SCP and try again. - this->readall_scp(); - std::this_thread::sleep_for(cloe::Milliseconds{VTD_INIT_WAIT_SLEEP_MS}); - - // TODO(henning): Every scp message that is not ending the while loop - // is a missed try. Just assume VTD or anyone connected - // to the SCP channel to write 300 individual messages - // and this will fail. So we need to use a true timeout - // here (or elsewhere as pointed out above). - if (!tries_left--) { - throw cloe::ModelError("timeout waiting for SCP message"); - } + // Start dat file recording + if (config_.dat_file.size()) { + logger()->info("Recording data file: {}", config_.dat_file); + sleep_awhile(); + scp::RecordDat recdat; + recdat.datfile_path = config_.dat_file; + scp_client_->send(recdat); } - logger()->info("Scenario loaded."); + // Send init command + scp_client_->send(scp::InitOperation); + + // Wait for all agents' initialization + // Expect vehicles_ to be initialized in VtdBinding::apply_scp_set() + scp_try_read_until([this]() { return agents_expected_.size() == vehicles_.size(); }); + + // Start the simulation + scp_client_->send(scp::Start); + + // Release the init lock so VTD can proceed to run state + scp_client_->send(scp::AckInit); + + // Continue reading until VTD is running + // Expect init_done_ to be set to true in VtdBinding::apply_scp_init_done() + scp_try_read_until([this]() { return init_done_; }); + // Expect the operational_ flag to be set to true in VtdBinding::apply_scp_run() + scp_try_read_until([this]() { return operational_; }); + + logger()->info("VTD Started."); } if (num_vehicles() == 0) { @@ -289,11 +315,12 @@ class VtdBinding : public cloe::Simulator { * scenario will start over from 0. */ void reset() final { + operational_ = false; + // send a restart to VTD as reset request didn't come from VTD scp_client_->send(scp::Restart); // If in reset, block until VTD sends "Run" again then start next cycle - operational_ = false; while (!operational_) { readall_scp(); } @@ -318,7 +345,7 @@ class VtdBinding : public cloe::Simulator { void start(const cloe::Sync&) final { // operational_ is set by connect() not start(). - assert(is_operational()); + assert(operational_); } /** @@ -334,7 +361,7 @@ class VtdBinding : public cloe::Simulator { // Preconditions: assert(task_control_); assert(is_connected()); - assert(is_operational()); + assert(operational_); // Statistics: timer::DurationTimer t( @@ -369,10 +396,7 @@ class VtdBinding : public cloe::Simulator { return sync.time(); } - void stop(const cloe::Sync&) final { - scp_client_->send(scp::Stop); - scp_client_->send(scp::Config); - } + void stop(const cloe::Sync&) final { scp_client_->send(scp::Stop); } protected: std::shared_ptr get_vehicle_by_id(uint64_t id) const { @@ -438,6 +462,22 @@ class VtdBinding : public cloe::Simulator { } } + /* + * Waits for a predicate to become true while processing SCP input. + * + * Waiting is limited to a number of retries until timeout. + */ + void scp_try_read_until(std::function pred) { + int tries_left = config_.connection.retry_attempts; + while (!pred()) { + this->readall_scp(); + std::this_thread::sleep_for(cloe::Milliseconds{VTD_INIT_WAIT_SLEEP_MS}); + if (!tries_left--) { + throw cloe::ModelError("timeout waiting for SCP message"); + } + } + } + /** * Parse selected VTD SCP messages and call the relevant apply methods. */ @@ -446,17 +486,22 @@ class VtdBinding : public cloe::Simulator { std::istringstream is(scp_message); boost::property_tree::read_xml(is, pt); boost::optional child; - // TODO(ben): Incorrect, we need to iterate over all children. There could be multiple! if ((child = pt.get_child_optional("TaskControl.RDB"))) { this->apply_scp_rdb(*child); } else if ((child = pt.get_child_optional("Set"))) { this->apply_scp_set(*child); + } else if ((child = pt.get_child_optional("SimCtrl.InitDone"))) { + this->apply_scp_init_done(*child); } else if ((child = pt.get_child_optional("SimCtrl.Run"))) { this->apply_scp_run(*child); } else if ((child = pt.get_child_optional("SimCtrl.Stop"))) { this->apply_scp_stop(*child); } else if ((child = pt.get_child_optional("SimCtrl.Restart"))) { this->apply_scp_restart(*child); + } else if ((child = pt.get_child_optional("SimCtrl.LoadScenario"))) { + this->apply_scenario_filename(*child); + } else if ((child = pt.get_child_optional("Reply.GetScenario"))) { + this->apply_scp_scenario_response(*child); } } @@ -514,6 +559,13 @@ class VtdBinding : public cloe::Simulator { vehicles_.push_back(veh); } + void apply_scp_init_done(boost::property_tree::ptree& xml) { + auto place = xml.get(".place", "default"); + if (place == "checkInitConfirmation") { + init_done_ = true; + } + } + void apply_scp_run(boost::property_tree::ptree&) { operational_ = true; } void apply_scp_restart(boost::property_tree::ptree&) { @@ -535,6 +587,60 @@ class VtdBinding : public cloe::Simulator { void apply_scp_stop(boost::property_tree::ptree&) { operational_ = false; } + void apply_scenario_filename(boost::property_tree::ptree& xml) { + auto scenario = xml.get(".filename", "none"); + scenario = relative_scenario_path(fs::path(scenario)).generic_string(); + if (config_.scenario != "" && config_.scenario != scenario) { + throw cloe::ModelError( + fmt::format("Loaded scenario {} doesn't match the configured scenario {}", scenario, + config_.scenario)); + } + // configure scenario in case it's selected/loaded externally to Cloe (e.g. VTD-GUI) + config_.scenario = scenario; + } + + void apply_scp_scenario_response(boost::property_tree::ptree& xml) { + auto trafficcontrol = xml.get_child("Scenario").get_child("TrafficControl"); + for (auto& it : trafficcontrol) { + if (it.first != "Player") continue; + auto p = it.second; + std::string control = p.get("Description..Control", "default"); + if (control == "external") { + auto name = p.get("Description..Name", "unspecified"); + agents_expected_.insert(name); + + // Ask VTD to create a vehicle dynamics instance for this vehicle + scp::DynamicsPluginConfig cfg; + cfg.name = name; + scp_client_->send(cfg); + } + } + } + + fs::path relative_scenario_path(const fs::path& p) { + if (p.is_absolute()) { + // make relative to first subdirectory called "Scenarios" + auto s = std::find(p.begin(), p.end(), "Scenarios"); + if (s == p.end()) { + throw cloe::ModelError( + fmt::format("Can't derive VTD Scenario directory from path: {}", p.generic_string())); + } + fs::path r; + for (auto it = ++s; it != p.end(); ++it) { + r /= *it; + } + if (std::find(s, p.end(), "Scenarios") != p.end()) { + logger()->warn( + "Cannot determine the scenario directory unambiguously because " + "the chosen scenario path contains multiple 'Scenario/' elements: " + "{}", + p.native()); + } + return r; + } + return p; + } + private: VtdConfiguration config_{}; @@ -543,6 +649,16 @@ class VtdBinding : public cloe::Simulator { */ VtdVehicleFactory vehicle_factory_; + /** + * Inidcate whether VTD is done initializing. + */ + bool init_done_{false}; + + /** + * Expected agents' names due to queried scenario. + */ + std::set agents_expected_{}; + /** * SCP client for configuring the parameter server * @@ -595,7 +711,7 @@ class VtdBinding : public cloe::Simulator { {"vehicles", b.vehicles_}, }; } -}; +}; // namespace vtd DEFINE_SIMULATOR_FACTORY(VtdFactory, VtdConfiguration, "vtd", "VIRES Virtual Test Drive") DEFINE_SIMULATOR_FACTORY_MAKE(VtdFactory, VtdBinding) diff --git a/plugins/vtd/src/vtd_conf.hpp b/plugins/vtd/src/vtd_conf.hpp index 2289b94b1..a7a3a8f76 100644 --- a/plugins/vtd/src/vtd_conf.hpp +++ b/plugins/vtd/src/vtd_conf.hpp @@ -30,14 +30,11 @@ #include // for TcpTransceiverConfiguration, ... #include "osi_omni_sensor.hpp" // for SensorMockLevel -// Connection +// Connection / Initialization #define VTD_DEFAULT_SCP_PORT 48179 #define VTD_PARAMSERVER_PORT 54345 #define VTD_INIT_SYNC_SLEEP_MS 3000 - -// Scenario Loading #define VTD_INIT_WAIT_SLEEP_MS 200 -#define VTD_INIT_WAIT_RETRIES 300 namespace vtd { @@ -279,7 +276,7 @@ struct VtdConfiguration : public cloe::Confable { {"rdb_params", Schema(&rdb_params, "rdb connection parameters")}, {"sensor_initial_port", Schema(&sensor_initial_port, "initial port for sensor communication")}, {"vehicles", Schema(&vehicles, "vehicle configuration like sensors and component mapping")}, - {"configuration_rety_attempts", Schema(&configuration_retry_attempts, "attempts to retry connection on broken pipe")}, + {"configuration_retry_attempts", Schema(&configuration_retry_attempts, "attempts to retry connection on broken pipe")}, {"setup", Schema(&setup, "indicate which setup you are using")}, {"image_generator", Schema(&image_generator, "switch whether VTD should use image generator")}, {"scenario", Schema(&scenario, "VTD scenario to use (project must already be loaded)")}, diff --git a/plugins/vtd/src/vtd_vehicle.hpp b/plugins/vtd/src/vtd_vehicle.hpp index 54a47a720..c45aca891 100644 --- a/plugins/vtd/src/vtd_vehicle.hpp +++ b/plugins/vtd/src/vtd_vehicle.hpp @@ -28,8 +28,8 @@ #include // for this_thread #include // for ObjectSensorFilter -#include // for CloeComponent #include // for Duration +#include // for CloeComponent #include // for Sync #include // for inja #include // for TcpTransceiverConfiguration @@ -332,9 +332,7 @@ class VtdVehicleFactory { veh->sensors_[name] = osi; break; } - default: { - throw cloe::Error("VtdVehicle: unknown sensor protocol"); - } + default: { throw cloe::Error("VtdVehicle: unknown sensor protocol"); } } } veh->configure_components(vcfg.components); diff --git a/tests/config_multi_agent_smoketest.json b/tests/config_multi_agent_smoketest.json index dc0c4d6cd..75a728322 100644 --- a/tests/config_multi_agent_smoketest.json +++ b/tests/config_multi_agent_smoketest.json @@ -6,7 +6,7 @@ }, "triggers": [ {"event": "virtue_first/failure", "action": "fail"}, - {"event": "start", "action": "log=info: Running multi-agent minimator smoketest."}, + {"event": "start", "action": "log=info: Running multi-agent smoketest."}, {"event": "start", "action": "realtime_factor=-1"}, { "event": "start", diff --git a/tests/test_engine_json_schema_with_vtd.json b/tests/test_engine_json_schema_with_vtd.json index 6ac787c0b..e40b1bb29 100644 --- a/tests/test_engine_json_schema_with_vtd.json +++ b/tests/test_engine_json_schema_with_vtd.json @@ -1116,7 +1116,7 @@ }, "type": "object" }, - "configuration_rety_attempts": { + "configuration_retry_attempts": { "description": "attempts to retry connection on broken pipe", "maximum": 65535, "minimum": 0, diff --git a/tests/test_vtd.bats b/tests/test_vtd.bats index 8ef1a3656..98bc72e61 100755 --- a/tests/test_vtd.bats +++ b/tests/test_vtd.bats @@ -30,7 +30,7 @@ teardown() { @test "Expect check/run success: test_vtd_watchdog.json" { if ! test_vtd_plugin_exists; then - skip "required simulator VTD not present" + skip "required simulator vtd not present" fi if ! type killall &>/dev/null; then skip "required program killall not present" diff --git a/tests/test_vtd_multi_agent_smoketest.json b/tests/test_vtd_multi_agent_smoketest.json index 7f1defffe..429a52870 100644 --- a/tests/test_vtd_multi_agent_smoketest.json +++ b/tests/test_vtd_multi_agent_smoketest.json @@ -7,18 +7,18 @@ ], "triggers": [ { - "label": "Vehicle first should not reach 100 km/h with the vtd binding and basic controller.", - "event": "first_speed/kmph=>100.0", "action": "fail" + "label": "Vehicle first should not reach 50 km/h with the vtd binding and basic controller.", + "event": "first_speed/kmph=>50.0", "action": "fail" }, {"event": "virtue_second/failure", "action": "fail"}, { - "label": "Vehicle second should not reach 100 km/h with the vtd binding and basic controller.", - "event": "second_speed/kmph=>100.0", "action": "fail" + "label": "Vehicle second should not reach 50 km/h with the vtd binding and basic controller.", + "event": "second_speed/kmph=>50.0", "action": "fail" }, {"event": "virtue_third/failure", "action": "fail"}, { - "label": "Vehicle third should not reach 100 km/h with the vtd binding and basic controller.", - "event": "third_speed/kmph=>100.0", "action": "fail" + "label": "Vehicle third should not reach 50 km/h with the vtd binding and basic controller.", + "event": "third_speed/kmph=>50.0", "action": "fail" } ] }