From 1ecf24ae9b52e6dd7ce775bf05ffb547856bd3f1 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia <65171491+MarcoLm993@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:51:39 +0100 Subject: [PATCH] Introduce blackboard support (#71) * Implemented BT blackboard support for input and output ports * Introduced Math.random() function support for assignments * Implemented new grid_world test for comparison with SOTA --------- Signed-off-by: Marco Lampacrescia Signed-off-by: Christian Henkel --- .../graphics/blackboard_to_scxml.drawio.svg | 107 +++++++++++++ docs/source/scxml-jani-conversion.rst | 111 ++++++++------ .../grid_robot_interfaces/CMakeLists.txt | 42 ++++++ .../grid_robot_interfaces/README.md | 1 + .../grid_robot_interfaces/msg/Int2D.msg | 2 + .../grid_robot_interfaces/package.xml | 22 +++ .../as2fm_common/ecmascript_interpretation.py | 5 +- .../jani_generator/jani_entries/__init__.py | 7 +- .../jani_entries/jani_assignment.py | 18 ++- .../jani_entries/jani_automaton.py | 6 + .../jani_convince_expression_expansion.py | 64 +++++++- .../jani_generator/jani_entries/jani_edge.py | 8 +- .../jani_entries/jani_expression.py | 131 ++++++++++++---- .../jani_entries/jani_expression_generator.py | 12 +- .../jani_entries/jani_helpers.py | 125 ++++++++++++++++ .../scxml_helpers/scxml_tags.py | 67 ++++++--- .../scxml_helpers/scxml_to_jani.py | 2 + .../scxml_helpers/top_level_interpreter.py | 10 +- src/as2fm/scxml_converter/bt_converter.py | 73 +++++++++ .../scxml_converter/scxml_entries/__init__.py | 5 +- .../scxml_converter/scxml_entries/bt_utils.py | 79 ++++++++-- .../scxml_entries/scxml_bt_in_port.py | 58 +++++++ .../scxml_entries/scxml_bt_out_port.py | 89 +++++++++++ ..._ports.py => scxml_bt_port_declaration.py} | 36 +---- .../scxml_entries/scxml_bt_ticks.py | 8 + .../scxml_entries/scxml_data.py | 14 +- .../scxml_entries/scxml_executable_entries.py | 70 ++++++++- .../scxml_entries/scxml_param.py | 19 ++- .../scxml_entries/scxml_root.py | 28 +++- .../scxml_entries/scxml_ros_action_client.py | 8 + .../scxml_entries/scxml_ros_base.py | 17 ++- .../scxml_entries/scxml_ros_field.py | 6 - .../scxml_entries/scxml_state.py | 66 +++++++- .../scxml_entries/scxml_transition.py | 12 ++ test/as2fm_common/test_utilities_smc_storm.py | 2 +- .../battery_example/output_GROUND_TRUTH.jani | 21 +-- .../_test_data/blackboard_test/bt.xml | 9 ++ .../blackboard_test/bt_read_bb.scxml | 43 ++++++ .../blackboard_test/bt_write_bb.scxml | 23 +++ .../_test_data/blackboard_test/main.xml | 17 +++ .../blackboard_test/properties.jani | 26 ++++ .../_test_data/grid_robot_blackboard/bt.xml | 33 ++++ .../grid_robot_blackboard/bt_move.scxml | 38 +++++ .../grid_robot_blackboard/bt_shall_move.scxml | 76 ++++++++++ .../bt_update_goal_and_current_position.scxml | 64 ++++++++ .../_test_data/grid_robot_blackboard/main.xml | 23 +++ .../grid_robot_blackboard/properties.jani | 59 ++++++++ .../grid_robot_blackboard/world.scxml | 69 +++++++++ .../grid_robot_blackboard_simple/bt.xml | 35 +++++ .../bt_move.scxml | 56 +++++++ .../bt_shall_move.scxml | 76 ++++++++++ .../bt_update_goal_and_current_position.scxml | 77 ++++++++++ .../grid_robot_blackboard_simple/main.xml | 22 +++ .../properties.jani | 26 ++++ .../uc1_docking/bt_publish_empty_msg.scxml | 25 ---- .../_test_data/uc1_docking/main.xml | 1 - .../uc1_docking/main_with_problem.xml | 1 - .../_test_data/uc1_docking/policy.xml | 35 ++--- .../_test_data/uc1_docking/properties.jani | 7 +- .../test_systemtest_scxml_to_jani.py | 32 +++- .../test_unittest_expression_expansion.py | 141 ++++++++++++++++++ 61 files changed, 2127 insertions(+), 238 deletions(-) create mode 100644 docs/source/graphics/blackboard_to_scxml.drawio.svg create mode 100644 ros_support_interfaces/grid_robot_interfaces/CMakeLists.txt create mode 100644 ros_support_interfaces/grid_robot_interfaces/README.md create mode 100644 ros_support_interfaces/grid_robot_interfaces/msg/Int2D.msg create mode 100644 ros_support_interfaces/grid_robot_interfaces/package.xml create mode 100644 src/as2fm/jani_generator/jani_entries/jani_helpers.py create mode 100644 src/as2fm/scxml_converter/scxml_entries/scxml_bt_in_port.py create mode 100644 src/as2fm/scxml_converter/scxml_entries/scxml_bt_out_port.py rename src/as2fm/scxml_converter/scxml_entries/{scxml_bt_ports.py => scxml_bt_port_declaration.py} (76%) create mode 100644 test/jani_generator/_test_data/blackboard_test/bt.xml create mode 100644 test/jani_generator/_test_data/blackboard_test/bt_read_bb.scxml create mode 100644 test/jani_generator/_test_data/blackboard_test/bt_write_bb.scxml create mode 100644 test/jani_generator/_test_data/blackboard_test/main.xml create mode 100644 test/jani_generator/_test_data/blackboard_test/properties.jani create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/bt.xml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/bt_move.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/bt_shall_move.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/bt_update_goal_and_current_position.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/main.xml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/properties.jani create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard/world.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard_simple/bt.xml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_move.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_shall_move.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_update_goal_and_current_position.scxml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard_simple/main.xml create mode 100644 test/jani_generator/_test_data/grid_robot_blackboard_simple/properties.jani delete mode 100644 test/jani_generator/_test_data/uc1_docking/bt_publish_empty_msg.scxml create mode 100644 test/jani_generator/test_unittest_expression_expansion.py diff --git a/docs/source/graphics/blackboard_to_scxml.drawio.svg b/docs/source/graphics/blackboard_to_scxml.drawio.svg new file mode 100644 index 00000000..4b31c576 --- /dev/null +++ b/docs/source/graphics/blackboard_to_scxml.drawio.svg @@ -0,0 +1,107 @@ + + + + + + + +
+
+
+ idle +
+
+
+
+ + idle + +
+
+ + + + + + + +
+
+
+ bt_blackboard_set_<bb_var_x> +
+ * assign bb_var_x = _event.data.value +
+
+
+
+ + bt_blackboard_set_<bb_var_x>... + +
+
+ + + + + +
+
+
+ bt_blackboard_req +
+ * send bt_blackboard_get +
+ * field bb_var_1 = bb_var_1 +
+ * .... +
+ * field bb_var_n = bb_var_n +
+
+
+
+ + bt_blackboard_req... + +
+
+ + + + +
+
+
+ + + datamodel + +
+
+ * bb_var_1 +
+ * bb_var_2 +
+ ... +
+ * bb_var_n +
+
+
+
+ + datamodel... + +
+
+
+ + + + + Text is not SVG - cannot display + + + +
diff --git a/docs/source/scxml-jani-conversion.rst b/docs/source/scxml-jani-conversion.rst index b417000c..e5786494 100644 --- a/docs/source/scxml-jani-conversion.rst +++ b/docs/source/scxml-jani-conversion.rst @@ -6,18 +6,18 @@ SCXML and JANI In CONVINCE, we expect developers to use Behavior Trees and SCXML to model the different parts of a robotic systems. -SCXML (Scope XML) is a high level format that describes a single state machine, and allows it to exchange information with other state machines using events. Each SCXML file defines its variables (datamodel), states, and transitions. +SCXML (State Chart XML) is an XML format that describes a single state machine, and allows it to exchange information with other SCXML state machines using events. Each SCXML file defines its variables (datamodel), states, and transitions. -With SCXML, the system consists of a set of state machines, each one represented by an SCXML file, which are synchronized together using events. Operations are carried out when the execution of a state machine receives an event, enters a state, or exits a state. +Using SCXML, the system can be modeled as a set of state machines, each one represented by an SCXML file, which are synchronized together using events. Operations are carried out when the execution of a state machine receives an event, enters a state, or exits a state. -With JANI, the whole system model is contained in a single JSON file, consisting of a set of global variables, automata (equivalent to state machines) with their edges (equivalent to transitions), and a composition description, specifying how the automata should be synchronized by advancing specific edges at the same time synchronously. +Using AS2FM, we can convert the model described using SCXML to JANI, that is a JSON-based format for describing a system as a formal model. With JANI, the whole system model is contained in a single JSON file, consisting of a set of global variables, automata (equivalent to state machines) with their edges (equivalent to transitions), and a composition description, specifying how the automata should be synchronized by advancing specific edges at the same time synchronously. The main difference between SCXML and JANI is that in JANI there is no concept of events, so synchronization must be achieved using the global variables and composition description. High-Level (ROS) SCXML Implementation --------------------------------------- -In CONVINCE, we extended the standard SCXML format defined `here `_ with ROS specific features, to make it easier for ROS developers to model ROS-based systems. +In CONVINCE, we extended the standard SCXML format defined `here `_ with ROS and Behavior Tree (BT) specific features, to make it easier for robot developers to model their systems using both ROS and BT. In this guide we will refer to the extended SCXML format as high-level SCXML and to the standard SCXML format as low-level SCXML. @@ -25,22 +25,80 @@ Currently, the supported ROS-features are: * ROS Topics * ROS Timers (Rate-callbacks) - -TODO: Example of Topic and Timer declaration + usage. +* ROS Service +* ROS Actions +* BT Ticks +* BT Responses +* BT Ports +* BT Blackboard Low-Level SCXML Conversion ---------------------------- Low-Level SCXML is the standard SCXML format defined `here `_. -Our converter is able to transform high-level SCXML to low-level SCXML by translating the ROS specific features to standard SCXML features. -In case of timers, we need additional information that cannot be encoded in SCXML, such that information is generated at runtime. +Our converter is able to transform High-Level (HL) SCXML to Low-Level (LL) SCXML by translating ROS and BT specific features to standard SCXML features. +This applies also for timers: we generate an additional SCXML FSM encoding a global clock, sending out events to trigger the timers defined in the model at the correct rate. + +The next subsections describe our conversion strategy from HL-SCXML to LL-SCXML. +The entry-point for the conversion is implemented in ScxmlRoot.as_plain_scxml(). TODO: Link to API. + +Handling of (ROS) Timers +__________________________ + +TODO + +Handling of (ROS) Services +_____________________________ + +ROS services, as well as ROS topics, can be handled directly in the conversion from HL-SCXML to LL-SCXML. + +The main structure of the SCXML related state machines can be inspected in the diagram below: + +.. image:: graphics/ros_service_to_scxml.drawio.svg + :alt: Handling of ROS Services + :align: center + +The automata of clients and services are converted directly from the existing ROS-SCXML files, while the "Extra Service Handler" is autogenerated starting from the provided clients' and services' declarations. + + +Handling of (ROS) Actions +_____________________________ -The conversion between the two SCXML formats is implemented in ScxmlRoot.as_plain_scxml(). TODO: Link to API. +ROS actions are handled similarly to ROS Services: a HL-SCXML description of the system is converted to LL-SCXML, and an additional automaton is generated to handle the synchronization between the clients and the server. -TODO: Describe how we translate the high-level SCXML to the low-level SCXML. +The structure of a client-server communication through actions and additional threads looks as follows: + +.. image:: graphics/ros_action_to_scxml.drawio.svg + :alt: Handling of ROS Actions + :align: center + + +Handling the BT Blackboard +_____________________________ + +The Blackboard is a container that shares variables across different BT plugins. The value of those variables normally changes over time, and is expected to be updated at each tick. + +In LL-SCXML, this is handled by an autogenerated SCXML FSM, that receives the updates from the various plugins and, upon request, provides the data. +This diagram summarizes the FSM structure. + +.. image:: graphics/blackboard_to_scxml.drawio.svg + :alt: Blackboard FSM + :align: center -TODO: Timers are useful for SCAN as well: instead of keeping them in a runtime object, we can consider to list them in an intermediary XML file. + +Setting Blackboard Variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This can be done using the tag `bt_set_output` in HL-SCXML. In LL-SCXML this translates to a send event, that is received by the Blackboard FSM to update the internal data. + +Reading Blackboard Variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +At the current state, to read the internal variables there needs to be some message exchange between the plugin FSM and the Blackboard FSM. +This needs to happen for each and every Blackboard variable that is read. + +Optimization is nevertheless possible (sending the whole set of blackboard variables each time), but this would diverge from the Blackboard.CPP implementation, so it should rather be an automatic conversion. JANI Conversion ---------------- @@ -114,34 +172,3 @@ The JANI model resulting from applying the conversion strategies we just describ :align: center It can be seen how new self loop edges are added in the `A_B_receiver` automaton (the dashed ones) and how the `ev_a_on_send` is now duplicated in the composition table, one advancing the `A sender` automaton and the other one advancing the `A_B sender` automaton. - - -Handling of (ROS) Timers -__________________________ - -TODO - -Handling of (ROS) Services -_____________________________ - -ROS services, as well as ROS topics, can be handled directly in the ROS to plain SCXML conversion, without the need of adding JANI-specific features, as for the ROS timers. - -The main structure of the SCXML related state machines can be inspected in the diagram below: - -.. image:: graphics/ros_service_to_scxml.drawio.svg - :alt: Handling of ROS Services - :align: center - -The automata of clients and services are converted directly from the existing ROS-SCXML files, while the "Extra Service Handler" is autogenerated starting from the provided clients and services. - - -Handling of (ROS) Actions -_____________________________ - -ROS actions are handled similarly to ROS Services: a ROS-SCXML description of the system is converted to plain SCXML, and an additional automaton is generated to handle the synchronization between the clients and the server. - -The structure of a client-server communication through actions and additional threads looks as follows: - -.. image:: graphics/ros_action_to_scxml.drawio.svg - :alt: Handling of ROS Actions - :align: center diff --git a/ros_support_interfaces/grid_robot_interfaces/CMakeLists.txt b/ros_support_interfaces/grid_robot_interfaces/CMakeLists.txt new file mode 100644 index 00000000..cd9e906b --- /dev/null +++ b/ros_support_interfaces/grid_robot_interfaces/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.5) +project(grid_robot_interfaces) + +# Default to C99 +if(NOT CMAKE_C_STANDARD) + set(CMAKE_C_STANDARD 99) +endif() + +# Default to C++14 +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 14) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# find dependencies +find_package(ament_cmake REQUIRED) +# uncomment the following section in order to fill in +# further dependencies manually. +find_package(std_msgs REQUIRED) + +find_package(rosidl_default_generators REQUIRED) + +rosidl_generate_interfaces(${PROJECT_NAME} + "msg/Int2D.msg" + DEPENDENCIES std_msgs + ) + +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + # the following line skips the linter which checks for copyrights + # uncomment the line when a copyright and license is not present in all source files + #set(ament_cmake_copyright_FOUND TRUE) + # the following line skips cpplint (only works in a git repo) + # uncomment the line when this package is not in a git repo + #set(ament_cmake_cpplint_FOUND TRUE) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/ros_support_interfaces/grid_robot_interfaces/README.md b/ros_support_interfaces/grid_robot_interfaces/README.md new file mode 100644 index 00000000..b4a6a3b7 --- /dev/null +++ b/ros_support_interfaces/grid_robot_interfaces/README.md @@ -0,0 +1 @@ +Used in `test/jani_generator/_test_data/grid_robot_blackboard` diff --git a/ros_support_interfaces/grid_robot_interfaces/msg/Int2D.msg b/ros_support_interfaces/grid_robot_interfaces/msg/Int2D.msg new file mode 100644 index 00000000..10fb937a --- /dev/null +++ b/ros_support_interfaces/grid_robot_interfaces/msg/Int2D.msg @@ -0,0 +1,2 @@ +int32 x +int32 y diff --git a/ros_support_interfaces/grid_robot_interfaces/package.xml b/ros_support_interfaces/grid_robot_interfaces/package.xml new file mode 100644 index 00000000..3f8a6782 --- /dev/null +++ b/ros_support_interfaces/grid_robot_interfaces/package.xml @@ -0,0 +1,22 @@ + + + + grid_robot_interfaces + 0.0.0 + TODO: Package description + root + TODO: License declaration + + ament_cmake + std_msgs + ament_lint_auto + ament_lint_common + + rosidl_default_generators + rosidl_default_runtime + rosidl_interface_packages + + + ament_cmake + + diff --git a/src/as2fm/as2fm_common/ecmascript_interpretation.py b/src/as2fm/as2fm_common/ecmascript_interpretation.py index 1437f3db..c0e963f7 100644 --- a/src/as2fm/as2fm_common/ecmascript_interpretation.py +++ b/src/as2fm/as2fm_common/ecmascript_interpretation.py @@ -45,7 +45,10 @@ def interpret_ecma_script_expr( msg_addition = "" if expr in ("True", "False"): msg_addition = "Did you mean to use 'true' or 'false' instead?" - raise RuntimeError(f"Failed to interpret JS expression: 'result = {expr}'. {msg_addition}") + raise RuntimeError( + f"Failed to interpret JS expression using variables {variables}: ", + f"'result = {expr}'. {msg_addition}", + ) expr_result = context.result if isinstance(expr_result, BasicJsTypes): return expr_result diff --git a/src/as2fm/jani_generator/jani_entries/__init__.py b/src/as2fm/jani_generator/jani_entries/__init__.py index a947bb31..04f109ae 100644 --- a/src/as2fm/jani_generator/jani_entries/__init__.py +++ b/src/as2fm/jani_generator/jani_entries/__init__.py @@ -1,7 +1,12 @@ # isort: skip_file # Skipping file to avoid circular import problem from .jani_value import JaniValue # noqa: F401 -from .jani_expression import JaniExpression, JaniExpressionType # noqa: F401 +from .jani_expression import ( # noqa: F401 + JaniExpression, + JaniExpressionType, + JaniDistribution, + generate_jani_expression, +) # noqa: F401 from .jani_constant import JaniConstant # noqa: F401 from .jani_variable import JaniVariable # noqa: F401 from .jani_assignment import JaniAssignment # noqa: F401 diff --git a/src/as2fm/jani_generator/jani_entries/jani_assignment.py b/src/as2fm/jani_generator/jani_entries/jani_assignment.py index f6afca24..4bd63998 100644 --- a/src/as2fm/jani_generator/jani_entries/jani_assignment.py +++ b/src/as2fm/jani_generator/jani_entries/jani_assignment.py @@ -19,7 +19,7 @@ from typing import Dict -from as2fm.jani_generator.jani_entries import JaniConstant, JaniExpression +from as2fm.jani_generator.jani_entries import JaniConstant, JaniExpression, generate_jani_expression from as2fm.jani_generator.jani_entries.jani_convince_expression_expansion import expand_expression @@ -30,12 +30,24 @@ class JaniAssignment: def __init__(self, assignment_dict: dict): """Initialize the assignment from a dictionary""" - self._var_name = JaniExpression(assignment_dict["ref"]) - self._value = JaniExpression(assignment_dict["value"]) + self._var_name = generate_jani_expression(assignment_dict["ref"]) + self._value: JaniExpression = generate_jani_expression(assignment_dict["value"]) self._index = 0 if "index" in assignment_dict: self._index = assignment_dict["index"] + def get_target(self): + """Return the variable storing the expression result.""" + return self._var_name + + def get_expression(self): + """Return the expression assigned to the target variable (or array entry)""" + return self._value + + def get_index(self) -> int: + """Returns the index, i.e. the number that defines the order of execution in Jani.""" + return self._index + def as_dict(self, constants: Dict[str, JaniConstant]): """Transform the assignment to a dictionary""" expanded_value = expand_expression(self._value, constants) diff --git a/src/as2fm/jani_generator/jani_entries/jani_automaton.py b/src/as2fm/jani_generator/jani_entries/jani_automaton.py index eb4563c0..ea8716eb 100644 --- a/src/as2fm/jani_generator/jani_entries/jani_automaton.py +++ b/src/as2fm/jani_generator/jani_entries/jani_automaton.py @@ -78,6 +78,12 @@ def add_edge(self, edge: JaniEdge): self._edge_id += 1 self._edges.append(edge) + def set_edges(self, new_edges: List[JaniEdge]) -> None: + """Replace the edges in the Automaton.""" + self._edges = [] + for edge in new_edges: + self.add_edge(edge) + def get_edges(self) -> List[JaniEdge]: return self._edges diff --git a/src/as2fm/jani_generator/jani_entries/jani_convince_expression_expansion.py b/src/as2fm/jani_generator/jani_entries/jani_convince_expression_expansion.py index 67a88fed..22a877e1 100644 --- a/src/as2fm/jani_generator/jani_entries/jani_convince_expression_expansion.py +++ b/src/as2fm/jani_generator/jani_entries/jani_convince_expression_expansion.py @@ -15,15 +15,22 @@ """Expand expressions into jani.""" +from copy import deepcopy from math import pi -from typing import Callable, Dict, Union +from typing import Callable, Dict, List -from as2fm.jani_generator.jani_entries import JaniConstant, JaniExpression, JaniValue +from as2fm.jani_generator.jani_entries import ( + JaniConstant, + JaniDistribution, + JaniExpression, + JaniExpressionType, +) from as2fm.jani_generator.jani_entries.jani_expression_generator import ( abs_operator, and_operator, ceil_operator, cos_operator, + distribution_expression, divide_operator, equal_operator, floor_operator, @@ -83,6 +90,11 @@ } +def random_operator() -> JaniDistribution: + """Function to get a random number between 0 and 1 in the Jani Model.""" + return distribution_expression("Uniform", [0.0, 1.0]) + + # Custom operators (CONVINCE, specific to mobile 2D robot use case) def intersection_operator(left, right) -> JaniExpression: return JaniExpression( @@ -453,7 +465,7 @@ def __substitute_expression_op(expression: JaniExpression) -> JaniExpression: def expand_expression( - expression: Union[JaniExpression, JaniValue], jani_constants: Dict[str, JaniConstant] + expression: JaniExpression, jani_constants: Dict[str, JaniConstant] ) -> JaniExpression: # Given a CONVINCE JaniExpression, expand it to a plain JaniExpression assert isinstance( @@ -462,6 +474,9 @@ def expand_expression( assert ( expression.is_valid() ), "The expression is not valid: it defines no value, nor variable, nor operation to be done." + if expression.get_expression_type() == JaniExpressionType.DISTRIBUTION: + # For now this is fine, since we expect only real values in the args + return expression if expression.op is None: # It is either a variable/constant identifier or a value return expression @@ -492,6 +507,48 @@ def expand_expression( return __substitute_expression_op(expression) +def expand_distribution_expressions( + expression: JaniExpression, *, n_options: int = 101 +) -> List[JaniExpression]: + """ + Traverse the expression and substitute each distribution with n expressions. + + This is a workaround, until we can support it in our model checker. + + :param expression: The expression to expand. + :param n_options: How many options to generate for each encountered distribution. + :return: One expression, if no distribution is found, n_options^n_distributions expr. otherwise. + """ + assert isinstance( + expression, JaniExpression + ), f"Unexpected expression type: {type(expression)} != (JaniExpression, JaniDistribution)." + assert expression.is_valid(), f"Invalid expression found: {expression}." + expr_type = expression.get_expression_type() + if expr_type == JaniExpressionType.OPERATOR: + # Generate all possible expressions, if expansion returns many expressions for an operand + expanded_expressions: List[JaniExpression] = [deepcopy(expression)] + for key, value in expression.operands.items(): + expanded_operand = expand_distribution_expressions(value, n_options=n_options) + base_expressions = expanded_expressions + expanded_expressions = [] + for expr in base_expressions: + for key_value in expanded_operand: + expr.operands[key] = key_value + expanded_expressions.append(deepcopy(expr)) + return expanded_expressions + elif expr_type == JaniExpressionType.DISTRIBUTION: + # Here we need to substitute the distribution with a number of constants + assert isinstance(expression, JaniDistribution) and expression.is_valid() + lower_bound = expression.get_dist_args()[0] + dist_width = expression.get_dist_args()[1] - lower_bound + # Generate a (constant) JaniExpression for each possible outcome + return [ + JaniExpression(lower_bound + (x * dist_width / (n_options - 1))) + for x in range(n_options) + ] + return [expression] + + # Map each function name to the corresponding Expression generator CALLABLE_OPERATORS_MAP: Dict[str, Callable] = { "abs": abs_operator, @@ -503,4 +560,5 @@ def expand_expression( "pow": pow_operator, "min": min_operator, "max": max_operator, + "random": random_operator, } diff --git a/src/as2fm/jani_generator/jani_entries/jani_edge.py b/src/as2fm/jani_generator/jani_entries/jani_edge.py index b4296d5b..85cf5e16 100644 --- a/src/as2fm/jani_generator/jani_entries/jani_edge.py +++ b/src/as2fm/jani_generator/jani_entries/jani_edge.py @@ -15,7 +15,7 @@ """And edge defining the possible transition from one state to another in jani.""" -from typing import Dict, Optional +from typing import Dict, List, Optional from as2fm.jani_generator.jani_entries import ( JaniAssignment, @@ -52,6 +52,7 @@ def __init__(self, edge_dict: dict): jani_destination["assignments"].append(assignment) else: raise RuntimeError(f"Unexpected type {type(assignment)} in assignments") + _sort_assignments_by_index(jani_destination["assignments"]) self.destinations.append(jani_destination) def get_action(self) -> Optional[str]: @@ -93,3 +94,8 @@ def as_dict(self, constants: Dict[str, JaniConstant]): single_destination.update({"assignments": expanded_assignments}) edge_dict["destinations"].append(single_destination) return edge_dict + + +def _sort_assignments_by_index(assignments: List[JaniAssignment]) -> None: + """Sorts a list of assignments by assignment index.""" + assignments.sort(key=lambda assignment: assignment.get_index()) diff --git a/src/as2fm/jani_generator/jani_entries/jani_expression.py b/src/as2fm/jani_generator/jani_entries/jani_expression.py index 46eb782a..269d5569 100644 --- a/src/as2fm/jani_generator/jani_entries/jani_expression.py +++ b/src/as2fm/jani_generator/jani_entries/jani_expression.py @@ -18,7 +18,7 @@ """ from enum import Enum -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from as2fm.jani_generator.jani_entries import JaniValue from as2fm.scxml_converter.scxml_entries.utils import PLAIN_SCXML_EVENT_DATA_PREFIX @@ -32,6 +32,7 @@ class JaniExpressionType(Enum): IDENTIFIER = 1 # Reference to a constant or variable id LITERAL = 2 # Reference to a literal value OPERATOR = 3 # Reference to an operator (a composition of expressions) + DISTRIBUTION = 4 # A random number from a distribution class JaniExpression: @@ -53,6 +54,9 @@ def __init__(self, expression: Union[SupportedExp, "JaniExpression", JaniValue]) self.op: Optional[str] = None self.operands: Dict[str, JaniExpression] = {} if isinstance(expression, JaniExpression): + assert ( + expression.get_expression_type() != JaniExpressionType.DISTRIBUTION + ), "Cannot convert a JaniDistribution to a JaniExpression explicitly." self.identifier = expression.identifier self.value = expression.value self.op = expression.op @@ -79,6 +83,7 @@ def __init__(self, expression: Union[SupportedExp, "JaniExpression", JaniValue]) self.operands = self._get_operands(expression) def _get_operands(self, expression_dict: dict) -> Dict[str, "JaniExpression"]: + """Generate the expressions operands from a raw dictionary, after validating it.""" assert self.op is not None, "Operator not set" if self.op in ("intersect", "distance"): # intersect: returns a value in [0.0, 1.0], indicating where on the robot trajectory @@ -87,15 +92,15 @@ def _get_operands(self, expression_dict: dict) -> Dict[str, "JaniExpression"]: # intersection occurs at the start distance: returns the distance between the robot and # the barrier. return { - "robot": JaniExpression(expression_dict["robot"]), - "barrier": JaniExpression(expression_dict["barrier"]), + "robot": generate_jani_expression(expression_dict["robot"]), + "barrier": generate_jani_expression(expression_dict["barrier"]), } if self.op in ("distance_to_point"): # distance between robot outer radius and point x-y coords return { - "robot": JaniExpression(expression_dict["robot"]), - "x": JaniExpression(expression_dict["x"]), - "y": JaniExpression(expression_dict["y"]), + "robot": generate_jani_expression(expression_dict["robot"]), + "x": generate_jani_expression(expression_dict["x"]), + "y": generate_jani_expression(expression_dict["y"]), } if self.op in ( "&&", @@ -127,8 +132,8 @@ def _get_operands(self, expression_dict: dict) -> Dict[str, "JaniExpression"]: "==", ): return { - "left": JaniExpression(expression_dict["left"]), - "right": JaniExpression(expression_dict["right"]), + "left": generate_jani_expression(expression_dict["left"]), + "right": generate_jani_expression(expression_dict["right"]), } if self.op in ( "!", @@ -143,39 +148,39 @@ def _get_operands(self, expression_dict: dict) -> Dict[str, "JaniExpression"]: "to_deg", "to_rad", ): - return {"exp": JaniExpression(expression_dict["exp"])} + return {"exp": generate_jani_expression(expression_dict["exp"])} if self.op in ("ite"): return { - "if": JaniExpression(expression_dict["if"]), - "then": JaniExpression(expression_dict["then"]), - "else": JaniExpression(expression_dict["else"]), + "if": generate_jani_expression(expression_dict["if"]), + "then": generate_jani_expression(expression_dict["then"]), + "else": generate_jani_expression(expression_dict["else"]), } # Array-specific expressions if self.op == "ac": return { - "var": JaniExpression(expression_dict["var"]), - "length": JaniExpression(expression_dict["length"]), - "exp": JaniExpression(expression_dict["exp"]), + "var": generate_jani_expression(expression_dict["var"]), + "length": generate_jani_expression(expression_dict["length"]), + "exp": generate_jani_expression(expression_dict["exp"]), } if self.op == "aa": return { - "exp": JaniExpression(expression_dict["exp"]), - "index": JaniExpression(expression_dict["index"]), + "exp": generate_jani_expression(expression_dict["exp"]), + "index": generate_jani_expression(expression_dict["index"]), } if self.op == "av": - return {"elements": JaniExpression(expression_dict["elements"])} + return {"elements": generate_jani_expression(expression_dict["elements"])} # Convince specific expressions if self.op in ("norm2d"): return { - "x": JaniExpression(expression_dict["x"]), - "y": JaniExpression(expression_dict["y"]), + "x": generate_jani_expression(expression_dict["x"]), + "y": generate_jani_expression(expression_dict["y"]), } if self.op in ("dot2d", "cross2d"): return { - "x1": JaniExpression(expression_dict["x1"]), - "y1": JaniExpression(expression_dict["y1"]), - "x2": JaniExpression(expression_dict["x2"]), - "y2": JaniExpression(expression_dict["y2"]), + "x1": generate_jani_expression(expression_dict["x1"]), + "y1": generate_jani_expression(expression_dict["y1"]), + "x2": generate_jani_expression(expression_dict["x2"]), + "y2": generate_jani_expression(expression_dict["y2"]), } assert False, f'Unknown operator "{self.op}" found.' @@ -190,7 +195,7 @@ def get_expression_type(self) -> JaniExpressionType: return JaniExpressionType.OPERATOR raise RuntimeError("Unknown expression type") - def replace_event(self, replacement: Optional[str]): + def replace_event(self, replacement: Optional[str]) -> "JaniExpression": """Replace the default SCXML event prefix with the provided replacement. Within a transitions, scxml can access to the event's parameters using a specific prefix. @@ -218,6 +223,7 @@ def replace_event(self, replacement: Optional[str]): return self def is_valid(self) -> bool: + """Expression validity check.""" return self.identifier is not None or self.value is not None or self.op is not None def as_literal(self) -> Optional[JaniValue]: @@ -238,6 +244,7 @@ def as_operator(self) -> Optional[Tuple[str, Dict[str, "JaniExpression"]]]: return (self.op, self.operands) def as_dict(self) -> Union[str, int, float, bool, dict]: + """Convert the expression to a dictionary, ready to be converted to JSON.""" assert hasattr(self, "identifier"), f"Identifier not set for {self.__dict__}" if self.identifier is not None: return self.identifier @@ -250,6 +257,78 @@ def as_dict(self) -> Union[str, int, float, bool, dict]: assert isinstance( op_value, JaniExpression ), f"Expected an expression, found {type(op_value)} for {op_key}" - assert hasattr(op_value, "identifier"), f"Identifier not set for {op_key}" + assert op_value.is_valid(), f"Expression's {op_key}'s value is invalid: {op_value}" op_dict.update({op_key: op_value.as_dict()}) return op_dict + + +class JaniDistribution(JaniExpression): + """ + A class representing a Jani Distribution (a random variable). + + At the moment, this is only meant to support Uniform distributions between 0.0 and 1.0 + """ + + def __init__(self, expression: dict): + self._distribution = expression.get("distribution") + self._args = expression.get("args") + assert ( + self._distribution == "Uniform" + ), f"Expected distribution to be Uniform, found {self._distribution}." + assert ( + isinstance(self._args, list) and len(self._args) == 2 + ), f"Unexpected arguments for Uniform distribution expression: {self._args}." + assert self.is_valid(), "Invalid arguments provided: expected args[0] <= args[1]." + + def is_valid(self): + """Distribution validity check.""" + # All other checks are carried out in the constructor + return all(isinstance(argument, (int, float)) for argument in self._args) and ( + self._args[0] <= self._args[1] + ) + + def get_expression_type(self) -> JaniExpressionType: + """Get the type of the expression.""" + assert self.is_valid(), "Expression is not valid" + return JaniExpressionType.DISTRIBUTION + + def replace_event(self, _: Optional[str]) -> "JaniDistribution": + """Replace the default SCXML event prefix with the provided replacement.""" + return self + + def as_literal(self) -> None: + """Provide the expression as a literal (JaniValue), if possible. None otherwise.""" + return None + + def as_identifier(self) -> None: + """Provide the expression as an identifier, if possible. None otherwise.""" + return None + + def as_operator(self) -> None: + """Provide the expression as an operator, if possible. None otherwise.""" + return None + + def get_dist_type(self) -> str: + """Return the distribution type set in the object.""" + return self._distribution + + def get_dist_args(self) -> List[Union[int, float]]: + """Return the config. arguments of the distribution.""" + return self._args + + def as_dict(self) -> Dict[str, Any]: + """Convert the distribution to a dictionary, ready to be converted to JSON.""" + assert self.is_valid(), "Expected distribution to be valid." + return {"distribution": self._distribution, "args": self._args} + + +def generate_jani_expression(expr: SupportedExp) -> JaniExpression: + """Generate a JaniExpression or a JaniDistribution, depending on the input.""" + if isinstance(expr, JaniExpression): + return expr + if isinstance(expr, (str, JaniValue)) or JaniValue(expr).is_valid(): + return JaniExpression(expr) + assert isinstance(expr, dict), f"Unsupported expression provided: {expr}." + if "distribution" in expr: + return JaniDistribution(expr) + return JaniExpression(expr) diff --git a/src/as2fm/jani_generator/jani_entries/jani_expression_generator.py b/src/as2fm/jani_generator/jani_entries/jani_expression_generator.py index 06cc4c45..bc34bca3 100644 --- a/src/as2fm/jani_generator/jani_entries/jani_expression_generator.py +++ b/src/as2fm/jani_generator/jani_entries/jani_expression_generator.py @@ -17,7 +17,7 @@ Generate full expressions in Jani """ -from as2fm.jani_generator.jani_entries import JaniExpression +from as2fm.jani_generator.jani_entries import JaniDistribution, JaniExpression # Math operators @@ -152,3 +152,13 @@ def array_value_operator(elements) -> JaniExpression: :param elements: The elements of the array """ return JaniExpression({"op": "av", "elements": elements}) + + +def distribution_expression(distribution: str, arguments: list) -> JaniDistribution: + """ + Generate a distribution expression + + :param distribution: The statistical distribution to pick from + :param arguments: The parameters for configuring the statistical distribution + """ + return JaniDistribution({"distribution": distribution, "args": arguments}) diff --git a/src/as2fm/jani_generator/jani_entries/jani_helpers.py b/src/as2fm/jani_generator/jani_entries/jani_helpers.py new file mode 100644 index 00000000..ed4b4772 --- /dev/null +++ b/src/as2fm/jani_generator/jani_entries/jani_helpers.py @@ -0,0 +1,125 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List, Union + +from as2fm.jani_generator.jani_entries import JaniAssignment, JaniEdge, JaniExpression, JaniModel +from as2fm.jani_generator.jani_entries.jani_convince_expression_expansion import ( + expand_distribution_expressions, +) + + +def _generate_new_edge_for_random_assignments( + edge_location: str, + edge_target: str, + assignment_var: Union[str, JaniExpression], + assignment_possibilities: List[JaniExpression], +) -> JaniEdge: + probability = 1.0 / len(assignment_possibilities) + return JaniEdge( + { + "location": edge_location, + "destinations": [ + { + "location": edge_target, + "probability": {"exp": probability}, + "assignments": [{"ref": assignment_var, "value": assignment_value}], + } + for assignment_value in assignment_possibilities + ], + } + ) + + +def _expand_random_variables_in_edge( + jani_edge: JaniEdge, *, n_options: int = 101 +) -> List[JaniEdge]: + """ + If there are random variables in the input JaniEdge, generate new edges to handle it. + + :param jani_edge: The edge to expand + :return: All the edges resulting from the input. + """ + generated_edges: List[JaniEdge] = [jani_edge] + edge_location = jani_edge.location + edge_action = jani_edge.action + assert edge_action is not None, "Expected edge actions to be always defined." + edge_id = f"{edge_location}_{edge_action}" + + for dest_id, dest_val in enumerate(jani_edge.destinations): + jani_assignments: List[JaniAssignment] = dest_val["assignments"] + curr_assign_idx = 0 + while curr_assign_idx < len(jani_assignments): + expanded_assignments = expand_distribution_expressions( + jani_assignments[curr_assign_idx].get_expression(), n_options=n_options + ) + if len(expanded_assignments) > 1: + # In this case, we expanded the assignments, and we need to generate new edges + original_target_loc = dest_val["location"] + expanded_edge_loc = f"{edge_id}_dest_{dest_id}_expanded_assign_{curr_assign_idx}" + next_target_edge_loc = f"{edge_id}_dest_{dest_id}_after_assign_{curr_assign_idx}" + expanded_edge = _generate_new_edge_for_random_assignments( + expanded_edge_loc, + next_target_edge_loc, + jani_assignments[curr_assign_idx].get_target(), + expanded_assignments, + ) + next_assign_idx = curr_assign_idx + 1 + continuation_edge = JaniEdge( + { + "location": next_target_edge_loc, + "action": "act", # Keep it simple, due to the location naming scheme + "destinations": [ + { + "location": original_target_loc, + "assignments": jani_assignments[next_assign_idx:], + } + ], + } + ) + dest_val["location"] = expanded_edge_loc + dest_val["assignments"] = dest_val["assignments"][0:curr_assign_idx] + generated_edges.append(expanded_edge) + generated_edges.extend( + _expand_random_variables_in_edge(continuation_edge, n_options=n_options) + ) + break + curr_assign_idx += 1 + return generated_edges + + +def expand_random_variables_in_jani_model(model: JaniModel, *, n_options: int = 101) -> None: + """Find all expression containing the 'distribution' expression and expand them.""" + # Check that no global variable has a random value (not supported) + for g_var_name, g_var in model.get_variables().items(): + assert ( + len(expand_distribution_expressions(g_var.get_init_expr())) == 1 + ), f"Global variable {g_var_name} is init using a random value. This is unsupported." + for automaton in model.get_automata(): + # Also for automaton, check variables initialization + for aut_var_name, aut_var in automaton.get_variables().items(): + assert len(expand_distribution_expressions(aut_var.get_init_expr())) == 1, ( + f"Variable {aut_var_name} in automaton {automaton.get_name()} is init using random " + f"values: init expr = '{aut_var.get_init_expr().as_dict()}'. This is unsupported." + ) + # Edges created to handle random distributions + new_edges: List[JaniEdge] = [] + for edge in automaton.get_edges(): + generated_edges = _expand_random_variables_in_edge(edge, n_options=n_options) + for gen_edge in generated_edges: + automaton.add_location(gen_edge.location) + new_edges.extend(generated_edges) + automaton.set_edges(new_edges) + model._generate_missing_syncs() diff --git a/src/as2fm/jani_generator/scxml_helpers/scxml_tags.py b/src/as2fm/jani_generator/scxml_helpers/scxml_tags.py index c8a710d8..dcdf92ba 100644 --- a/src/as2fm/jani_generator/scxml_helpers/scxml_tags.py +++ b/src/as2fm/jani_generator/scxml_helpers/scxml_tags.py @@ -19,12 +19,17 @@ from array import ArrayType from hashlib import sha256 -from typing import Dict, List, Optional, Set, Tuple, Union, get_args +from typing import Any, Dict, List, Optional, Set, Tuple, Union, get_args import lxml.etree as ET from lxml.etree import _Element as Element -from as2fm.as2fm_common.common import check_value_type_compatible, string_to_value, value_to_type +from as2fm.as2fm_common.common import ( + check_value_type_compatible, + get_default_expression_for_type, + string_to_value, + value_to_type, +) from as2fm.as2fm_common.ecmascript_interpretation import interpret_ecma_script_expr from as2fm.jani_generator.jani_entries import ( JaniAssignment, @@ -207,27 +212,41 @@ def _append_scxml_body_to_jani_automaton( hash_str: str, guard_exp: Optional[JaniExpression], trigger_event: Optional[str], + data_event: Optional[str], max_array_size: int, ) -> Tuple[List[JaniEdge], List[str]]: """ Converts the body of an SCXML element to a set of locations and edges. They need to be added to a JaniAutomaton later on. + + :param jani_automaton: The single automaton hosting the generated edges and locations. + :param events_holder: A data structure describing the events generated in the automaton. + :param body: A list of SCXML entries to be translated into Jani. + :param source: The location we are starting executing the body from. + :param target: The location we are ending up in after executing the body. + :param hash_str: Additional hash to ensure a unique action identifier to executing the body. + :param guard_exp: An expression that needs to hold before executing this action. + :param trigger_event: The event starting the exec. block (use only from ScxmlTransition). + :param data_event: The event carrying the data, that might be read in the exec block. + :param max_array_size: The maximum allowed array size (for unbounded arrays). """ - edge_action_name = f"{source}-{target}-{hash_str}" - trigger_event_action = ( - edge_action_name if trigger_event is None else f"{trigger_event}_on_receive" + jani_action_name = ( + f"{trigger_event}_on_receive" + if trigger_event is not None + else f"{source}-{target}-parent-{hash_str}" ) + new_edges = [] new_locations = [] if guard_exp is not None: - guard_exp.replace_event(trigger_event) + guard_exp.replace_event(data_event) # First edge. Has to evaluate guard and trigger event of original transition. new_edges.append( JaniEdge( { "location": source, - "action": trigger_event_action, + "action": jani_action_name, "guard": JaniGuard(guard_exp), "destinations": [{"location": None, "assignments": []}], } @@ -236,7 +255,7 @@ def _append_scxml_body_to_jani_automaton( for i, ec in enumerate(body): if isinstance(ec, ScxmlAssign): assign_idx = len(new_edges[-1].destinations[0]["assignments"]) - jani_assigns = _interpret_scxml_assign(ec, jani_automaton, trigger_event, assign_idx) + jani_assigns = _interpret_scxml_assign(ec, jani_automaton, data_event, assign_idx) new_edges[-1].destinations[0]["assignments"].extend(jani_assigns) elif isinstance(ec, ScxmlSend): event_name = ec.get_event() @@ -268,7 +287,7 @@ def _append_scxml_body_to_jani_automaton( if isinstance(res_eval_value, ArrayType): array_info = ArrayInfo(get_args(res_eval_type)[0], max_array_size) jani_expr = parse_ecmascript_to_jani_expression(expr, array_info).replace_event( - trigger_event + data_event ) new_edge.destinations[0]["assignments"].append( JaniAssignment({"ref": param_assign_name, "value": jani_expr}) @@ -288,7 +307,7 @@ def _append_scxml_body_to_jani_automaton( ) ) elif jani_expr_type == JaniExpressionType.OPERATOR: - op_type, operands = jani_expr.as_operator() + op_type, _ = jani_expr.as_operator() if op_type == "av": assert isinstance( res_eval_value, ArrayType @@ -324,7 +343,7 @@ def _append_scxml_body_to_jani_automaton( for if_idx, (cond_str, conditional_body) in enumerate(ec.get_conditional_executions()): current_cond = parse_ecmascript_to_jani_expression(cond_str) jani_cond = _merge_conditions(previous_conditions, current_cond).replace_event( - trigger_event + data_event ) sub_edges, sub_locs = _append_scxml_body_to_jani_automaton( jani_automaton, @@ -334,7 +353,9 @@ def _append_scxml_body_to_jani_automaton( interm_loc_after, "-".join([hash_str, _hash_element(ec), str(if_idx)]), jani_cond, - None, + None, # This is not triggered by an event, even under a transition. Because + # the event triggering the transition is handled at the top of this function. + data_event, max_array_size, ) new_edges.extend(sub_edges) @@ -344,7 +365,7 @@ def _append_scxml_body_to_jani_automaton( else_execution_body = ec.get_else_execution() else_execution_id = str(len(ec.get_conditional_executions())) else_execution_body = [] if else_execution_body is None else else_execution_body - jani_cond = _merge_conditions(previous_conditions).replace_event(trigger_event) + jani_cond = _merge_conditions(previous_conditions).replace_event(data_event) sub_edges, sub_locs = _append_scxml_body_to_jani_automaton( jani_automaton, events_holder, @@ -354,16 +375,18 @@ def _append_scxml_body_to_jani_automaton( "-".join([hash_str, _hash_element(ec), else_execution_id]), jani_cond, None, + data_event, max_array_size, ) new_edges.extend(sub_edges) new_locations.extend(sub_locs) # Prepare the edge from the end of the if-else block + end_edge_action_name = f"{source}-{target}-{hash_str}" new_edges.append( JaniEdge( { "location": interm_loc_after, - "action": edge_action_name, + "action": end_edge_action_name, "guard": None, "destinations": [{"location": None, "assignments": []}], } @@ -448,6 +471,8 @@ def get_children(self) -> List[ScxmlBase]: return [] def write_model(self): + # A collection of the variables read from the datamodel so far + read_vars: Dict[str, Any] = {} for scxml_data in self.element.get_data_entries(): assert isinstance(scxml_data, ScxmlData), "Unexpected element in the DataModel." assert scxml_data.check_validity(), "Found invalid data entry." @@ -465,12 +490,10 @@ def write_model(self): expected_type = ArrayType array_info = ArrayInfo(array_type, max_array_size) init_value = parse_ecmascript_to_jani_expression(scxml_data.get_expr(), array_info) - expr_type = type(interpret_ecma_script_expr(scxml_data.get_expr())) - assert check_value_type_compatible( - interpret_ecma_script_expr(scxml_data.get_expr()), expected_type - ), ( + evaluated_expr = interpret_ecma_script_expr(scxml_data.get_expr(), read_vars) + assert check_value_type_compatible(evaluated_expr, expected_type), ( f"Invalid value for {scxml_data.get_name()}: " - f"Expected type {expected_type}, got {expr_type}." + f"Expected type {expected_type}, got {type(evaluated_expr)}." ) # TODO: Add support for lower and upper bounds self.automaton.add_variable( @@ -484,6 +507,9 @@ def write_model(self): self.automaton.add_variable( JaniVariable(f"{scxml_data.get_name()}.length", int, JaniValue(len(init_expr))) ) + read_vars.update( + {scxml_data.get_name(): get_default_expression_for_type(scxml_data.get_type())} + ) class ScxmlTag(BaseTag): @@ -520,6 +546,7 @@ def handle_entry_state(self): hash_str, None, None, + None, self.max_array_size, ) # Add the initial state and start sequence to the automaton @@ -609,6 +636,7 @@ def add_unhandled_transitions(self, transitions_set: Set[str]): "", guard_exp, event_name, + event_name, self.max_array_size, ) assert ( @@ -745,6 +773,7 @@ def write_model(self): hash_str, guard, transition_trigger_event, + transition_trigger_event, self.max_array_size, ) for edge in new_edges: diff --git a/src/as2fm/jani_generator/scxml_helpers/scxml_to_jani.py b/src/as2fm/jani_generator/scxml_helpers/scxml_to_jani.py index 3a9a5069..9afd8376 100644 --- a/src/as2fm/jani_generator/scxml_helpers/scxml_to_jani.py +++ b/src/as2fm/jani_generator/scxml_helpers/scxml_to_jani.py @@ -20,6 +20,7 @@ from typing import List from as2fm.jani_generator.jani_entries.jani_automaton import JaniAutomaton +from as2fm.jani_generator.jani_entries.jani_helpers import expand_random_variables_in_jani_model from as2fm.jani_generator.jani_entries.jani_model import JaniModel from as2fm.jani_generator.ros_helpers.ros_communication_handler import ( remove_empty_self_loops_from_interface_handlers_in_jani, @@ -82,4 +83,5 @@ def convert_multiple_scxmls_to_jani( base_model.add_jani_automaton(timer_automaton) implement_scxml_events_as_jani_syncs(events_holder, timers, max_array_size, base_model) remove_empty_self_loops_from_interface_handlers_in_jani(base_model) + expand_random_variables_in_jani_model(base_model, n_options=100) return base_model diff --git a/src/as2fm/jani_generator/scxml_helpers/top_level_interpreter.py b/src/as2fm/jani_generator/scxml_helpers/top_level_interpreter.py index 9c4f1ee2..2a076242 100644 --- a/src/as2fm/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/src/as2fm/jani_generator/scxml_helpers/top_level_interpreter.py @@ -36,7 +36,11 @@ from as2fm.jani_generator.ros_helpers.ros_service_handler import RosServiceHandler from as2fm.jani_generator.ros_helpers.ros_timer import RosTimer, make_global_timer_scxml from as2fm.jani_generator.scxml_helpers.scxml_to_jani import convert_multiple_scxmls_to_jani -from as2fm.scxml_converter.bt_converter import bt_converter +from as2fm.scxml_converter.bt_converter import ( + bt_converter, + generate_blackboard_scxml, + get_blackboard_variables_from_models, +) from as2fm.scxml_converter.scxml_entries import EventsToAutomata, ScxmlRoot @@ -174,6 +178,7 @@ def generate_plain_scxml_models_and_timers( all_timers: List[RosTimer] = [] all_services: Dict[str, RosCommunicationHandler] = {} all_actions: Dict[str, RosCommunicationHandler] = {} + bt_blackboard_vars: Dict[str, str] = get_blackboard_variables_from_models(ros_scxmls) for scxml_entry in ros_scxmls: plain_scxmls, ros_declarations = scxml_entry.to_plain_scxml_and_declarations() # Handle ROS timers @@ -197,6 +202,9 @@ def generate_plain_scxml_models_and_timers( ros_declarations._action_clients, ) plain_scxml_models.extend(plain_scxmls) + # Generate sync SCXML model for BT Blackboard (if needed) + if len(bt_blackboard_vars) > 0: + plain_scxml_models.append(generate_blackboard_scxml(bt_blackboard_vars)) # Generate sync SCXML models for services and actions for plain_scxml in generate_plain_scxml_from_handlers(all_services | all_actions): plain_scxml_models.append(plain_scxml) diff --git a/src/as2fm/scxml_converter/bt_converter.py b/src/as2fm/scxml_converter/bt_converter.py index 38645fec..d3e0baec 100644 --- a/src/as2fm/scxml_converter/bt_converter.py +++ b/src/as2fm/scxml_converter/bt_converter.py @@ -24,19 +24,92 @@ from lxml import etree as ET +from as2fm.as2fm_common.common import get_default_expression_for_type, value_to_string from as2fm.scxml_converter.scxml_entries import ( BtChildStatus, BtTickChild, RosRateCallback, RosTimeRate, + ScxmlAssign, + ScxmlData, + ScxmlDataModel, ScxmlExecutionBody, + ScxmlParam, ScxmlRoot, + ScxmlSend, ScxmlState, + ScxmlTransition, ) +from as2fm.scxml_converter.scxml_entries.bt_utils import ( + BT_BLACKBOARD_EVENT_VALUE, + BT_BLACKBOARD_GET, + BT_BLACKBOARD_REQUEST, + generate_bt_blackboard_set, + get_blackboard_variable_name, + is_blackboard_reference, +) +from as2fm.scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE BT_ROOT_PREFIX = "bt_root_fsm_" +def get_blackboard_variables_from_models(models: List[ScxmlRoot]) -> Dict[str, str]: + """ + Collect all blackboard variables and return them as a dictionary. + + :param models: List of ScxmlModel to extract the information from. + :return: Dictionary with name and type of the detected blackboard variable. + """ + blackboard_vars: Dict[str, str] = {} + for scxml_model in models: + declared_ports: List[Tuple[str, str, str]] = scxml_model.get_bt_ports_types_values() + for p_name, p_type, p_value in declared_ports: + assert ( + p_value is not None + ), f"Error in model {scxml_model.get_name()}: undefined value in {p_name} BT port." + if is_blackboard_reference(p_value): + var_name = get_blackboard_variable_name(p_value) + existing_bt_type = blackboard_vars.get(var_name) + assert existing_bt_type is None or existing_bt_type == p_type + blackboard_vars.update({var_name: p_type}) + return blackboard_vars + + +def generate_blackboard_scxml(bt_blackboard_vars: Dict[str, str]) -> ScxmlRoot: + """Generate an SCXML model that handles all BT-related synchronization.""" + assert len(bt_blackboard_vars) > 0, "Cannot generate BT Blackboard, no variables" + # TODO: Append the name of the related BT, as in generate_bt_root_scxml + scxml_model_name = "bt_blackboard_fsm" + state_name = "idle" + idle_state = ScxmlState(state_name) + bt_data: List[ScxmlData] = [] + bt_bb_param_list: List[ScxmlParam] = [] + for bb_key, bb_type in bt_blackboard_vars.items(): + default_value = value_to_string( + get_default_expression_for_type(SCXML_DATA_STR_TO_TYPE[bb_type]) + ) + bt_data.append(ScxmlData(bb_key, default_value, bb_type)) + bt_bb_param_list.append(ScxmlParam(bb_key, expr=bb_key)) + idle_state.add_transition( + ScxmlTransition( + state_name, + [generate_bt_blackboard_set(bb_key)], + body=[ScxmlAssign(bb_key, BT_BLACKBOARD_EVENT_VALUE)], + ) + ) + idle_state.add_transition( + ScxmlTransition( + state_name, + [BT_BLACKBOARD_REQUEST], + body=[ScxmlSend(BT_BLACKBOARD_GET, bt_bb_param_list)], + ) + ) + bt_root = ScxmlRoot(scxml_model_name) + bt_root.set_data_model(ScxmlDataModel(bt_data)) + bt_root.add_state(idle_state, initial=True) + return bt_root + + def is_bt_root_scxml(scxml_name: str) -> bool: """ Check if the SCXML name matches with the BT root SCXML name pattern. diff --git a/src/as2fm/scxml_converter/scxml_entries/__init__.py b/src/as2fm/scxml_converter/scxml_entries/__init__.py index 35332f39..b4a07c4f 100644 --- a/src/as2fm/scxml_converter/scxml_entries/__init__.py +++ b/src/as2fm/scxml_converter/scxml_entries/__init__.py @@ -3,12 +3,12 @@ from .scxml_base import ScxmlBase # noqa: F401 from .utils import CallbackType # noqa: F401 from .bt_utils import RESERVED_BT_PORT_NAMES # noqa: F401 -from .scxml_bt_ports import ( # noqa: F401 +from .scxml_bt_port_declaration import ( # noqa: F401 BtInputPortDeclaration, BtPortDeclarations, BtOutputPortDeclaration, - BtGetValueInputPort, ) # noqa: F401 +from .scxml_bt_in_port import BtGetValueInputPort # noqa: F401 from .scxml_param import ScxmlParam # noqa: F401 from .scxml_ros_field import RosField # noqa: F401 from .scxml_data import ScxmlData # noqa: F401 @@ -20,6 +20,7 @@ ScxmlExecutionBody, EventsToAutomata, ) # noqa: F401 +from .scxml_bt_out_port import BtSetValueOutputPort # noqa: F401 from .scxml_executable_entries import ( # noqa: F401 execution_body_from_xml, as_plain_execution_body, diff --git a/src/as2fm/scxml_converter/scxml_entries/bt_utils.py b/src/as2fm/scxml_converter/scxml_entries/bt_utils.py index fe587ddc..45bb2fcf 100644 --- a/src/as2fm/scxml_converter/scxml_entries/bt_utils.py +++ b/src/as2fm/scxml_converter/scxml_entries/bt_utils.py @@ -19,7 +19,10 @@ from enum import Enum, auto from typing import Dict, Tuple, Type -from as2fm.scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE +from as2fm.scxml_converter.scxml_entries.utils import ( + PLAIN_SCXML_EVENT_DATA_PREFIX, + SCXML_DATA_STR_TO_TYPE, +) VALID_BT_INPUT_PORT_TYPES: Dict[str, Type] = SCXML_DATA_STR_TO_TYPE | {"string": str} VALID_BT_OUTPUT_PORT_TYPES: Dict[str, Type] = SCXML_DATA_STR_TO_TYPE @@ -27,6 +30,13 @@ # List of keys that are not going to be read as BT ports from the BT XML definition. RESERVED_BT_PORT_NAMES = ["ID", "name"] +# Blackboard-related autogenerated events +BT_BLACKBOARD_REQUEST = "bt_blackboard_req" +BT_BLACKBOARD_GET = "bt_blackboard_get" + +BT_SET_BLACKBOARD_PARAM = "value" +BT_BLACKBOARD_EVENT_VALUE = PLAIN_SCXML_EVENT_DATA_PREFIX + BT_SET_BLACKBOARD_PARAM + class BtResponse(Enum): """Enumeration of possible BT responses.""" @@ -51,6 +61,16 @@ def process_expr(expr: str) -> str: return expr +def generate_bt_blackboard_set(bt_bb_ref_name: str) -> str: + """ + Generate the name of the evnt setting a specific Blackboard variable. + + :param bt_bb_ref_name: The name of the blackboard variable to set. + :return: The name of the event to use to generate the specific variable. + """ + return f"bt_blackboard_set_{bt_bb_ref_name}" + + def generate_bt_tick_event(instance_id: str) -> str: """Generate the BT tick event name for a given BT node instance.""" return f"bt_{instance_id}_tick" @@ -82,6 +102,24 @@ def is_blackboard_reference(port_value: str) -> bool: return re.match(r"\{.+\}", port_value) is not None +def get_blackboard_variable_name(port_value: str) -> str: + assert is_blackboard_reference( + port_value + ), f"Error: expected '{port_value}' to be a reference to a blackboard variable." + return port_value.removeprefix("{").removesuffix("}") + + +def get_input_variable_as_scxml_expression(port_value: str) -> str: + """ + Given an input variable it generates an expression as event data or single value. + + The outcome depends on whether port value refers to the BT blackboard or not. + """ + if is_blackboard_reference(port_value): + return PLAIN_SCXML_EVENT_DATA_PREFIX + get_blackboard_variable_name(port_value) + return port_value + + class BtPortsHandler: """Collector for declared BT ports and their assigned value.""" @@ -97,7 +135,7 @@ def check_port_name_allowed(port_name: str) -> None: def __init__(self): # For each port name, store the port type string and value. self._in_ports: Dict[str, Tuple[str, str]] = {} - self._out_ports: Dict[str, Tuple[Type, str]] = {} + self._out_ports: Dict[str, Tuple[str, str]] = {} def in_port_exists(self, port_name: str) -> bool: """Check if an input port exists.""" @@ -144,6 +182,10 @@ def get_port_value(self, port_name: str) -> str: else: raise RuntimeError(f"Error: Port {port_name} is not declared.") + def get_all_ports(self) -> Dict[str, Tuple[str, str]]: + """Get all declaed ports as a dict referencing port names to type and value.""" + return self._in_ports | self._out_ports + def get_in_port_value(self, port_name: str) -> str: """Get the value of an input port.""" assert self.in_port_exists( @@ -155,7 +197,15 @@ def get_in_port_value(self, port_name: str) -> str: def get_out_port_value(self, port_name: str) -> str: """Get the value of an output port.""" - raise NotImplementedError("Error: Output ports are not supported yet.") + assert self.out_port_exists( + port_name + ), f"Error: Port {port_name} is not declared as input port." + port_value = self._out_ports[port_name][1] + assert port_value is not None, f"Error: Port {port_name} has no assigned value." + assert is_blackboard_reference( + port_value + ), f"Error: Port {port_name} should be a blackboard reference, found value {port_value}" + return port_value def set_port_value(self, port_name: str, port_value: str) -> None: """Set the value of a port.""" @@ -165,8 +215,7 @@ def set_port_value(self, port_name: str, port_value: str) -> None: self._set_out_port_value(port_name, port_value) else: # The reserved port IDs can be set in the bt.xml even if they are unused in the plugin - if port_name not in RESERVED_BT_PORT_NAMES: - raise RuntimeError(f"Error: Port {port_name} is not declared.") + assert port_name in RESERVED_BT_PORT_NAMES, f"Error: Port {port_name} is not declared." def _set_in_port_value(self, port_name: str, port_value: str): """Set the value of an input port.""" @@ -175,16 +224,20 @@ def _set_in_port_value(self, port_name: str, port_value: str): ), f"Error: Port {port_name} is not declared as input port." assert ( self._in_ports[port_name][1] is None - ), f"Error: Port {port_name} already has a value assigned." + ), f"Error: Value of port {port_name} already assigned." port_type = self._in_ports[port_name][0] - # Ensure this is not a Blackboard variable reference: currently not supported - if is_blackboard_reference(port_value): - raise NotImplementedError( - f"Error: {port_value} assigns a Blackboard variable to {port_name}. " - "This is not yet supported." - ) self._in_ports[port_name] = (port_type, port_value) def _set_out_port_value(self, port_name: str, port_value: str): """Set the value of an output port.""" - raise NotImplementedError("Error: Output ports are not supported yet.") + assert self.out_port_exists( + port_name + ), f"Error: Port {port_name} is not declared as output port." + assert ( + self._out_ports[port_name][1] is None + ), f"Error: Value of port {port_name} already assigned." + assert is_blackboard_reference( + port_value + ), f"Error: value of output port {port_name} must be a blackboard variable." + port_type = self._out_ports[port_name][0] + self._out_ports[port_name] = (port_type, port_value) diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_bt_in_port.py b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_in_port.py new file mode 100644 index 00000000..923620fb --- /dev/null +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_in_port.py @@ -0,0 +1,58 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +SCXML get input for Behavior Trees' Ports. +""" + +from lxml import etree as ET + +from as2fm.scxml_converter.scxml_entries import ScxmlBase +from as2fm.scxml_converter.scxml_entries.utils import is_non_empty_string +from as2fm.scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok, get_xml_argument + + +class BtGetValueInputPort(ScxmlBase): + """ + Get the value of an input port in a bt plugin. + """ + + @staticmethod + def get_tag_name() -> str: + return "bt_get_input" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "BtGetValueInputPort": + assert_xml_tag_ok(BtGetValueInputPort, xml_tree) + key_str = get_xml_argument(BtGetValueInputPort, xml_tree, "key") + return BtGetValueInputPort(key_str) + + def __init__(self, key_str: str): + self._key = key_str + + def check_validity(self) -> bool: + return is_non_empty_string(BtGetValueInputPort, "key", self._key) + + def get_key_name(self) -> str: + return self._key + + def as_plain_scxml(self, _) -> ScxmlBase: + # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot + raise RuntimeError("Error: SCXML BT Port value getter cannot be converted to plain SCXML.") + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML BT Input Port: invalid parameters." + xml_bt_in_port = ET.Element(BtGetValueInputPort.get_tag_name(), {"key": self._key}) + return xml_bt_in_port diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_bt_out_port.py b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_out_port.py new file mode 100644 index 00000000..e0fbbe13 --- /dev/null +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_out_port.py @@ -0,0 +1,89 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +SCXML set output for Behavior Trees' Ports. +""" + +from lxml import etree as ET + +from as2fm.scxml_converter.scxml_entries import ScxmlParam, ScxmlSend +from as2fm.scxml_converter.scxml_entries.bt_utils import ( + BT_SET_BLACKBOARD_PARAM, + BtPortsHandler, + generate_bt_blackboard_set, + get_blackboard_variable_name, + is_blackboard_reference, +) +from as2fm.scxml_converter.scxml_entries.utils import is_non_empty_string +from as2fm.scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok, get_xml_argument + + +class BtSetValueOutputPort(ScxmlSend): + """ + Get the value of an input port in a bt plugin. + """ + + @staticmethod + def get_tag_name() -> str: + return "bt_set_output" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "BtSetValueOutputPort": + assert_xml_tag_ok(BtSetValueOutputPort, xml_tree) + key_str = get_xml_argument(BtSetValueOutputPort, xml_tree, "key") + expr_str = get_xml_argument(BtSetValueOutputPort, xml_tree, "expr") + return BtSetValueOutputPort(key_str, expr_str) + + def __init__(self, key_str: str, expr_str: str): + self._key = key_str + self._expr = expr_str + self._blackboard_reference = None + + def check_validity(self) -> bool: + return is_non_empty_string(BtSetValueOutputPort, "key", self._key) and is_non_empty_string( + BtSetValueOutputPort, "expr", self._expr + ) + + def has_bt_blackboard_input(self, _) -> bool: + """We do not expect reading from BT Ports here. Return False!""" + return False + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + assert bt_ports_handler.out_port_exists( + self._key + ), f"Error: SCXML BT Port {self._key} is not declared as output port." + port_value = bt_ports_handler.get_out_port_value(self._key) + assert is_blackboard_reference( + port_value + ), f"Error: SCXML BT Port {self._key} is not referencing a blackboard variable." + self._blackboard_reference = get_blackboard_variable_name(port_value) + + def as_plain_scxml(self, _) -> ScxmlSend: + # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot + assert ( + self._blackboard_reference is not None + ), "Error: SCXML BT Output Port: must run 'update_bt_ports_values' before 'as_plain_scxml'" + return ScxmlSend( + generate_bt_blackboard_set(self._blackboard_reference), + [ScxmlParam(BT_SET_BLACKBOARD_PARAM, expr=self._expr)], + ) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML BT Output Port: invalid parameters." + xml_bt_in_port = ET.Element( + BtSetValueOutputPort.get_tag_name(), {"key": self._key, "expr": self._expr} + ) + return xml_bt_in_port diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_bt_ports.py b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_port_declaration.py similarity index 76% rename from src/as2fm/scxml_converter/scxml_entries/scxml_bt_ports.py rename to src/as2fm/scxml_converter/scxml_entries/scxml_bt_port_declaration.py index 58d00570..efdd3855 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_bt_ports.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_port_declaration.py @@ -14,7 +14,7 @@ # limitations under the License. """ -SCXML entries related to Behavior Trees' Ports. +SCXML entries related to Behavior Trees' Ports declaration. """ from typing import Union @@ -112,38 +112,4 @@ def as_xml(self) -> ET.Element: return xml_bt_in_port -class BtGetValueInputPort(ScxmlBase): - """ - Get the value of an input port in a bt plugin. - """ - - @staticmethod - def get_tag_name() -> str: - return "bt_get_input" - - @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "BtGetValueInputPort": - assert_xml_tag_ok(BtGetValueInputPort, xml_tree) - key_str = get_xml_argument(BtGetValueInputPort, xml_tree, "key") - return BtGetValueInputPort(key_str) - - def __init__(self, key_str: str): - self._key = key_str - - def check_validity(self) -> bool: - return is_non_empty_string(BtGetValueInputPort, "key", self._key) - - def get_key_name(self) -> str: - return self._key - - def as_plain_scxml(self, _) -> ScxmlBase: - # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot - raise RuntimeError("Error: SCXML BT Port value getter cannot be converted to plain SCXML.") - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML BT Input Port: invalid parameters." - xml_bt_in_port = ET.Element(BtGetValueInputPort.get_tag_name(), {"key": self._key}) - return xml_bt_in_port - - BtPortDeclarations = Union[BtInputPortDeclaration, BtOutputPortDeclaration] diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_bt_ticks.py b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_ticks.py index a0ef8d81..f5ed8e5c 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_bt_ticks.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_bt_ticks.py @@ -133,6 +133,10 @@ def __init__(self, child_seq_id: Union[str, int]): def check_validity(self) -> bool: return True + def has_bt_blackboard_input(self, _): + """Check whether the If entry reads content from the BT Blackboard.""" + return False + def instantiate_bt_events( self, instance_id: int, children_ids: List[int] ) -> Union[ScxmlIf, ScxmlSend]: @@ -275,6 +279,10 @@ def __init__(self, status: str): def check_validity(self) -> bool: return True + def has_bt_blackboard_input(self, _) -> bool: + """We do not expect reading from BT Ports here. Return False!""" + return False + def instantiate_bt_events(self, instance_id: int, _) -> ScxmlSend: return ScxmlSend( generate_bt_response_event(instance_id), diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_data.py b/src/as2fm/scxml_converter/scxml_entries/scxml_data.py index 13e18e88..0d857e2b 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_data.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_data.py @@ -24,7 +24,7 @@ from as2fm.as2fm_common.common import is_array_type, is_comment from as2fm.scxml_converter.scxml_entries import BtGetValueInputPort, ScxmlBase -from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler, is_blackboard_reference from as2fm.scxml_converter.scxml_entries.utils import ( convert_string_to_type, get_array_max_size, @@ -203,7 +203,19 @@ def as_plain_scxml(self, _): def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): if isinstance(self._expr, BtGetValueInputPort): self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) + assert not is_blackboard_reference(self._expr), ( + f"Error: SCXML Data: '{self._id}': cannot set the initial expression from " + f" the BT blackboard variable {self._expr}" + ) if isinstance(self._lower_bound, BtGetValueInputPort): self._lower_bound = bt_ports_handler.get_in_port_value(self._lower_bound.get_key_name()) + assert not is_blackboard_reference(self._lower_bound), ( + f"Error: SCXML Data: '{self._id}': cannot set the lower bound from " + f" the BT blackboard variable {self._lower_bound}" + ) if isinstance(self._upper_bound, BtGetValueInputPort): self._upper_bound = bt_ports_handler.get_in_port_value(self._upper_bound.get_key_name()) + assert not is_blackboard_reference(self._upper_bound), ( + f"Error: SCXML Data: '{self._id}': cannot set the upper bound from " + f" the BT blackboard variable {self._upper_bound}" + ) diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_executable_entries.py b/src/as2fm/scxml_converter/scxml_entries/scxml_executable_entries.py index 9c8f9a53..2601eb71 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_executable_entries.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_executable_entries.py @@ -30,7 +30,12 @@ ScxmlParam, ScxmlRosDeclarationsContainer, ) -from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler, is_bt_event +from as2fm.scxml_converter.scxml_entries.bt_utils import ( + BtPortsHandler, + get_input_variable_as_scxml_expression, + is_blackboard_reference, + is_bt_event, +) from as2fm.scxml_converter.scxml_entries.utils import ( CallbackType, get_plain_expression, @@ -77,6 +82,21 @@ def update_exec_body_bt_ports_values( entry.update_bt_ports_values(bt_ports_handler) +def has_bt_blackboard_input( + exec_body: Optional[ScxmlExecutionBody], bt_ports_info: BtPortsHandler +) -> bool: + """ + Check if any entry in the execution body requires reading from the blackboard. + """ + if exec_body is None: + return False + for entry in exec_body: + # If any entry in the executable body requires reading from the blackboard, report it + if entry.has_bt_blackboard_input(bt_ports_info): + return True + return False + + class ScxmlIf(ScxmlBase): """This class represents SCXML conditionals.""" @@ -154,6 +174,13 @@ def get_else_execution(self) -> ScxmlExecutionBody: """Get the else execution.""" return self._else_execution + def has_bt_blackboard_input(self, bt_ports_handler: BtPortsHandler): + """Check whether the If entry reads content from the BT Blackboard.""" + for _, cond_body in self._conditional_executions: + if has_bt_blackboard_input(cond_body, bt_ports_handler): + return True + return has_bt_blackboard_input(self._else_execution, bt_ports_handler) + def instantiate_bt_events(self, instance_id: int, children_ids: List[int]) -> "ScxmlIf": """Instantiate the behavior tree events in the If action, if available.""" for _, exec_body in self._conditional_executions: @@ -205,6 +232,13 @@ def set_thread_id(self, thread_id: int) -> None: if hasattr(entry, "set_thread_id"): entry.set_thread_id(thread_id) + def is_plain_scxml(self) -> bool: + if type(self) is ScxmlIf: + return all( + is_plain_execution_body(body) for _, body in self._conditional_executions + ) and is_plain_execution_body(self._else_execution) + return False + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "ScxmlIf": assert self._cb_type is not None, "Error: SCXML if: callback type not set." conditional_executions = [] @@ -302,6 +336,13 @@ def set_target_automaton(self, target_automaton: str) -> None: """Set the target automata associated to this send event.""" self._target_automaton = target_automaton + def has_bt_blackboard_input(self, bt_ports_handler: BtPortsHandler): + """Check whether the If entry reads content from the BT Blackboard.""" + for param in self._params: + if param.has_bt_blackboard_input(bt_ports_handler): + return True + return False + def instantiate_bt_events(self, instance_id: int, _) -> "ScxmlSend": """Instantiate the behavior tree events in the send action, if available.""" # Support for deprecated BT events handling. Remove the whole if block once transition done. @@ -354,6 +395,11 @@ def append_param(self, param: ScxmlParam) -> None: assert isinstance(param, ScxmlParam), "Error: SCXML send: invalid param." self._params.append(param) + def is_plain_scxml(self) -> bool: + if type(self) is ScxmlSend: + return all(isinstance(param.get_expr(), str) for param in self._params) + return False + def as_plain_scxml(self, _) -> "ScxmlSend": # For now we don't need to do anything here. Change this to handle ros expr in scxml params. assert self._cb_type is not None, "Error: SCXML send: callback type not set." @@ -413,6 +459,12 @@ def get_expr(self) -> Union[str, BtGetValueInputPort]: """Get the expression to assign.""" return self._expr + def has_bt_blackboard_input(self, bt_ports_handler: BtPortsHandler): + """Check whether the If entry reads content from the BT Blackboard.""" + return isinstance(self._expr, BtGetValueInputPort) and is_blackboard_reference( + bt_ports_handler.get_port_value(self._expr.get_key_name()) + ) + def instantiate_bt_events(self, _, __) -> "ScxmlAssign": """This functionality is not needed in this class.""" return self @@ -420,7 +472,9 @@ def instantiate_bt_events(self, _, __) -> "ScxmlAssign": def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: """Update the values of potential entries making use of BT ports.""" if isinstance(self._expr, BtGetValueInputPort): - self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) + self._expr = get_input_variable_as_scxml_expression( + bt_ports_handler.get_port_value(self._expr.get_key_name()) + ) def check_validity(self) -> bool: # TODO: Check that the location to assign exists in the data-model @@ -433,6 +487,11 @@ def check_valid_ros_instantiations(self, _) -> bool: # This has nothing to do with ROS. Return always True return True + def is_plain_scxml(self) -> bool: + if type(self) is ScxmlAssign: + return isinstance(self._expr, str) + return False + def as_plain_scxml(self, _) -> "ScxmlAssign": # TODO: Might make sense to check if the assignment happens in a topic callback assert self._cb_type is not None, "Error: SCXML assign: callback type not set." @@ -548,6 +607,13 @@ def set_execution_body_callback_type(exec_body: ScxmlExecutionBody, cb_type: Cal entry.set_callback_type(cb_type) +def is_plain_execution_body(exec_body: Optional[ScxmlExecutionBody]) -> bool: + """Check if al entries in the exec body are plain scxml.""" + if exec_body is None: + return True + return all(entry.is_plain_scxml() for entry in exec_body) + + def as_plain_execution_body( exec_body: Optional[ScxmlExecutionBody], ros_declarations: ScxmlRosDeclarationsContainer ) -> Optional[ScxmlExecutionBody]: diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_param.py b/src/as2fm/scxml_converter/scxml_entries/scxml_param.py index c9388a05..1127e938 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_param.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_param.py @@ -22,7 +22,11 @@ from lxml import etree as ET from as2fm.scxml_converter.scxml_entries import BtGetValueInputPort, ScxmlBase -from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from as2fm.scxml_converter.scxml_entries.bt_utils import ( + BtPortsHandler, + get_input_variable_as_scxml_expression, + is_blackboard_reference, +) from as2fm.scxml_converter.scxml_entries.utils import CallbackType, is_non_empty_string from as2fm.scxml_converter.scxml_entries.xml_utils import ( assert_xml_tag_ok, @@ -59,7 +63,7 @@ def __init__( """ Initialize the SCXML Parameter object. - The location entryu is kept for consistency, but using expr achieves the same result. + The 'location' entry is kept for consistency, but using expr achieves the same result. :param name: The name of the parameter. :param expr: The expression to assign to the parameter. Can come from a BT port. @@ -77,16 +81,23 @@ def set_callback_type(self, cb_type: CallbackType): def get_name(self) -> str: return self._name - def get_expr(self) -> Optional[str]: + def get_expr(self) -> Optional[Union[BtGetValueInputPort, str]]: return self._expr def get_location(self) -> Optional[str]: return self._location + def has_bt_blackboard_input(self, bt_ports_handler: BtPortsHandler): + return isinstance(self._expr, BtGetValueInputPort) and is_blackboard_reference( + bt_ports_handler.get_port_value(self._expr.get_key_name()) + ) + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): """Update the values of potential entries making use of BT ports.""" if isinstance(self._expr, BtGetValueInputPort): - self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) + self._expr = get_input_variable_as_scxml_expression( + bt_ports_handler.get_port_value(self._expr.get_key_name()) + ) def check_validity(self) -> bool: valid_name = is_non_empty_string(ScxmlParam, "name", self._name) diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_root.py b/src/as2fm/scxml_converter/scxml_entries/scxml_root.py index 7a144dea..e3d39d6f 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_root.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_root.py @@ -235,6 +235,17 @@ def set_bt_ports_values(self, ports_values: List[Tuple[str, str]]): for port_name, port_value in ports_values: self.set_bt_port_value(port_name, port_value) + def get_bt_ports_types_values(self) -> List[Tuple[str, str, str]]: + """ + Get information about the BT ports in the model. + + :return: A list of Tuples containing bt_port_name, type and value. + """ + return [ + (p_name, p_type, p_value) + for p_name, (p_type, p_value) in self._bt_ports_handler.get_all_ports().items() + ] + def append_bt_child_id(self, child_id: int): """Append a child ID to the list of child IDs.""" assert isinstance(child_id, int), "Error: SCXML root: invalid child ID type." @@ -253,9 +264,14 @@ def instantiate_bt_information(self): ros_decl_scxml.update_bt_ports_values(self._bt_ports_handler) for scxml_thread in self._additional_threads: scxml_thread.update_bt_ports_values(self._bt_ports_handler) + processed_states: List[ScxmlState] = [] for state in self._states: - state.instantiate_bt_events(self._bt_plugin_id, self._bt_children_ids) - state.update_bt_ports_values(self._bt_ports_handler) + processed_states.extend( + state.instantiate_bt_events( + self._bt_plugin_id, self._bt_children_ids, self._bt_ports_handler + ) + ) + self._states = processed_states def _generate_ros_declarations_helper(self) -> Optional[ScxmlRosDeclarationsContainer]: """Generate a HelperRosDeclarations object from the existing ROS declarations.""" @@ -311,11 +327,11 @@ def _check_valid_ros_declarations(self) -> bool: return True def is_plain_scxml(self) -> bool: - """Check whether there are ROS specific features or all entries are plain SCXML.""" + """Check whether there are ROS or BT specific tags in the SCXML model.""" assert self.check_validity(), "SCXML: found invalid root object." - has_ros_entries = len(self._ros_declarations) > 0 or len(self._additional_threads) > 0 - has_bt_entries = any(state.has_bt_tick_transitions() for state in self._states) - return not (has_ros_entries or has_bt_entries) + no_ros_declarations = (len(self._ros_declarations) + len(self._additional_threads)) == 0 + all_states_plain = all(state.is_plain_scxml() for state in self._states) + return no_ros_declarations and all_states_plain def to_plain_scxml_and_declarations( self, diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_ros_action_client.py b/src/as2fm/scxml_converter/scxml_entries/scxml_ros_action_client.py index 61d43b6c..1e5ea105 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_ros_action_client.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_ros_action_client.py @@ -33,6 +33,7 @@ generate_action_result_handle_event, is_action_type_known, ) +from as2fm.scxml_converter.scxml_entries.scxml_executable_entries import execution_body_from_xml from as2fm.scxml_converter.scxml_entries.scxml_ros_base import ( RosCallback, RosDeclaration, @@ -104,6 +105,9 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosActionHandleGoalResponse": action_name = get_xml_argument(RosActionHandleGoalResponse, xml_tree, "name") accept_target = get_xml_argument(RosActionHandleGoalResponse, xml_tree, "accept") reject_target = get_xml_argument(RosActionHandleGoalResponse, xml_tree, "reject") + assert ( + len(execution_body_from_xml(xml_tree)) == 0 + ), "Error: SCXML RosActionHandleGoalResponse can not have an execution body." return RosActionHandleGoalResponse(action_name, accept_target, reject_target) def __init__( @@ -144,6 +148,10 @@ def update_bt_ports_values(self, _) -> None: # We do not expect a body with BT ports to be substituted pass + def has_bt_blackboard_input(self, _) -> bool: + """This can not have a body, so it can not have BT blackboard input.""" + return False + def check_valid_ros_instantiations( self, ros_declarations: ScxmlRosDeclarationsContainer ) -> bool: diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_ros_base.py b/src/as2fm/scxml_converter/scxml_entries/scxml_ros_base.py index ca4c9fd7..482afa24 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_ros_base.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_ros_base.py @@ -30,7 +30,7 @@ ScxmlSend, ScxmlTransition, ) -from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler, is_blackboard_reference from as2fm.scxml_converter.scxml_entries.scxml_executable_entries import ( as_plain_execution_body, execution_body_from_xml, @@ -140,9 +140,11 @@ def check_valid_instantiation(self) -> bool: def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: """Update the values of potential entries making use of BT ports.""" if isinstance(self._interface_name, BtGetValueInputPort): - self._interface_name = bt_ports_handler.get_in_port_value( - self._interface_name.get_key_name() - ) + port_value = bt_ports_handler.get_in_port_value(self._interface_name.get_key_name()) + assert not is_blackboard_reference( + port_value + ), f"Error: SCXML {self.__class__.__name__}: interface can't come from BT Blackboard." + self._interface_name = port_value def as_plain_scxml(self, _) -> ScxmlBase: # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot @@ -368,6 +370,13 @@ def append_field(self, field: RosField) -> None: field.set_callback_type(self._cb_type) self._fields.append(field) + def has_bt_blackboard_input(self, bt_ports_handler: BtPortsHandler): + """Check whether the If entry reads content from the BT Blackboard.""" + for field in self._fields: + if field.has_bt_blackboard_input(bt_ports_handler): + return True + return False + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): """Update the values of potential entries making use of BT ports.""" for field in self._fields: diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_ros_field.py b/src/as2fm/scxml_converter/scxml_entries/scxml_ros_field.py index 1f69e9a5..5bfd1064 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_ros_field.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_ros_field.py @@ -20,7 +20,6 @@ from lxml import etree as ET from as2fm.scxml_converter.scxml_entries import BtGetValueInputPort, ScxmlParam -from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler from as2fm.scxml_converter.scxml_entries.utils import ( ROS_FIELD_PREFIX, CallbackType, @@ -64,11 +63,6 @@ def check_validity(self) -> bool: ) return valid_name and valid_expr - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): - """Update the values of potential entries making use of BT ports.""" - if isinstance(self._expr, BtGetValueInputPort): - self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) - def as_plain_scxml(self, _) -> ScxmlParam: # In order to distinguish the message body from additional entries, add a prefix to the name assert ( diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_state.py b/src/as2fm/scxml_converter/scxml_entries/scxml_state.py index c03c9dc0..10b5272a 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_state.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_state.py @@ -23,18 +23,24 @@ from as2fm.as2fm_common.common import is_comment from as2fm.scxml_converter.scxml_entries import ( - BtTick, ScxmlBase, ScxmlExecutableEntry, ScxmlExecutionBody, ScxmlRosDeclarationsContainer, + ScxmlSend, ScxmlTransition, ) -from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from as2fm.scxml_converter.scxml_entries.bt_utils import ( + BT_BLACKBOARD_GET, + BT_BLACKBOARD_REQUEST, + BtPortsHandler, +) from as2fm.scxml_converter.scxml_entries.scxml_executable_entries import ( as_plain_execution_body, execution_body_from_xml, + has_bt_blackboard_input, instantiate_exec_body_bt_events, + is_plain_execution_body, set_execution_body_callback_type, valid_execution_body, ) @@ -142,8 +148,39 @@ def set_thread_id(self, thread_idx: int): if hasattr(entry, "set_thread_id"): entry.set_thread_id(thread_idx) - def instantiate_bt_events(self, instance_id: int, children_ids: List[int]) -> None: - """Instantiate the BT events in all entries belonging to a state.""" + def _generate_blackboard_retrieval( + self, bt_ports_handler: BtPortsHandler + ) -> List["ScxmlState"]: + generated_states: List[ScxmlState] = [self] + assert not has_bt_blackboard_input(self._on_entry, bt_ports_handler), ( + f"Error: SCXML state {self.get_id()}: reading blackboard variables from onentry. " + "This isn't yet supported." + ) + assert not has_bt_blackboard_input(self._on_exit, bt_ports_handler), ( + f"Error: SCXML state {self.get_id()}: reading blackboard variables from onexit. " + "This isn't yet supported." + ) + for transition in self._body: + if transition.has_bt_blackboard_input(bt_ports_handler): + # Prepare the new state using the received BT info + states_count = len(generated_states) + new_state_id = f"{self.get_id()}_{transition.get_tag_name()}_{states_count}" + new_state = ScxmlState(new_state_id) + blackboard_transition = ScxmlTransition( + transition.get_target_state_id(), + [BT_BLACKBOARD_GET], + body=transition.get_body(), + ) + new_state.add_transition(blackboard_transition) + generated_states.append(new_state) + # Set the new target and body to the original transition + transition.set_target_state_id(new_state_id) + transition.set_body([ScxmlSend(BT_BLACKBOARD_REQUEST)]) + return generated_states + + def _substitute_bt_events_and_ports( + self, instance_id: int, children_ids: List[int], bt_ports_handler: BtPortsHandler + ) -> None: instantiated_transitions: List[ScxmlTransition] = [] for transition in self._body: new_transitions = transition.instantiate_bt_events(instance_id, children_ids) @@ -154,8 +191,9 @@ def instantiate_bt_events(self, instance_id: int, children_ids: List[int]) -> No self._body = instantiated_transitions instantiate_exec_body_bt_events(self._on_entry, instance_id, children_ids) instantiate_exec_body_bt_events(self._on_exit, instance_id, children_ids) + self._update_bt_ports_values(bt_ports_handler) - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + def _update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: """Update the values of potential entries making use of BT ports.""" for transition in self._body: transition.update_bt_ports_values(bt_ports_handler) @@ -164,6 +202,15 @@ def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: for entry in self._on_exit: entry.update_bt_ports_values(bt_ports_handler) + def instantiate_bt_events( + self, instance_id: int, children_ids: List[int], bt_ports_handler: BtPortsHandler + ) -> List["ScxmlState"]: + """Instantiate the BT events in all entries belonging to a state.""" + generated_states = self._generate_blackboard_retrieval(bt_ports_handler) + for state in generated_states: + state._substitute_bt_events_and_ports(instance_id, children_ids, bt_ports_handler) + return generated_states + def add_transition(self, transition: ScxmlTransition) -> None: self._body.append(transition) @@ -229,9 +276,12 @@ def _check_valid_ros_instantiations( entry.check_valid_ros_instantiations(ros_declarations) for entry in body ) - def has_bt_tick_transitions(self) -> bool: - """Check if the state has BT tick transitions.""" - return any(isinstance(entry, BtTick) for entry in self._body) + def is_plain_scxml(self) -> bool: + """Check if all SCXML entries in the state are plain scxml.""" + plain_entry = is_plain_execution_body(self._on_entry) + plain_exit = is_plain_execution_body(self._on_exit) + plain_body = all(transition.is_plain_scxml() for transition in self._body) + return plain_entry and plain_exit and plain_body def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "ScxmlState": """Convert the ROS-specific entries to be plain SCXML""" diff --git a/src/as2fm/scxml_converter/scxml_entries/scxml_transition.py b/src/as2fm/scxml_converter/scxml_entries/scxml_transition.py index d853139f..1cb6a81d 100644 --- a/src/as2fm/scxml_converter/scxml_entries/scxml_transition.py +++ b/src/as2fm/scxml_converter/scxml_entries/scxml_transition.py @@ -31,7 +31,9 @@ from as2fm.scxml_converter.scxml_entries.bt_utils import BtPortsHandler, is_bt_event from as2fm.scxml_converter.scxml_entries.scxml_executable_entries import ( execution_body_from_xml, + has_bt_blackboard_input, instantiate_exec_body_bt_events, + is_plain_execution_body, set_execution_body_callback_type, valid_execution_body, valid_execution_body_entry_types, @@ -105,6 +107,9 @@ def get_target_state_id(self) -> str: """Return the ID of the target state of this transition.""" return self._target + def set_target_state_id(self, state_id: str): + self._target = state_id + def get_events(self) -> List[str]: """Return the events that trigger this transition (if any).""" return self._events @@ -121,6 +126,9 @@ def set_body(self, body: ScxmlExecutionBody) -> None: """Set the body of this transition.""" self._body = body + def has_bt_blackboard_input(self, bt_ports_handler: BtPortsHandler): + return has_bt_blackboard_input(self._body, bt_ports_handler) + def instantiate_bt_events( self, instance_id: int, children_ids: List[int] ) -> List["ScxmlTransition"]: @@ -197,6 +205,10 @@ def set_thread_id(self, thread_id: int) -> None: if hasattr(entry, "set_thread_id"): entry.set_thread_id(thread_id) + def is_plain_scxml(self) -> bool: + """Check if the transition is a plain scxml entry and contains only plain scxml.""" + return type(self) is ScxmlTransition and is_plain_execution_body(self._body) + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "ScxmlTransition": assert isinstance( ros_declarations, ScxmlRosDeclarationsContainer diff --git a/test/as2fm_common/test_utilities_smc_storm.py b/test/as2fm_common/test_utilities_smc_storm.py index 7bae2b04..e35e9959 100644 --- a/test/as2fm_common/test_utilities_smc_storm.py +++ b/test/as2fm_common/test_utilities_smc_storm.py @@ -38,7 +38,7 @@ def _interpret_output(output: str, expected_content: List[str], not_expected_con def _run_smc_storm(args: str) -> Tuple[str, str, int]: """Run smc_storm with the given arguments and return the stdout, stderr and return code.""" - command = f"smc_storm {args} --max-trace-length 10000 --max-n-traces 10000" + command = f"smc_storm {args}" print("Running command: ", command) with subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, universal_newlines=True diff --git a/test/jani_generator/_test_data/battery_example/output_GROUND_TRUTH.jani b/test/jani_generator/_test_data/battery_example/output_GROUND_TRUTH.jani index d64d2935..c6b0c02f 100644 --- a/test/jani_generator/_test_data/battery_example/output_GROUND_TRUTH.jani +++ b/test/jani_generator/_test_data/battery_example/output_GROUND_TRUTH.jani @@ -2,7 +2,10 @@ "jani-version": 1, "name": "", "type": "mdp", - "features": ["arrays", "trigonometric-functions"], + "features": [ + "arrays", + "trigonometric-functions" + ], "metadata": { "description": "Autogenerated with CONVINCE toolchain" }, @@ -29,10 +32,10 @@ "name": "level_on_send" }, { - "name": "use_battery-first-exec-use_battery-766fa6e4" + "name": "use_battery-first-exec-use_battery-parent-766fa6e4" }, { - "name": "use_battery-use_battery-cf7e7c41" + "name": "use_battery-use_battery-parent-cf7e7c41" } ], "automata": [ @@ -74,7 +77,7 @@ ] } ], - "action": "use_battery-use_battery-cf7e7c41" + "action": "use_battery-use_battery-parent-cf7e7c41" }, { "location": "use_battery-1-cf7e7c41", @@ -105,7 +108,7 @@ "assignments": [] } ], - "action": "use_battery-first-exec-use_battery-766fa6e4" + "action": "use_battery-first-exec-use_battery-parent-766fa6e4" }, { "location": "use_battery-first-exec-0-766fa6e4", @@ -252,17 +255,17 @@ ] }, { - "result": "use_battery-first-exec-use_battery-766fa6e4", + "result": "use_battery-first-exec-use_battery-parent-766fa6e4", "synchronise": [ - "use_battery-first-exec-use_battery-766fa6e4", + "use_battery-first-exec-use_battery-parent-766fa6e4", null, null ] }, { - "result": "use_battery-use_battery-cf7e7c41", + "result": "use_battery-use_battery-parent-cf7e7c41", "synchronise": [ - "use_battery-use_battery-cf7e7c41", + "use_battery-use_battery-parent-cf7e7c41", null, null ] diff --git a/test/jani_generator/_test_data/blackboard_test/bt.xml b/test/jani_generator/_test_data/blackboard_test/bt.xml new file mode 100644 index 00000000..4712d2dd --- /dev/null +++ b/test/jani_generator/_test_data/blackboard_test/bt.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/jani_generator/_test_data/blackboard_test/bt_read_bb.scxml b/test/jani_generator/_test_data/blackboard_test/bt_read_bb.scxml new file mode 100644 index 00000000..bb96244b --- /dev/null +++ b/test/jani_generator/_test_data/blackboard_test/bt_read_bb.scxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/blackboard_test/bt_write_bb.scxml b/test/jani_generator/_test_data/blackboard_test/bt_write_bb.scxml new file mode 100644 index 00000000..74781bcb --- /dev/null +++ b/test/jani_generator/_test_data/blackboard_test/bt_write_bb.scxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/blackboard_test/main.xml b/test/jani_generator/_test_data/blackboard_test/main.xml new file mode 100644 index 00000000..9b822905 --- /dev/null +++ b/test/jani_generator/_test_data/blackboard_test/main.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/blackboard_test/properties.jani b/test/jani_generator/_test_data/blackboard_test/properties.jani new file mode 100644 index 00000000..1094fe1c --- /dev/null +++ b/test/jani_generator/_test_data/blackboard_test/properties.jani @@ -0,0 +1,26 @@ +{ + "properties": [ + { + "name": "tree_success", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "=", + "left": "bt_1000_response.status", + "right": 1, + "comment": "Bt 1000 is always the root, Values = {1: SUCCESS, 2: FAILURE, 3: RUNNING}" + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/bt.xml b/test/jani_generator/_test_data/grid_robot_blackboard/bt.xml new file mode 100644 index 00000000..e7a9668b --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/bt.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/bt_move.scxml b/test/jani_generator/_test_data/grid_robot_blackboard/bt_move.scxml new file mode 100644 index 00000000..9462122c --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/bt_move.scxml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/bt_shall_move.scxml b/test/jani_generator/_test_data/grid_robot_blackboard/bt_shall_move.scxml new file mode 100644 index 00000000..90a8a178 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/bt_shall_move.scxml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/bt_update_goal_and_current_position.scxml b/test/jani_generator/_test_data/grid_robot_blackboard/bt_update_goal_and_current_position.scxml new file mode 100644 index 00000000..474fe424 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/bt_update_goal_and_current_position.scxml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/main.xml b/test/jani_generator/_test_data/grid_robot_blackboard/main.xml new file mode 100644 index 00000000..68f8d889 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/main.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/properties.jani b/test/jani_generator/_test_data/grid_robot_blackboard/properties.jani new file mode 100644 index 00000000..591adf9f --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/properties.jani @@ -0,0 +1,59 @@ +{ + "properties": [ + { + "name": "tree_success", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "=", + "left": "bt_1000_response.status", + "right": 1, + "comment": "Bt 1000 is always the root, Values = {1: SUCCESS, 2: FAILURE, 3: RUNNING}" + } + } + }, + "states": { + "op": "initial" + } + } + }, + { + "name": "at_goal", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "∧", + "left": "topic_goal_msg.valid", + "right": { + "op": "∧", + "left": { + "op": "=", + "left": "topic_pose_msg.ros_fields__x", + "right": "topic_goal_msg.ros_fields__x" + }, + "right": { + "op": "=", + "left": "topic_pose_msg.ros_fields__y", + "right": "topic_goal_msg.ros_fields__y" + } + } + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} diff --git a/test/jani_generator/_test_data/grid_robot_blackboard/world.scxml b/test/jani_generator/_test_data/grid_robot_blackboard/world.scxml new file mode 100644 index 00000000..8e7ecf02 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard/world.scxml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt.xml b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt.xml new file mode 100644 index 00000000..9472dc78 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_move.scxml b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_move.scxml new file mode 100644 index 00000000..9ed117cd --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_move.scxml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_shall_move.scxml b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_shall_move.scxml new file mode 100644 index 00000000..90a8a178 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_shall_move.scxml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_update_goal_and_current_position.scxml b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_update_goal_and_current_position.scxml new file mode 100644 index 00000000..3f56fcb5 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard_simple/bt_update_goal_and_current_position.scxml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard_simple/main.xml b/test/jani_generator/_test_data/grid_robot_blackboard_simple/main.xml new file mode 100644 index 00000000..0c3c9171 --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard_simple/main.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/grid_robot_blackboard_simple/properties.jani b/test/jani_generator/_test_data/grid_robot_blackboard_simple/properties.jani new file mode 100644 index 00000000..1094fe1c --- /dev/null +++ b/test/jani_generator/_test_data/grid_robot_blackboard_simple/properties.jani @@ -0,0 +1,26 @@ +{ + "properties": [ + { + "name": "tree_success", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "=", + "left": "bt_1000_response.status", + "right": 1, + "comment": "Bt 1000 is always the root, Values = {1: SUCCESS, 2: FAILURE, 3: RUNNING}" + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} diff --git a/test/jani_generator/_test_data/uc1_docking/bt_publish_empty_msg.scxml b/test/jani_generator/_test_data/uc1_docking/bt_publish_empty_msg.scxml deleted file mode 100644 index 263a182c..00000000 --- a/test/jani_generator/_test_data/uc1_docking/bt_publish_empty_msg.scxml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/test/jani_generator/_test_data/uc1_docking/main.xml b/test/jani_generator/_test_data/uc1_docking/main.xml index 0540a327..6b6cd750 100644 --- a/test/jani_generator/_test_data/uc1_docking/main.xml +++ b/test/jani_generator/_test_data/uc1_docking/main.xml @@ -13,7 +13,6 @@ - diff --git a/test/jani_generator/_test_data/uc1_docking/main_with_problem.xml b/test/jani_generator/_test_data/uc1_docking/main_with_problem.xml index 3fa69a13..941aa1b3 100644 --- a/test/jani_generator/_test_data/uc1_docking/main_with_problem.xml +++ b/test/jani_generator/_test_data/uc1_docking/main_with_problem.xml @@ -13,7 +13,6 @@ - diff --git a/test/jani_generator/_test_data/uc1_docking/policy.xml b/test/jani_generator/_test_data/uc1_docking/policy.xml index 7c61812e..11cbb5e2 100644 --- a/test/jani_generator/_test_data/uc1_docking/policy.xml +++ b/test/jani_generator/_test_data/uc1_docking/policy.xml @@ -1,24 +1,19 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/test/jani_generator/_test_data/uc1_docking/properties.jani b/test/jani_generator/_test_data/uc1_docking/properties.jani index 57f65d54..8df0aa58 100644 --- a/test/jani_generator/_test_data/uc1_docking/properties.jani +++ b/test/jani_generator/_test_data/uc1_docking/properties.jani @@ -38,7 +38,12 @@ "op": "Pmin", "exp": { "op": "F", - "exp": "topic_tree_succeeded_msg.valid" + "exp": { + "op": "=", + "left": "bt_1000_response.status", + "right": 1, + "comment": "1: SUCCESS, 2: FAILURE, 3: RUNNING" + } } }, "states": { diff --git a/test/jani_generator/test_systemtest_scxml_to_jani.py b/test/jani_generator/test_systemtest_scxml_to_jani.py index 5a69f6d8..cc7020f2 100644 --- a/test/jani_generator/test_systemtest_scxml_to_jani.py +++ b/test/jani_generator/test_systemtest_scxml_to_jani.py @@ -213,6 +213,7 @@ def _test_with_main( skip_smc: bool = False, property_name: str, success: bool, + size_limit: int = 10_000, ): """ Testing the conversion of the model xml file with the entrypoint. @@ -257,7 +258,8 @@ def _test_with_main( pos_res = "Result: 1" if success else "Result: 0" neg_res = "Result: 0" if success else "Result: 1" run_smc_storm_with_output( - f"--model {output_path} --properties-names {property_name}", + f"--model {output_path} --properties-names {property_name} " + + f"--max-trace-length {size_limit} --max-n-traces {size_limit}", [property_name, output_path, pos_res], [neg_res], ) @@ -434,6 +436,34 @@ def test_uc2_assembly_with_bug(self): success=False, ) + def test_blackboard_features(self): + """Test the blackboard features.""" + self._test_with_main( + "blackboard_test", + model_xml="main.xml", + property_name="tree_success", + success=True, + ) + + def test_grid_robot_blackboard(self): + """Test the grid_robot_blackboard model (BT + Blackboard).""" + self._test_with_main( + "grid_robot_blackboard", + model_xml="main.xml", + property_name="at_goal", + success=True, + ) + + def test_grid_robot_blackboard_simple(self): + """Test the simpler grid_robot_blackboard model (BT + Blackboard).""" + self._test_with_main( + "grid_robot_blackboard_simple", + model_xml="main.xml", + property_name="tree_success", + success=True, + size_limit=1_000_000, + ) + def test_command_line_output_with_line_numbers(self): """Test the command line output with line numbers for the main.xml file.""" tmp_test_dir = os.path.join("/tmp", "test_as2fm") diff --git a/test/jani_generator/test_unittest_expression_expansion.py b/test/jani_generator/test_unittest_expression_expansion.py new file mode 100644 index 00000000..7ca8e3b4 --- /dev/null +++ b/test/jani_generator/test_unittest_expression_expansion.py @@ -0,0 +1,141 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""""Test the SCXML data conversion""" + + +import pytest + +from as2fm.jani_generator.jani_entries import generate_jani_expression +from as2fm.jani_generator.jani_entries.jani_convince_expression_expansion import ( + expand_distribution_expressions, +) + + +def test_jani_expression_expansion_no_distribution(): + """ + Test the expansion of an expression containing no distribution (should stay the same). + """ + jani_entry = generate_jani_expression(5) + jani_expressions = expand_distribution_expressions(jani_entry) + assert len(jani_expressions) == 1, "Expression without distribution should not be expanded!" + assert jani_entry.as_dict() == jani_expressions[0].as_dict() + jani_entry = generate_jani_expression( + {"op": "*", "left": 2, "right": {"op": "floor", "exp": 1.1}} + ) + jani_expressions = expand_distribution_expressions(jani_entry) + assert len(jani_expressions) == 1, "Expression without distribution should not be expanded!" + assert jani_entry.as_dict() == jani_expressions[0].as_dict() + + +def test_jani_expression_expansion_distribution(): + """ + Test the expansion of an expression with only a distribution. + """ + # Simplest case, just a distribution. Boundaries are included + n_options = 101 + jani_distribution = generate_jani_expression({"distribution": "Uniform", "args": [1.0, 3.0]}) + jani_expressions = expand_distribution_expressions(jani_distribution, n_options=n_options) + assert len(jani_expressions) == n_options, "Base distribution was not expanded!" + assert all(expr.as_literal() is not None for expr in jani_expressions) + assert jani_expressions[0].as_literal().value() == pytest.approx(1.0) + assert jani_expressions[100].as_literal().value() == pytest.approx(3.0) + assert jani_expressions[10].as_literal().value() == pytest.approx(1.2) + # Test a non trivial expression + jani_distribution = generate_jani_expression( + { + "op": "floor", + "exp": { + "op": "*", + "left": {"distribution": "Uniform", "args": [0.0, 1.0]}, + "right": 20, + }, + } + ) + jani_expressions = expand_distribution_expressions(jani_distribution, n_options=n_options) + assert len(jani_expressions) == n_options, "Base distribution was not expanded!" + assert jani_expressions[0].as_dict() == { + "op": "floor", + "exp": {"op": "*", "left": 0.0, "right": 20}, + } + assert jani_expressions[10].as_dict() == { + "op": "floor", + "exp": {"op": "*", "left": 0.1, "right": 20}, + } + assert jani_expressions[100].as_dict() == { + "op": "floor", + "exp": {"op": "*", "left": 1.0, "right": 20}, + } + + +def test_jani_expression_expansion_expr_with_multiple_distribution(): + """ + Test the expansion of complex expressions with multiple distributions. + """ + # Multiple distributions at the same level + n_options = 21 + jani_distribution = generate_jani_expression( + { + "op": "floor", + "exp": { + "op": "*", + "left": {"distribution": "Uniform", "args": [0.0, 20.0]}, + "right": {"distribution": "Uniform", "args": [0.0, 10.0]}, + }, + } + ) + jani_expressions = expand_distribution_expressions(jani_distribution, n_options=n_options) + assert len(jani_expressions) == n_options**2, "Base distribution was not expanded!" + assert jani_expressions[0].as_dict() == { + "op": "floor", + "exp": {"op": "*", "left": 0.0, "right": 0.0}, + } + assert jani_expressions[-1].as_dict() == { + "op": "floor", + "exp": {"op": "*", "left": 20.0, "right": 10.0}, + } + assert jani_expressions[-2].as_dict() == { + "op": "floor", + "exp": {"op": "*", "left": 20.0, "right": 9.5}, + } + # Multiple distributions at a different level + jani_distribution = generate_jani_expression( + { + "op": "*", + "left": {"distribution": "Uniform", "args": [0.0, 20.0]}, + "right": { + "op": "*", + "left": 2, + "right": {"distribution": "Uniform", "args": [0.0, 10.0]}, + }, + } + ) + jani_expressions = expand_distribution_expressions(jani_distribution, n_options=n_options) + assert len(jani_expressions) == n_options**2, "Base distribution was not expanded!" + assert jani_expressions[0].as_dict() == { + "op": "*", + "left": 0.0, + "right": {"op": "*", "left": 2, "right": 0.0}, + } + assert jani_expressions[-1].as_dict() == { + "op": "*", + "left": 20.0, + "right": {"op": "*", "left": 2, "right": 10.0}, + } + assert jani_expressions[1].as_dict() == { + "op": "*", + "left": 0.0, + "right": {"op": "*", "left": 2, "right": 0.5}, + }