diff --git a/main.py b/main.py index 86fc25a4..b2eae54b 100644 --- a/main.py +++ b/main.py @@ -2,8 +2,12 @@ from matrx import cases if __name__ == "__main__": - cases.run_vis_test() + # cases.run_vis_test() + # cases.run_vis_test2() # cases.run_test() # cases.run_simple_case() # cases.run_test_navigators() - # cases.run_bw4t() + cases.run_bw4t() + # cases.run_logger_test() + # cases.run_arg_test() + # cases.run_test_human_agent() diff --git a/matrx/__version__.py b/matrx/__version__.py index 58bac351..ca457ec5 100644 --- a/matrx/__version__.py +++ b/matrx/__version__.py @@ -12,7 +12,7 @@ __url__ = 'https://matrx-software.com' __doc_url__ = 'http://docs.matrx-software.com/en/latest/' __source_url__ = 'https://github.com/matrx-software/matrx' -__version__ = '2.0.8' +__version__ = '2.1.0' __author__ = 'MATRX Team at TNO.nl' __author_email__ = 'info@matrx.com' __license__ = 'MIT License' diff --git a/matrx/actions/action.py b/matrx/actions/action.py index 57646be1..c5d5d0e9 100644 --- a/matrx/actions/action.py +++ b/matrx/actions/action.py @@ -34,7 +34,7 @@ def __init__(self, duration_in_ticks=0): # number of ticks the action takes to complete self.duration_in_ticks = duration_in_ticks - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Method that mutates the world. This method is allowed to mutate a :class:`matrx.grid_world.GridWorld` @@ -44,11 +44,16 @@ def mutate(self, grid_world, agent_id, **kwargs): Parameters ---------- - grid_world + grid_world : GridWorld The GridWorld instance that should be mutated according to the Action's intended purpose. agent_id : string The unique identifier of the agent performing this action. + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. **kwargs The set of keyword arguments provided by the agent that decided upon this action. When overriding this method and setting required @@ -98,7 +103,7 @@ def mutate(self, grid_world, agent_id, **kwargs): """ return None - def is_possible(self, grid_world, agent_id, **kwargs): + def is_possible(self, grid_world, agent_id, world_state, **kwargs): """ Checks if the Action is possible. This method analyses a :class:`matrx.grid_world.GridWorld` instance @@ -112,6 +117,11 @@ def is_possible(self, grid_world, agent_id, **kwargs): is possible. agent_id : string The unique identifier of the agent performing this action. + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. kwargs : dictionary The set of keyword arguments provided by the Agent that decided upon this action. When overriding this method and setting required diff --git a/matrx/actions/door_actions.py b/matrx/actions/door_actions.py index 990b10c0..7375c2f7 100644 --- a/matrx/actions/door_actions.py +++ b/matrx/actions/door_actions.py @@ -32,7 +32,7 @@ class OpenDoorAction(Action): def __init__(self, duration_in_ticks=0): super().__init__(duration_in_ticks) - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Opens a door in the world. Mutates the `door_status` of an @@ -50,11 +50,14 @@ def mutate(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : st - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the door that - should be opened. + should be opened. If none is given, the closest door is selected. door_range : int Optional. Default: ``np.inf`` @@ -89,7 +92,7 @@ def mutate(self, grid_world, agent_id, **kwargs): result = OpenDoorActionResult(OpenDoorActionResult.RESULT_SUCCESS, True) return result - def is_possible(self, grid_world, agent_id, **kwargs): + def is_possible(self, grid_world, agent_id, world_state, **kwargs): """ Check if the OpenDoorAction is possible. Parameters @@ -100,11 +103,14 @@ def is_possible(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the door that - should be opened. + should be opened. If none is given, the closest door is selected. door_range : int Optional. Default: ``np.inf`` @@ -157,7 +163,7 @@ class CloseDoorAction(Action): def __init__(self, duration_in_ticks=0): super().__init__(duration_in_ticks) - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Closes a door in the world. Mutates the `door_status` of an @@ -175,11 +181,14 @@ def mutate(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : st - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the door that - should be closed. + should be opened. If none is given, the closest door is selected. door_range : int Optional. Default: ``np.inf`` @@ -211,7 +220,7 @@ def mutate(self, grid_world, agent_id, **kwargs): result = CloseDoorActionResult(CloseDoorActionResult.RESULT_SUCCESS, True) return result - def is_possible(self, grid_world, agent_id, **kwargs): + def is_possible(self, grid_world, agent_id, world_state, **kwargs): """Check if the CloseDoorAction is possible. Parameters @@ -222,11 +231,14 @@ def is_possible(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the door that - should be closed. + should be opened. If none is given, the closest door is selected. door_range : int Optional. Default: ``np.inf`` @@ -381,7 +393,7 @@ def _is_possible_door_open_close(grid_world, agent_id, action_result, object_id= objects_in_range = grid_world.get_objects_in_range(loc_agent, object_type=Door, sense_range=door_range) # there is no Door in range, so not possible to open any door - if len(objects_in_range) is 0: + if len(objects_in_range) == 0: return action_result(action_result.NO_DOORS_IN_RANGE, False) # if we did not get a specific door to open, we simply return success as it is possible to open an arbitrary door diff --git a/matrx/actions/move_actions.py b/matrx/actions/move_actions.py index c66cb9cf..1a4a8444 100644 --- a/matrx/actions/move_actions.py +++ b/matrx/actions/move_actions.py @@ -245,7 +245,7 @@ def __init__(self, duration_in_ticks=0): self.dx = 0 self.dy = 0 - def is_possible(self, grid_world, agent_id, **kwargs): + def is_possible(self, grid_world, agent_id, world_state, **kwargs): """ Checks if the move is possible. Checks for the following: @@ -263,6 +263,11 @@ def is_possible(self, grid_world, agent_id, **kwargs): agent_id : str The unique identifier for the agent whose location should be changed. + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. **kwargs : dict Not used. @@ -278,7 +283,7 @@ def is_possible(self, grid_world, agent_id, **kwargs): result = _is_possible_movement(grid_world, agent_id=agent_id, dx=self.dx, dy=self.dy) return result - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Mutates an agent's location Changes an agent's location property based on the attributes `dx` and @@ -289,6 +294,11 @@ def mutate(self, grid_world, agent_id, **kwargs): grid_world : GridWorld The :class:`matrx.grid_world.GridWorld` instance in which the agent resides whose location should be updated. + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. agent_id : str The unique identifier for the agent whose location should be changed. diff --git a/matrx/actions/object_actions.py b/matrx/actions/object_actions.py index bcbaeec5..e4be3f71 100644 --- a/matrx/actions/object_actions.py +++ b/matrx/actions/object_actions.py @@ -38,7 +38,7 @@ class RemoveObject(Action): def __init__(self, duration_in_ticks=0): super().__init__(duration_in_ticks) - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Removes the specified object. Removes a specific :class:`matrx.objects.env_object.EnvObject` from @@ -53,17 +53,19 @@ def mutate(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. + object_id: str (Optional. Default: None) The string representing the unique identifier of the :class:`matrx.objects.env_object.EnvObject` that should be + removed. If not given, the closest object is selected. removed. - remove_range : int - Optional. Default: ``1`` - - The range in which the to be removed - :class:`matrx.objects.env_object.EnvObject` should be in. + remove_range : int (Optional. Default: 1) + The range in which the :class:`matrx.objects.env_object.EnvObject` + should be in for it to be removed. Returns ------- @@ -118,17 +120,18 @@ def is_possible(self, grid_world, agent_id, **kwargs): agent_id: str The string representing the unique identified that represents the agent performing this action. - object_id: str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. + object_id: str (Optional. Default: None) The string representing the unique identifier of the :class:`matrx.objects.env_object.EnvObject` that should be - removed. - remove_range : int - Optional. Default: ``1`` - - The range in which the to be removed - :class:`matrx.objects.env_object.EnvObject` should be in. + removed. If not given, the closest object is selected. + remove_range : int (Optional. Default: 1) + The range in which the :class:`matrx.objects.env_object.EnvObject` + should be in for it to be removed. Returns ------- @@ -250,7 +253,7 @@ class GrabObject(Action): def __init__(self, duration_in_ticks=0): super().__init__(duration_in_ticks) - def is_possible(self, grid_world, agent_id, **kwargs): + def is_possible(self, grid_world, agent_id, world_state, **kwargs): """ Checks if the object can be grabbed. Parameters @@ -261,20 +264,19 @@ def is_possible(self, grid_world, agent_id, **kwargs): agent_id: str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the :class:`matrx.objects.env_object.EnvObject` that should be grabbed. When not given, a random object within range is selected. - grab_range : int - Optional. Default: ``np.inf`` - - The range in which the to be grabbed - :class:`matrx.objects.env_object.EnvObject` should be in. - max_objects : int - Optional. Default: ``np.inf`` - + grab_range : int (Optional. Default: np.inf) + The range in which the :class:`matrx.objects.env_object.EnvObject` + should be in to be grabbed. + max_objects : int (Optional. Default: np.inf) The maximum of objects the agent can carry. Returns @@ -295,7 +297,7 @@ def is_possible(self, grid_world, agent_id, **kwargs): return _is_possible_grab(grid_world, agent_id=agent_id, object_id=object_id, grab_range=grab_range, max_objects=max_objects) - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Grabs an object. Alters the properties of the agent doing the grabbing, and the object @@ -314,20 +316,19 @@ def mutate(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the :class:`matrx.objects.env_object.EnvObject` that should be grabbed. When not given, a random object within range is selected. - grab_range : int - Optional. Default: ``np.inf`` - - The range in which the to be grabbed - :class:`matrx.objects.env_object.EnvObject` should be in. - max_objects : int - Optional. Default: ``np.inf`` - + grab_range : int (Optional. Default: np.inf) + The range in which the :class:`matrx.objects.env_object.EnvObject` + should be in to be grabbed. + max_objects : int (Optional. Default: np.inf) The maximum of objects the agent can carry. Returns @@ -481,7 +482,7 @@ class DropObject(Action): def __init__(self, duration_in_ticks=0): super().__init__(duration_in_ticks) - def is_possible(self, grid_world, agent_id, **kwargs): + def is_possible(self, grid_world, agent_id, world_state, **kwargs): """ Checks if the object can be dropped. Parameters @@ -492,17 +493,17 @@ def is_possible(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when checking if an + action can be performed. Note that this is the State of the + entire world, not that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the :class:`matrx.objects.env_object.EnvObject` that should be + dropped. When not given the last object that was grabbed is dropped. - - When not given the last object that was grabbed is dropped. - drop_range : int - Optional. Default: ``np.inf`` - + drop_range : int (Optional. Default: np.inf) The range in which the object can be dropped, with the agent's location at its center. @@ -530,7 +531,7 @@ def is_possible(self, grid_world, agent_id, **kwargs): return _possible_drop(grid_world, agent_id=agent_id, obj_id=obj_id, drop_range=drop_range) - def mutate(self, grid_world, agent_id, **kwargs): + def mutate(self, grid_world, agent_id, world_state, **kwargs): """ Drops the carried object. Parameters @@ -541,18 +542,19 @@ def mutate(self, grid_world, agent_id, **kwargs): agent_id : str The string representing the unique identifier that represents the agent performing this action. - object_id : str - Optional. Default: ``None`` - + world_state : State + The State object representing the entire world. Can be used to + simplify search of objects and properties when performing an + action. Note that this is the State of the entire world, not + that of the agent performing the action. + object_id : str (Optional. Default: None) The string representing the unique identifier of the :class:`matrx.objects.env_object.EnvObject` that should be + dropped. When not given the last object that was grabbed is dropped. - - When not given the last object that was grabbed is dropped. - drop_range : int - Optional. Default: ``np.inf`` - - The range in which the object can be dropped. + drop_range : int (Optional. Default: np.inf) + The range in which the object can be dropped, with the agent's + location at its center. Returns ------- diff --git a/matrx/agents/agent_brain.py b/matrx/agents/agent_brain.py index 0c13f8d8..5a66fe06 100644 --- a/matrx/agents/agent_brain.py +++ b/matrx/agents/agent_brain.py @@ -97,15 +97,31 @@ def __init__(self, memorize_for_ticks=None): self._state = None def initialize(self): - """ To initialize an agent's brain. + """ Method called by any world when it starts. - Method called at the start of a :class:`matrx.grid_world.GridWorld`. + When adding an agent to a :class:`matrx.grid_world.GridWorld`, through + a world builer, you only pass the class of your agent brain, not the + actual instance. Instead, this instance is made by the builder when + a new world is created and ran. At that point this method is called. - Here you can initialize everything you need for your agent to work - since you can't do much in the constructor as the brain needs to be - connected to a GridWorld first in most cases (e.g. to get an AgentID, - its random generator, etc.) + That makes this method the ideal place for any initialization or + reset you want your agent brain to do when starting a world or between + worlds. + + Importantly, this method is called after the builder assigned things + to it such as its location, name and object ID. As this method is + called afterwards, it allows you to do things related to to those + properties. + + An example is when you run the same world multiple times. In that case + the instance of your agent brain will have attributes with values from + the previous run. This method can be used to reset them. """ + self.previous_action = None + self.previous_action_result = None + self.messages_to_send = [] + self.received_messages = [] + self._init_state() def filter_observations(self, state): """ Filters the world state before deciding on an action. @@ -114,8 +130,8 @@ def filter_observations(self, state): properties and objects the agent is actually supposed to see. Currently the world returns ALL properties of ALL objects within a - certain range(s), as specified by : - class:`matrx.agents.capabilities.capability.SenseCapability`. But + certain range(s), as specified by + :class:`matrx.agents.capabilities.capability.SenseCapability`. But perhaps some objects are obscured because they are behind walls and this agent is not supposed to look through walls, or an agent is not able to see some properties of certain objects (e.g. colour). @@ -547,7 +563,7 @@ def _get_action(self, state, agent_properties, agent_id): self.agent_properties = agent_properties # Update the state property of an agent with the GridWorld's state dictionary - self.state.state_update(state) + self.state.state_update(state.as_dict()) # Call the filter method to filter the observation self.state = self.filter_observations(self.state) @@ -558,18 +574,14 @@ def _get_action(self, state, agent_properties, agent_id): # Store the action so in the next call the agent still knows what it did self.previous_action = action - # Get the dictionary from the State object - filtered_state = self.state.as_dict() - # Return the filtered state, the (updated) properties, the intended actions and any keyword arguments for that # action if needed. - return filtered_state, self.agent_properties, action, action_kwargs + return self.state, self.agent_properties, action, action_kwargs - def _fetch_state(self, state_dict): - self.state.state_update(state_dict) - state = self.filter_observations(self.state) - filtered_state_dict = state.as_dict() - return filtered_state_dict + def _fetch_state(self, state): + self.state.state_update(state.as_dict()) + filtered_state = self.filter_observations(self.state) + return filtered_state def _get_log_data(self): return self.get_log_data() diff --git a/matrx/agents/agent_types/human_agent.py b/matrx/agents/agent_types/human_agent.py index 4eb88f4f..c51c1998 100644 --- a/matrx/agents/agent_types/human_agent.py +++ b/matrx/agents/agent_types/human_agent.py @@ -129,7 +129,7 @@ def _get_action(self, state, agent_properties, agent_id, user_input): Parameters ---------- - state : dict + state : State A state description containing all properties of EnvObject that are within a certain range as defined by self.sense_capability. It is a list of properties in a dictionary @@ -160,7 +160,7 @@ def _get_action(self, state, agent_properties, agent_id, user_input): # Update the state property of an agent with the GridWorld's state # dictionary - self.state.state_update(state) + self.state.state_update(state.as_dict()) # Call the filter method to filter the observation self.state = self.filter_observations(self.state) @@ -175,12 +175,9 @@ def _get_action(self, state, agent_properties, agent_id, user_input): # did. self.previous_action = action - # Get the dictionary from the State object - filtered_state = self.state.as_dict() - # Return the filtered state, the (updated) properties, the intended # actions and any keyword arguments for that action if needed. - return filtered_state, self.agent_properties, action, action_kwargs + return self.state, self.agent_properties, action, action_kwargs def decide_on_action(self, state, user_input): """ Contains the decision logic of the agent. diff --git a/matrx/agents/agent_types/patrolling_agent.py b/matrx/agents/agent_types/patrolling_agent.py index f35e9188..f4e3f1fe 100644 --- a/matrx/agents/agent_types/patrolling_agent.py +++ b/matrx/agents/agent_types/patrolling_agent.py @@ -4,8 +4,21 @@ class PatrollingAgentBrain(AgentBrain): + """ A simple agent that moves along a given path. + + """ def __init__(self, waypoints, move_speed=0): + """ Creates an agent brain to move along a set of waypoints. + + Parameters + ---------- + waypoints : list + The list of waypoints as (x,y) grid coordinates for the agent to move along. + move_speed : int (Default: 0) + This many ticks will be between each of the agent's move actions. When 0 or smaller, it will act on every + tick. When 1 or higher, it will wait at least 1 or more ticks before moving again. + """ super().__init__() self.state_tracker = None self.navigator = None @@ -13,6 +26,12 @@ def __init__(self, waypoints, move_speed=0): self.move_speed = move_speed def initialize(self): + """ Resets the agent's to be visited waypoints. + + This method is called each time a new world is created or the same world is reset. This prevents the agent to + remember that it already moved and visited some waypoints. + + """ # Initialize this agent's state tracker self.state_tracker = StateTracker(agent_id=self.agent_id) @@ -22,12 +41,44 @@ def initialize(self): self.navigator.add_waypoints(self.waypoints, is_circular=True) def filter_observations(self, state): + """ Instead of filtering any observations, it just returns the given state. + + This means that the agent has no fancy observation mechanisms. + + Parameters + ---------- + state : State + The state object already filtered on the sensing range of the agent. + + Returns + ------- + dict + The unchanged State instance. + + """ self.state_tracker.update(state) return state def decide_on_action(self, state): + """ Makes use of the navigator to decide upon the next move action to get one step closer to the next waypoint. + + Parameters + ---------- + state : State + The State instance returned from `filter_observations`. In the case of this agent, that is the unchanged + instance from the grid world who filtered only on the sensing range of this agent. + + Returns + ------- + str + The name of the next action. + dict + A dictionary containing any additional arguments for the action to perform. This agent provides the + duration how long its move action should take. + + """ from matrx.messages.message import Message move_action = self.navigator.get_move_action(self.state_tracker) - return move_action, {"action_duration": self.move_speed} \ No newline at end of file + return move_action, {"action_duration": self.move_speed} diff --git a/matrx/agents/agent_utils/navigator.py b/matrx/agents/agent_utils/navigator.py index 5b89b87d..fd6edb79 100644 --- a/matrx/agents/agent_utils/navigator.py +++ b/matrx/agents/agent_utils/navigator.py @@ -8,21 +8,31 @@ class Navigator: - """ A navigator object that can be used for path planning and navigation + """ A navigator object that can be used for path planning and navigation. Parameters ---------- - agent_id: string. - ID of the agent that wants to navigate + agent_id: string + ID of the agent that wants to navigate through a grid world. action_set: list - List of actions the agent can perform + List of actions the agent can perform. algorithm: string. Optional, default "a_star" - The pathplanning algorithm to use. As of now only a_star is supported. - is_circular: Boolean. Optional, default=False. - Whether to continuously navigate from point A to B, and back, until infinity. - """ + The path planning algorithm to use. As of now only A* is supported. + is_circular: bool (Default: False) + When True, it will continuously navigate given waypoints, until infinity. + + Warnings + -------- + This class still depends on the deprecated `StateTracker` instead of the new `State`. Note that the `StateTracker` + is created from a state dictionary that can be obtained from a `State` instance. This is the current workaround to + still make the `Navigator` work with the current `State`. + + Another approach is to not use `State` at all, and only rely on a `StateTracker`. See the + :class:`matrx.agents.agent_types.patrolling_agent.PatrollingAgentBrain` which uses this approach. + """ + """The A* algorithm parameter for path planning.""" A_STAR_ALGORITHM = "a_star" def __init__(self, agent_id, action_set, algorithm=A_STAR_ALGORITHM, is_circular=False): @@ -63,28 +73,106 @@ def __init__(self, agent_id, action_set, algorithm=A_STAR_ALGORITHM, is_circular self.__occupation_map = None def add_waypoint(self, waypoint): + """ Adds a waypoint to the path. + + Parameters + ---------- + waypoint: tuple, list + The (x,y) coordinates of the cell. + + Raises + ------ + AssertError + When the waypoint is not a tuple or list. + + """ + assert isinstance(waypoint, tuple) or isinstance(waypoint, list) wp = Waypoint(loc=waypoint, priority=self.__nr_waypoints) self.__nr_waypoints += 1 self.__waypoints[wp.priority] = wp def add_waypoints(self, waypoints, is_circular=False): + """ Adds multiple waypoints to the path in order. + + Parameters + ---------- + waypoints : list + The list of waypoints of the form (x,y). + is_circular : bool + Whether the navigator should continuously visit all waypoints (including the ones already given before). + + Raises + ------ + AssertError + When the waypoint is not a tuple or list. + + """ self.is_circular = is_circular for waypoint in waypoints: self.add_waypoint(waypoint) def get_all_waypoints(self): + """ Returns all current waypoints stored. + + Returns + ------- + dict + A dictionary with as keys the order and as value the waypoint as (x,y) coordinate. + + """ return [(k, wp.location) for k, wp in self.__waypoints.items()] def get_upcoming_waypoints(self): + """ Returns all waypoints not yet visited. + + Returns + ------- + dict + A dictionary with as keys the order and as value the waypoint as (x,y) coordinate. + + """ return [(k, wp.location) for k, wp in self.__waypoints.items() if not wp.is_visited()] def get_current_waypoint(self): + """ Returns the current waypoint the navigator will try to visit. + + Returns + ------- + list + The (x,y) coordinate of the next waypoint. + + """ wp = self.__waypoints[self.__current_waypoint_idx] return wp.location def get_move_action(self, state_tracker: StateTracker): + """ Returns the next move action. + + It applies the path planning algorithm to identify the next best move action to move closer to the next + waypoint. + + Parameters + ---------- + state_tracker : StateTracker + The state tracker used by the algorithm to determine what (according to what the agent observes) would be + the best possible next move action. + + Returns + ------- + str + The name of the move action the agent is capable of making. + + Raises + ------ + Warning + Raises a warning (not exception) to signal that no path can be found to the next waypoint. + + ValueError + Raised when the agent's location is not found on the route found by the path planning. + + """ # If we are done, don't do anything if self.is_done: return None @@ -124,16 +212,29 @@ def get_move_action(self, state_tracker: StateTracker): return move_action def reset(self): + """ Resets all waypoints to not being visited. + """ self.is_done = False self.__current_waypoint_idx = 0 for wp in self.__waypoints.values(): wp.reset() def reset_full(self): + """ Clears all waypoints to an empty Navigator. + + """ # This function resets the navigator to a new instance self.__init__(self.__agent_id, self.__action_set, self.__algorithm, self.is_circular) def __get_current_waypoint(self): + """ A private MATRX method. + + Returns + ------- + Waypoint + Returns the next waypoint object. + + """ if self.__current_waypoint_idx is None: self.__current_waypoint_idx = 0 @@ -142,12 +243,40 @@ def __get_current_waypoint(self): return wp def __initialize_path_planner(self, algorithm, action_set): + """ A private MATRX method. + + Initializes the correct path planner algorithm. + + Parameters + ---------- + algorithm : str + The name of the algorithm to be used. + action_set : list + The list of actions the agent is capable of taking. + + Raises + ------ + ValueError + When the given algorithm is not known. + + """ if algorithm == self.A_STAR_ALGORITHM: return AStarPlanner(action_set=action_set, metric=AStarPlanner.EUCLIDEAN_METRIC) else: - raise Exception() + raise ValueError(f"The path plannings algorithm {algorithm} is not known.") def __update_waypoints(self, agent_loc): + """ A private MATRX method. + + Updates all is_visited property of waypoints based on the agent's current location. Also sets the navigator + to done when all waypoints are visited. + + Parameters + ---------- + agent_loc : list + The agent's current location as (x,y) + + """ wp = self.__get_current_waypoint() if wp.is_visited(agent_loc): self.__current_waypoint_idx += 1 @@ -156,6 +285,23 @@ def __update_waypoints(self, agent_loc): self.is_done = True def __get_route(self, state_tracker: StateTracker): + """ A private MATRX method. + + Applies the path planner algorithm based on the information in the given state tracker. + + Parameters + ---------- + state_tracker : StateTracker + The state tracker of the agent conducting the path planning. Used to represent all observations of the + agent. + + Returns + ------- + list + A list of action strings the agent should conduct to arrive at the next waypoint. This route is empty when + no path can be found or when all waypoints are already visited. + + """ # Get our agent's location agent_loc = state_tracker.get_memorized_state()[state_tracker.agent_id]['location'] @@ -186,6 +332,25 @@ def __get_route(self, state_tracker: StateTracker): return route def __get_route_from_path(self, agent_loc, path): + """ A private MATRX method. + + Transforms a path of coordinates from the path planning algorithm to a series of actions. + + Parameters + ---------- + agent_loc : tuple + The agent's current location as (x,y). + path : list + List of coordinates that lead to the next waypoint. + + Returns + ------- + list + A list of strings of actions the agent is capable of taking to arrive at each path coordinate from the + previous one (with the first being the agent's current location). + + """ + route = {} curr_loc = agent_loc for idx, loc in enumerate(path): @@ -211,11 +376,40 @@ def __get_route_from_path(self, agent_loc, path): class PathPlanner: + """ A private MATRX class. + + The empty path planner. Future path planning algorithms should implement this class. + + """ def __init__(self, action_set): + """ Initializes the planner given the actions an agent is capable of. + + Parameters + ---------- + action_set : list + The list of actions the agent is capable of performing. + """ self.move_actions = get_move_actions(action_set) def plan(self, start, goal, occupation_map): + """ Plan a route from the start to the goal. + + Parameters + ---------- + start : tuple + The starting (x,y) coordinate. + goal : tuple + The goal (x,y) coordinate. + occupation_map : nparray + The list of lists representing which grid coordinates are blocked and which are not. + + Returns + ------- + list + A list of coordinates the agent should visit in order to reach the goal coordinate. + + """ pass @@ -236,11 +430,25 @@ def __init__(self, action_set, metric=EUCLIDEAN_METRIC): raise Exception(f"The distance metric {metric} for A* heuristic not known.") def plan(self, start, goal, occupation_map): - """ - A star algorithm, returns the shortest path to get from goal to start. + """ Plan a route from the start to the goal. + + A* algorithm, returns the shortest path to get from goal to start. Uses an 2D numpy array, with 0 being traversable, anything else (e.g. 1) not traversable Implementation from: https://www.analytics-link.com/single-post/2018/09/14/Applying-the-A-Path-Finding-Algorithm-in-Python-Part-1-2D-square-grid + + Parameters + ---------- + start : tuple + The starting (x,y) coordinate. + goal : tuple + The goal (x,y) coordinate. + occupation_map : list + The list of lists representing which grid coordinates are blocked and which are not. + + Returns + ------- + """ # possible movements @@ -293,13 +501,42 @@ def plan(self, start, goal, occupation_map): class Waypoint: + """ A private MATRX class. + + Used to represent a navigation waypoint for the Navigator class. + + """ def __init__(self, loc, priority): + """ Creates a waypoint at a certain location with a priority. + + Parameters + ---------- + loc : tuple + The (x,y) coordinate of this waypoint. + priority : int + The priority in which this waypoint should be visited. + """ self.location = loc self.priority = priority self.__is_visited = False def is_visited(self, current_loc=None): + """ Whether this waypoint is visited. + + Parameters + ---------- + current_loc : list (Default: None) + The (x,y) coordinate of an agent. When given, checks if this waypoint is visited by the agent on this + location. Otherwise, performs no check. + + Returns + ------- + bool + Returns True when this waypoint is visited (or just now visited when `current_loc` is given). False + otherwise. + + """ if current_loc is None: return self.__is_visited elif current_loc[0] == self.location[0] and current_loc[1] == self.location[1] and not self.__is_visited: @@ -308,10 +545,26 @@ def is_visited(self, current_loc=None): return self.__is_visited def reset(self): + """ Sets this waypoint as not visited. + """ self.__is_visited = False def get_move_actions(action_set): + """ Returns the names of all move actions in the given agent's action set. + + Parameters + ---------- + action_set : list + The names of all actions an agent can perform. + + Returns + ------- + dict + The dictionary of all move actions that are part of the agent's actions it can perform. The keys are the action + names and values are the delta x and y effects on an agent's location. + + """ move_actions = {} for action_name in action_set: if action_name == MoveNorth.__name__: diff --git a/matrx/agents/agent_utils/state.py b/matrx/agents/agent_utils/state.py index c3661790..ddb8b723 100644 --- a/matrx/agents/agent_utils/state.py +++ b/matrx/agents/agent_utils/state.py @@ -22,6 +22,14 @@ def __init__(self, own_id, memorize_for_ticks=None): def state_update(self, state_dict): + # Check if the given state dict is indeed a dict, otherwise throw + if not isinstance(state_dict, dict): + if isinstance(state_dict, State): + raise ValueError(f"A State object can only be updated with a dictionary. Try " + f"'state.state_update(old_state.as_dict())'.") + else: + raise ValueError(f"A State object can only be updated with a dictionary.") + # If decay does not matter, we simply use the given dictionary if self.__decay_val <= 0.0: # Set the previous and new state @@ -29,7 +37,7 @@ def state_update(self, state_dict): self.__state_dict = state_dict.copy() # Set the "me" - self.__me = self.get_self() + self.__me = self.get_self()[0] # Return self return self @@ -62,14 +70,19 @@ def state_update(self, state_dict): # Check for non-zero decays and flag them for keeping (this now also # includes all new_ids as we just added them). to_keep_ids = [] + to_remove_ids = [] for obj_id, decay in self.__decays.items(): if decay > 0: to_keep_ids.append(obj_id) - # remove all zero decay objects, this reduces + # tag all zero decay objects, this reduces # the self.__decays of growing with zero decays else: - self.__decays.pop(obj_id) + to_remove_ids.append(obj_id) to_keep_ids = set(to_keep_ids) + + # Remove zero decay items + for obj_id in to_remove_ids: + self.__decays.pop(obj_id, None) # Create new state new_state = {} @@ -90,7 +103,7 @@ def state_update(self, state_dict): self.__state_dict = new_state # Set the "me" - self.__me = self.get_self() + self.__me = self.get_self()[0] # Return self return self @@ -232,6 +245,9 @@ def remove(self, obj_id): def as_dict(self): return self.__state_dict + def _add_world_info(self, world_info_dict): + self.__state_dict["World"] = world_info_dict + ############################################### # Some helpful getters for the state # ############################################### @@ -322,7 +338,7 @@ def get_team_members(self, team_name=None): if self.__me is not None: team_name = self.__me['team'] else: - team_name = self.get_self()['team'] + team_name = self.get_self()[0]['team'] team_members = self.get_agents_with_property({'team': team_name}) return team_members @@ -394,7 +410,7 @@ def get_distance_map(self): if self.__me is not None: loc = self.__me['location'] else: - loc = self.get_self()['location'] + loc = self.get_self()[0]['location'] def distance(coord): return utils.get_distance(loc, coord) @@ -416,7 +432,7 @@ def __get_closest(self, objs): if self.__me is not None: my_loc = self.__me['location'] else: - my_loc = self.get_self()['location'] + my_loc = self.get_self()[0]['location'] def distance(x): loc = x['location'] @@ -456,7 +472,8 @@ def __find_object(self, props, combined): # For each prop_name, prop_value combination, find the relevant objects. If there are more than one allowable # property value, search for those as well. - found = [[self.__find(name, val) for val in vals] for name, vals in props.items()] + found = [[self.__find(name, val) for val in vals] if name != "location" else [self.__find(name, vals)] + for name, vals in props.items()] # Check if we searched for more than one property if len(props) > 1: diff --git a/matrx/agents/agent_utils/state_tracker.py b/matrx/agents/agent_utils/state_tracker.py index 80c8e6ac..34b45109 100644 --- a/matrx/agents/agent_utils/state_tracker.py +++ b/matrx/agents/agent_utils/state_tracker.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np from matrx.utils import get_distance @@ -6,8 +8,38 @@ class StateTracker: + """ The tracker of agent observations over ticks. + + .. deprecated:: 2.0.7 + `StateTracker` will be removed in a future MATRX version where it will be fully replaced by `State`. + + """ def __init__(self, agent_id, knowledge_decay=10, fov_occlusion=True): + """ Create an instance to track an agent's observations over ticks. + + Parameters + ---------- + agent_id : str + The agent id this tracker is linked to. + knowledge_decay : int + For how many ticks observations need to be remembered when not observed. + fov_occlusion : bool + Whether intraversable objects should block the agent's field of view or not. + + .. deprecated:: 2.0.7 + `StateTracker` will be removed in MATRX v2.2 and fully replaced by `State`. + + Warnings + -------- + The `fov_occlusion` is a very unstable feature not fully tested. Use at your own risk! + + """ + + warnings.warn( + "The StateTracker will be deprecated in a future version of MATRX, replaced by State.", + PendingDeprecationWarning + ) # The amount with which we forget known knowledge (we do so linearly) if knowledge_decay > 1.0: @@ -33,12 +65,43 @@ def __init__(self, agent_id, knowledge_decay=10, fov_occlusion=True): self.__decay_values = {} def set_knowledge_decay(self, knowledge_decay): + """ Sets the number of ticks the tracker should memorize unobserved objects. + + Parameters + ---------- + knowledge_decay : int + The number of ticks an unobserved object is memorized. + + """ self.__decay = knowledge_decay def get_memorized_state(self): + """ Returns the memorized state so far. + + Returns + ------- + dict + The dictionary containing all current and memorized observations. + + """ return self.__memorized_state.copy() def update(self, state): + """ Updates this tracker with the new observations. + + This method also removes any past observations that are needed to be forgotten by now. + + Parameters + ---------- + state : dict + The state dictionary containing an agent's current observations. + + Returns + ------- + dict + The dictionary containing all current and memorized observations. + + """ # Decay all objects in our memory for obj_id in self.__decay_values.keys(): self.__decay_values[obj_id] -= self.__decay @@ -87,6 +150,30 @@ def update(self, state): return self.get_memorized_state() def get_traversability_map(self, inverted=False, state=None): + """ Returns a map where the agent can move to. + + This map is based on the provided state dictionary that might represent the observations of an agent. Since + these observations can be limited in sense of range and accuracy, the map might not be truthful to what is + actually possible. This mimics the fact that an agent only knows what it can observe and infer from those + observations. + + Parameters + ---------- + inverted : bool (Default: False) + Whether the map should be inverted (signalling where the agent cannot move to). + state : dict + The dictionary representing the agent's (memorized) observations to be used to create the map. + + Returns + ------- + array + An array of shape (width,height) equal to the grid world's size. Contains a 1 on each (x,y) coordinate where + the agent can move to (a 0 when inverted) and a 0 where it cannot move to (a 1 when inverted). + list + A list of lists with the width and height of the gird world as size. Contains on each (x,y) coordinate the + object ID if any according to the provided state dictionary. + + """ if state is None: state = self.__memorized_state @@ -115,6 +202,21 @@ def get_traversability_map(self, inverted=False, state=None): return traverse_map, obj_grid def __get_occluded_objects(self, state): + """ A private MATRX method. + + Applies the Field of View (FOV) algorithm. + + Parameters + ---------- + state : dict + The dictionary representing the agent's (memorized) observations to be used to create the map. + + Returns + ------- + list + The list of objects that are being occluded by other objects. + + """ loc = state[self.agent_id]["location"] map_size = state['World']['grid_shape'] diff --git a/matrx/api/api.py b/matrx/api/api.py index 762f13d1..6d5ffa35 100644 --- a/matrx/api/api.py +++ b/matrx/api/api.py @@ -7,6 +7,7 @@ from flask_cors import CORS from matrx.messages.message import Message +from matrx.agents.agent_utils.state import State _debug = True @@ -134,7 +135,7 @@ def get_latest_state_and_messages(): return abort(error['error_code'], description=error['error_message']) # fetch states, chatrooms and messages - states_ = __fetch_states(_current_tick, agent_id) + states_ = __fetch_state_dicts(_current_tick, agent_id) chatrooms, messages = __get_messages(agent_id, chat_offsets) return jsonify({"matrx_paused": matrx_paused, "states": states_, "chatrooms": chatrooms, "messages": messages}) @@ -172,7 +173,7 @@ def get_states(tick): print("api request not valid:", error) return abort(error['error_code'], description=error['error_message']) - return jsonify(__fetch_states(tick)) + return jsonify(__fetch_state_dicts(tick)) @__app.route('/get_states///', methods=['GET', 'POST']) @@ -201,7 +202,7 @@ def get_states_specific_agents(tick, agent_ids): print("api request not valid:", error) return abort(error['error_code'], description=error['error_message']) - return jsonify(__fetch_states(tick, agent_ids)) + return jsonify(__fetch_state_dicts(tick, agent_ids)) @__app.route('/get_latest_state//', methods=['GET', 'POST']) @@ -239,7 +240,7 @@ def get_filtered_latest_state(agent_ids): return abort(error['error_code'], description=error['error_message']) # Get the agent states - agent_states = __fetch_states(_current_tick, agent_ids)[0] + agent_states = __fetch_state_dicts(_current_tick, agent_ids)[0] # Filter the agent states based on the received properties list props = request.json['properties'] @@ -792,7 +793,7 @@ def __check_states_API_request(tick=None, ids=None, ids_required=False): # e.g. "god"), or a list of ' f'IDs(string) for requesting states of multiple agents'} # check if the api was reset during this time - if len(__states) is 0: + if len(__states) == 0: return False, {'error_code': 400, 'error_message': f'api is reconnecting to a new world'} @@ -850,7 +851,7 @@ def __check_input(tick=None, ids=None): return True, None -def __fetch_states(tick, ids=None): +def __fetch_state_dicts(tick, ids=None): """ This private function fetches, filters and orders the states as specified by the tick and agent ids. Parameters @@ -894,6 +895,11 @@ def __fetch_states(tick, ids=None): # add each agent's state for this tick for agent_id in ids: if agent_id in __states[t]: + # double check the state is of type dict and not a State object + if not isinstance(__states[t][agent_id]['state'], dict): + __states[t][agent_id]['state'] = __states[t][agent_id]['state'].as_dict() + + # Get state at tick t and of agent agent_id states_this_tick[agent_id] = __states[t][agent_id] # save the states of all filtered agents for this tick @@ -967,12 +973,12 @@ def __reorder_state(state): ------- The world state, JSON serializable """ - new_state = copy.copy(state) + new_state = copy.copy(state.as_dict()) # loop through all objects in the state for objID, obj in state.items(): - if objID is not "World": + if objID != "World": # make the sense capability JSON serializable if "sense_capability" in obj: new_state[objID]["sense_capability"] = str(obj["sense_capability"]) @@ -1008,7 +1014,8 @@ def _add_state(agent_id, state, agent_inheritence_chain, world_settings): # state['World']['matrx_paused'] = matrx_paused # reorder and save the new state along with some meta information - _temp_state[agent_id] = {'state': __reorder_state(state), 'agent_inheritence_chain': agent_inheritence_chain} + reordered_state = __reorder_state(state) + _temp_state[agent_id] = {'state': reordered_state, 'agent_inheritence_chain': agent_inheritence_chain} def _next_tick(): diff --git a/matrx/cases/__init__.py b/matrx/cases/__init__.py index 9d136cae..f88c1f9d 100644 --- a/matrx/cases/__init__.py +++ b/matrx/cases/__init__.py @@ -4,7 +4,13 @@ from matrx.cases.test_case import run_test from matrx.cases.vis_test import create_builder as create_vis_test from matrx.cases.vis_test import run_vis_test +from matrx.cases.vis_test2 import create_builder2 as create_vis_test2 +from matrx.cases.vis_test2 import run_vis_test2 from matrx.cases.bw4t.bw4t import create_builder as create_bw4t from matrx.cases.bw4t.bw4t import run as run_bw4t from matrx.cases.test_navigators import create_builder as create_test_navigators from matrx.cases.test_navigators import run_test_navigators +from matrx.cases.logger_test import run_logger_test +from matrx.cases.test_obj_arguments import run_arg_test +from matrx.cases.test_human_agent import run_test_human_agent + diff --git a/matrx/cases/bw4t/bw4t_objects.py b/matrx/cases/bw4t/bw4t_objects.py index ccda050e..d9f5fc61 100644 --- a/matrx/cases/bw4t/bw4t_objects.py +++ b/matrx/cases/bw4t/bw4t_objects.py @@ -3,23 +3,20 @@ class CollectBlock(EnvObject): - def __init__(self, location, visualize_colour): - name = "Collect block" + def __init__(self, location, visualize_colour, name="Collect block"): super().__init__(location, name, class_callable=SignalBlock, is_traversable=True, is_movable=True, visualize_shape=0, visualize_colour=visualize_colour) class SignalBlock(EnvObject): - def __init__(self, location, drop_zone_name, rank): - """ - """ + + def __init__(self, location, drop_zone_name, rank, name="Signal Block"): self.__is_set = False self.__rank = rank self.__drop_zone_name = drop_zone_name - name = "Signal block" visualize_colour = "#ffffff" visualize_opacity = 0.0 # customizable_properties = ["visualize_colour", "visualize_opacity"] diff --git a/matrx/cases/logger_test.py b/matrx/cases/logger_test.py new file mode 100644 index 00000000..1d7e0a90 --- /dev/null +++ b/matrx/cases/logger_test.py @@ -0,0 +1,69 @@ +import os + +from matrx.agents.agent_types.patrolling_agent import PatrollingAgentBrain +from matrx.logger.log_agent_actions import LogActions, LogActionsV2 +from matrx.logger.log_idle_agents import LogIdleAgentsV2, LogIdleAgents +from matrx.logger.log_messages import MessageLoggerV2, MessageLogger +from matrx.logger.log_tick import LogDurationV2, LogDuration +from matrx.world_builder import WorldBuilder + + +def create_builder(): + tick_dur = 0.2 + builder = WorldBuilder(random_seed=1, shape=[15, 15], tick_duration=tick_dur, verbose=True, run_matrx_api=True, + run_matrx_visualizer=True, simulation_goal=int(300/tick_dur)) + + builder.add_logger(logger_class=LogActions, save_path="log_data/") + builder.add_logger(logger_class=MessageLogger, save_path="log_data/") + builder.add_logger(logger_class=LogIdleAgents, save_path="log_data/") + builder.add_logger(logger_class=LogDuration, save_path="log_data/") + + builder.add_logger(logger_class=LogActionsV2, save_path="log_data_v2/") + builder.add_logger(logger_class=MessageLoggerV2, save_path="log_data_v2/") + builder.add_logger(logger_class=LogIdleAgentsV2, save_path="log_data_v2/") + builder.add_logger(logger_class=LogDurationV2, save_path="log_data_v2/") + + builder.add_room(top_left_location=[0, 0], width=15, height=15, name="world_bounds") + + n_agent = 1 + + is_even = True + is_reverse = True + for x in range(1, n_agent+1): + if is_even: + is_even = False + if is_reverse: + start = (x, 14) + waypoints = [(x, 1), (x, 13)] + else: + start = (x, 1) + waypoints = [(x, 13), (x, 1)] + else: + is_even = True + is_reverse = False + if is_reverse: + start = (1, x) + waypoints = [(13, x), (1, x)] + else: + start = (14, x) + waypoints = [(1, x), (13, x)] + + navigating_agent = PatrollingAgentBrain(waypoints, move_speed=10) + builder.add_agent(start, navigating_agent, name="navigate " + str(x), visualize_shape=0, has_menu=True) + + return builder + + +def run_logger_test(nr_of_worlds=1): + builder = create_builder() + + # startup world-overarching MATRX scripts, such as the api and/or visualizer if requested + media_folder = os.path.dirname(os.path.realpath(__file__)) # set our path for media files to our current folder + builder.startup(media_folder=media_folder) + + # run each world + for world in builder.worlds(nr_of_worlds=nr_of_worlds): + world.run(builder.api_info) + + # stop MATRX scripts such as the api and visualizer (if used) + builder.stop() diff --git a/matrx/cases/test_human_agent.py b/matrx/cases/test_human_agent.py new file mode 100644 index 00000000..6298986d --- /dev/null +++ b/matrx/cases/test_human_agent.py @@ -0,0 +1,33 @@ +import os + +from matrx import WorldBuilder +from matrx.agents import PatrollingAgentBrain, HumanAgentBrain + + +def create_builder(): + tick_dur = 0.5 + builder = WorldBuilder(random_seed=1, shape=[3, 2], + tick_duration=tick_dur, verbose=False, + simulation_goal=int(300 / tick_dur), + run_matrx_api=True, run_matrx_visualizer=True) + + builder.add_human_agent(location=(0, 0), name="Test Human", agent=HumanAgentBrain()) + + # startup world-overarching MATRX scripts, such as the api and/or + # visualizer if requested + media_folder = os.path.dirname(os.path.realpath(__file__)) + builder.startup(media_folder=media_folder) + + return builder + + +def run_test_human_agent(): + # Create builder + builder = create_builder() + + # run each world + world = builder.get_world() + world.run(builder.api_info) + + # stop MATRX scripts such as the api and visualizer (if used) + builder.stop() diff --git a/matrx/cases/test_obj_arguments.py b/matrx/cases/test_obj_arguments.py new file mode 100644 index 00000000..173b3c1a --- /dev/null +++ b/matrx/cases/test_obj_arguments.py @@ -0,0 +1,56 @@ +import os + +from matrx.objects import EnvObject +from matrx.world_builder import WorldBuilder + + +class KwargsObject(EnvObject): + + def __init__(self, location, name, custom_mandatory, custom_optional="optional", **kwargs): + kwargs = {**kwargs, "custom_constructor": "constructor added"} + + super().__init__(location, name, KwargsObject, **kwargs) + self.add_property("custom_mandatory", custom_mandatory) + self.add_property("custom_optional", custom_optional) + + +class ArgsObject(EnvObject): + + def __init__(self, location, name, custom_mandatory, custom_optional="optional"): + + super().__init__(location, name, KwargsObject) + self.add_property("custom_mandatory", custom_mandatory) + self.add_property("custom_optional", custom_optional) + + +def create_builder(): + builder = WorldBuilder(random_seed=1, shape=[5, 5], tick_duration=.1, verbose=True, run_matrx_api=True, + run_matrx_visualizer=True, simulation_goal=-1) + + # # This should raise an exception, as the `custom_mandatory` property is not given. + # builder.add_object(location=(2,2), name="obj_1", callable_class=ArgObject, custom_builder="builder added") + + # This should pass, as the `custom_mandatory` property is given. + builder.add_object(location=(2, 2), name="obj_1", callable_class=KwargsObject, custom_mandatory="this is set!", + custom_builder="builder added") + + # This adds an object without an **kwargs element, so the custom_builder property is ignored. + builder.add_object(location=(3, 3), name="obj_2", callable_class=ArgsObject, custom_mandatory="this is set!", + custom_builder="builder added") + + return builder + + +def run_arg_test(): + builder = create_builder() + + # startup world-overarching MATRX scripts, such as the api and/or visualizer if requested + media_folder = os.path.dirname(os.path.realpath(__file__)) # set our path for media files to our current folder + builder.startup(media_folder=media_folder) + + # run a world + world = builder.get_world() + world.run(builder.api_info) + + # stop MATRX scripts such as the api and visualizer (if used) + builder.stop() diff --git a/matrx/cases/vis_test2.py b/matrx/cases/vis_test2.py new file mode 100644 index 00000000..9d6de6d3 --- /dev/null +++ b/matrx/cases/vis_test2.py @@ -0,0 +1,41 @@ +import os + +from matrx.agents import PatrollingAgentBrain, HumanAgentBrain +from matrx.logger.log_messages import MessageLogger +from matrx.world_builder import WorldBuilder +from matrx.actions import * +from datetime import datetime +from matrx.objects import Door, SquareBlock, AreaTile, SmokeTile, CollectionDropOffTile +from matrx.agents import HumanAgentBrain + +def create_builder2(): + tick_dur = 0.1 + factory = WorldBuilder(random_seed=1, shape=[9, 10], tick_duration=1, verbose=False, run_matrx_api=True, + run_matrx_visualizer=True, visualization_bg_clr="#f0f0f0", simulation_goal=100000) + + factory.add_room(top_left_location=[0, 0], width=9, height=10, name='Wall_Border', wall_visualize_opacity=0.5) + factory.add_object(location=[3,3], name="door", callable_class=Door, visualize_opacity=0.5, is_open=False) + factory.add_object(location=[3,4], name="block", callable_class=SquareBlock, visualize_opacity=0.5) + factory.add_object(location=[3,5], name="tile1", callable_class=AreaTile, visualize_opacity=0.5) + factory.add_object(location=[3,6], name="tile2", callable_class=SmokeTile, visualize_opacity=0.5) + factory.add_object(location=[4,6], name="CollectionDropOffTile", callable_class=CollectionDropOffTile, visualize_opacity=0.5) + + factory.add_human_agent(location=[5,6], agent=HumanAgentBrain(), visualize_opacity=0.5) + + return factory + + +def run_vis_test2(nr_of_worlds=2): + builder = create_builder2() + + # startup world-overarching MATRX scripts, such as the api and/or visualizer if requested + media_folder = os.path.dirname(os.path.realpath(__file__)) # set our path for media files to our current folder + builder.startup(media_folder=media_folder) + + # run each world + for world in builder.worlds(nr_of_worlds=nr_of_worlds): + # builder.api_info['matrx_paused'] = False + world.run(builder.api_info) + + # stop MATRX scripts such as the api and visualizer (if used) + builder.stop() diff --git a/matrx/defaults.py b/matrx/defaults.py index 5df17f9c..69bbe4be 100644 --- a/matrx/defaults.py +++ b/matrx/defaults.py @@ -18,12 +18,13 @@ # EnvObject defaults # ###################### ENVOBJECT_IS_TRAVERSABLE = False -ENVOBJECT_IS_MOVABLE = True +ENVOBJECT_IS_MOVABLE = None ENVOBJECT_VIS_SIZE = 1.0 ENVOBJECT_VIS_SHAPE = 0 ENVOBJECT_VIS_COLOUR = "#4286f4" ENVOBJECT_VIS_OPACITY = 1.0 ENVOBJECT_VIS_DEPTH = 80 +ENVOBJECT_VIS_FROM_CENTER = True ###################### # GridWorld defaults # diff --git a/matrx/goals/goals.py b/matrx/goals/goals.py index 588714b2..1d576b4a 100644 --- a/matrx/goals/goals.py +++ b/matrx/goals/goals.py @@ -12,12 +12,24 @@ class WorldGoal: """ A class that tracks whether the simulation has reached its global goal. + + .. deprecated:: 2.1.0 + `WorldGoal` will be removed in the future, it is replaced by + `WorldGoalV2` because the latter works with the + :class:`matrx.agents.agent_utils.state.State` object. """ def __init__(self): """ We set the self.is_done to False as a start. """ + + warnings.warn( + f"{self.__class__.__name__} will be updated in the future towards {self.__class__.__name__}V2. Switch to " + f"the usage of {self.__class__.__name__}V2 to prevent future problems.", + DeprecationWarning, + ) + self.is_done = False def goal_reached(self, grid_world): @@ -369,3 +381,373 @@ def get_random_order_property(cls, possibilities, length=None, with_duplicates=F rp_orders = matrx.world_builder.RandomProperty(values=orders) return rp_orders + + +class WorldGoalV2: + """ + A class that tracks whether the simulation has reached its global goal. + """ + + def __init__(self): + """ + We set the self.is_done to False as a start. + """ + self.is_done = False + + def goal_reached(self, world_state, grid_world): + """ + Returns whether the global goal of the simulated grid world is accomplished. This method should be overridden + by a new goal. + + Parameters + ---------- + world_state : State + The entire world state. Used to search and read objects within the world to check for world completion. + grid_world : GridWorld + The actual grid world instance. For access to components not present in the world state, such as the + messages send between agents and user input from human agents. + + Returns + ------- + goal_reached : bool + True when the goal is reached, False otherwise. + """ + pass + + def get_progress(self, world_state, grid_world): + """ + Returns the progress of reaching the global goal in the simulated grid world. This method can be overridden + if you want to track the progress. But is not required. + + Parameters + ---------- + world_state : State + The entire world state. Used to search and read objects within the world to check for world completion. + grid_world : GridWorld + The actual grid world instance. For access to components not present in the world state, such as the + messages send between agents and user input from human agents. + + Returns + ------- + progress : float + Representing with 0.0 no progress made, and 1.0 that the goal is reached. + """ + pass + + def reset(self): + """ Resets this goal's completion boolean and returns a copy of this object.""" + self.is_done = False + return copy.deepcopy(self) + + +class LimitedTimeGoalV2(WorldGoalV2): + """ + A world goal that simply tracks whether a maximum number of ticks has been reached. + """ + + def __init__(self, max_nr_ticks): + """ Initialize the LimitedTimeGoal by saving the `max_nr_ticks`. + """ + super().__init__() + self.max_nr_ticks = max_nr_ticks + + def goal_reached(self, world_state, grid_world): + """ Returns whether the number of specified ticks has been reached. + + Parameters + ---------- + world_state : State + The entire world state. Used to search and read objects within the world to check for world completion. + grid_world : GridWorld + The actual grid world instance. For access to components not present in the world state, such as the + messages send between agents and user input from human agents. + + Returns + ------- + goal_reached : bool + True when the goal is reached, False otherwise. + + Examples + -------- + + For an example, see :meth:`matrx.grid_world.__check_simulation_goal` + + Checking all simulation goals from e.g. action, world goal, or somewhere else with access to the Gridworld, + the function can be used as below: + + >>> goal_status = {} + >>> if grid_world.simulation_goal is not None: + >>> if isinstance(grid_world.simulation_goal, list): + >>> for sim_goal in grid_world.simulation_goal: + >>> is_done = sim_goal.goal_reached(grid_world) + >>> goal_status[sim_goal] = is_done + >>> else: + >>> is_done = grid_world.simulation_goal.goal_reached(grid_world) + >>> goal_status[grid_world.simulation_goal] = is_done + >>> + >>> is_done = np.array(list(goal_status.values())).all() + + """ + nr_ticks = grid_world.current_nr_ticks + if self.max_nr_ticks == np.inf or self.max_nr_ticks <= 0: + self.is_done = False + else: + if nr_ticks >= self.max_nr_ticks: + self.is_done = True + else: + self.is_done = False + return self.is_done + + def get_progress(self, world_state, grid_world): + """ Returns the progress of reaching the LimitedTimeGoal in the simulated grid world. + + Parameters + ---------- + world_state : State + The entire world state. Used to search and read objects within the world to check for world completion. + grid_world : GridWorld + The actual grid world instance. For access to components not present in the world state, such as the + messages send between agents and user input from human agents. + + Returns + ------- + progress : float + Representing with 0.0 no progress made, and 1.0 that the goal is reached. + + + Examples + -------- + Checking all simulation goals from e.g. action, world goal, or somewhere else with access to the Gridworld, + the function can be used as below. + In this example we know there is only 1 simulation goal. + + >>> progress = grid_world.simulation_goal.get_progress(grid_world) + >>> print(f"The simulation is {progress * 100} percent complete!") + + + """ + if self.max_nr_ticks == np.inf or self.max_nr_ticks <= 0: + return 0. + return min(1.0, grid_world.current_nr_ticks / self.max_nr_ticks) + + +class CollectionGoalV2(WorldGoalV2): + + def __init__(self, name, target_name, in_order=False): + super().__init__() + # Store the attributes + self.__area_name = name + self.__target_name = target_name + self.__in_order = in_order + + # Set attributes we will use to speed up things and keep track of collected objects + self.__drop_off_locs = None # all locations where objects can be dropped off + self.__target = None # all (ordered) objects that need to be collected described in their properties + self.__dropped_objects = {} # a dictionary of the required dropped objects (id as key, tick as value) + self.__attained_rank = 0 # The maximum attained rank of the correctly collected objects (only used if in_order) + + def goal_reached(self, world_state, grid_world): + if self.__drop_off_locs is None: # find all drop off locations, its tile ID's and goal blocks + self.__drop_off_locs = [] + self.__find_drop_off_locations(grid_world) + # Raise exception if no drop off locations were found. + if len(self.__drop_off_locs) == 0: + raise ValueError(f"The CollectionGoal {self.__area_name} could not find a " + f"{CollectionDropOffTile.__name__} with its 'collection_area_name' set to " + f"{self.__area_name}.") + + if self.__target is None: # find all objects that need to be collected (potentially in order) + self.__target = [] + self.__find_collection_objects(grid_world) + + # Go all drop locations and check if the requested objects are there (potentially dropped in the right order) + is_satisfied = self.__check_completion(grid_world) + self.is_done = is_satisfied + + return is_satisfied + + def __find_drop_off_locations(self, grid_world): + all_objs = grid_world.environment_objects + for obj_id, obj in all_objs.items(): + if 'name' in obj.properties.keys() \ + and self.__area_name == obj.properties['name']: + loc = obj.location + self.__drop_off_locs.append(loc) + + def __find_collection_objects(self, grid_world): + all_objs = grid_world.environment_objects + for obj_id, obj in all_objs.items(): + if 'collection_zone_name' in obj.properties.keys() \ + and self.__area_name == obj.properties['collection_zone_name']\ + and 'collection_objects' in obj.properties and 'is_drop_off_target' in obj.properties\ + and obj.properties['is_drop_off_target']: + self.__target = obj.properties['collection_objects'].copy() + + # Raise warning if no target object was found. + if len(self.__target) == 0: + warnings.warn(f"The CollectionGoal {self.__area_name} could not find a {CollectionTarget.__name__} " + f"object or its 'collection_objects' property is empty.") + + def __check_completion(self, grid_world): + # If we were already done before, we return the past values + if self.is_done: + return self.is_done + + # Get the current tick number + curr_tick = grid_world.current_nr_ticks + + # Retrieve all objects and the drop locations (this is the most performance heavy; it loops over all drop locs + # and queries the world to locate all objects at that point through distance calculation. Note: this calculation + # is not required, as the range is zero!). + obj_ids = [obj_id for loc in self.__drop_off_locs + for obj_id in grid_world.get_objects_in_range(loc, sense_range=0, object_type=None).keys()] + + # Get all world objects and agents + all_objs = grid_world.environment_objects + all_agents = grid_world.registered_agents + all_ = {**all_objs, **all_agents} + + # Go through all objects at the drop off locations. If an object was not already detected before as a + # required object, check if it is one of the desired objects. Also, ignore all drop off tiles and targets. + detected_objs = {} + for obj_id in obj_ids: + obj_props = all_[obj_id].properties + # Check if the object is either a collection area tile or a collection target object, if so skip it + if ("is_drop_off" in obj_props.keys() and "collection_area_name" in obj_props.keys()) \ + or ("is_drop_off_target" in obj_props.keys() and "collection_zone_name" in obj_props.keys() + and "is_invisible" in obj_props.keys()): + continue + for req_props in self.__target: + obj_props = utils._flatten_dict(obj_props) + if req_props.items() <= obj_props.items(): + detected_objs[obj_id] = curr_tick + + # Now compare the detected objects with the previous detected objects to see if any new objects were detected + # and thus should be added to the dropped objects + is_updated = False + for obj_id in detected_objs.keys(): + if obj_id not in self.__dropped_objects.keys(): + is_updated = True + self.__dropped_objects[obj_id] = detected_objs[obj_id] + + # Check if any objects detected previously are now not detected anymore, as such they need to be removed. + removed = [] + for obj_id in self.__dropped_objects.keys(): + if obj_id not in detected_objs.keys(): + removed.append(obj_id) + for obj_id in removed: + is_updated = True + self.__dropped_objects.pop(obj_id, None) + + # If required (and needed), check if the dropped objects are dropped in order by tracking the rank up which the + # dropped objects satisfy the requested order. + if self.__in_order and is_updated: + # Sort the dropped objects based on the tick they were detected (in ascending order) + sorted_dropped_obj = sorted(self.__dropped_objects.items(), key=lambda x: x[1], reverse=False) + rank = 0 + for obj_id, tick in sorted_dropped_obj: + props = all_[obj_id].properties + props = utils._flatten_dict(props) + req_props = self.__target[rank] + if req_props.items() <= props.items(): + rank += 1 + else: + # as soon as the next object is not the one we expect, we stop the search at this attained rank. + break + + # The goal is done as soon as the attained rank is equal to the number of requested objects + is_satisfied = rank == len(self.__target) + # Store the attained rank, used to measure the progress + self.__attained_rank = rank + + # objects do not need to be collected in order and new ones were dropped + elif is_updated: + # The goal is done when the number of collected objects equal the number of requested objects + is_satisfied = len(self.__dropped_objects) == len(self.__target) + + # no new objects detected, so just return the past values + else: + is_satisfied = self.is_done + + return is_satisfied + + def get_progress(self, world_state, grid_world): + # If we are done, just return 1.0 + if self.is_done: + return 1.0 + + # Check if the order matters, if so calculated the progress based on the maximum attained rank of correct + # ordered collected objects. + if self.__in_order: + # Progress is the currently attained rank divided by the number of requested objects + progress = self.__attained_rank / len(self.__target) + + # If the order does not matter, just calculate the progress as the number of correctly collected/dropped + # objects. + else: + # Progress the is the number of collected objects divided by the total number of requested objects + progress = len(self.__dropped_objects) / len(self.__target) + + return progress + + @classmethod + def get_random_order_property(cls, possibilities, length=None, with_duplicates=False): + """ Creates a `RandomProperty` representing a list of potential objects to collect in a certain order. + + Parameters + ---------- + possibilities: iterable + An iterable (e.g. list, tuple, etc.) of dictionaries representing property_name, property_value pairs that + can be collected. + + length: int (optional, default None) + The number of objects that need to be sampled from `possibilities` to be collected. + + with_duplicates: bool (optional, default False) + Whether entries in `possibilities` can occur more than once in the lists. + + Returns + ------- + RandomProperty + A random property representing all possible lists of collection objects. Each list differs in the order of + the objects. If length < len(possibilities), not all objects may be in each list. If with_duplicates=True, + some objects might occur more than once in a list. This random property can be given to a `CollectionGoal` + who will sample one of these lists every time a world is run. This allows a world with a `CollectionGoal` + to denote different collection goals each time but still based on all properties in `possibilities`. + + Examples + -------- + >>> from matrx import WorldBuilder + >>> from matrx.logger import LogActions + >>> from matrx.objects import SquareBlock + >>> from matrx.goals import CollectionGoal + >>> builder = WorldBuilder(shape=(3, 3)) + >>> builder.add_object([0, 0], "Red Block", callable_class=SquareBlock, visualize_colour="#eb4034") + >>> builder.add_object([1, 1], "Blue Block", callable_class=SquareBlock, visualize_colour="#3385e8") + + Add a collection goal, where we should collect red and blue blocks but every time we run the world, in a different + order. To do so, we need to pass a RandomProperty to `add_collection_goal` which it uses to sample such an + order each created world. We call this utility method to get us such a RandomProperty. + >>> rp_order = CollectionGoal.get_random_order_property([{'visualize_colour': 'eb4034'}, {'visualize_colour': '3385e8'}]) + >>> builder.add_collection_goal("Drop", [(2, 2)], rp_order, in_order=True) + + See Also + -------- + :meth:`matrx.world_builder.WorldBuilder.add_collection_goal` + The method that receives this return value. + :class:`matrx.world_builder.RandomProperty` + The class representing a property with a random value each world creation. + + """ + if length is None: + length = len(possibilities) + + if not with_duplicates: + orders = itertools.permutations(possibilities, r=length) + else: # with_duplicates + orders = itertools.product(possibilities, repeat=length) + orders = list(orders) + + rp_orders = matrx.world_builder.RandomProperty(values=orders) + + return rp_orders diff --git a/matrx/grid_world.py b/matrx/grid_world.py index 465ade85..3876bfb0 100644 --- a/matrx/grid_world.py +++ b/matrx/grid_world.py @@ -8,7 +8,9 @@ import gevent from matrx.actions.object_actions import * -from matrx.logger.logger import GridWorldLogger +from matrx.goals import WorldGoalV2 +from matrx.logger.logger import GridWorldLogger, GridWorldLoggerV2 +from matrx.agents.agent_utils.state import State from matrx.objects.env_object import EnvObject from matrx.objects.standard_objects import AreaTile from matrx.messages.message_manager import MessageManager @@ -87,6 +89,7 @@ def __init__(self, shape, tick_duration, simulation_goal, rnd_seed=1, self.__teams = {} # dictionary with team names (keys), and agents in those teams (values) self.__registered_agents = OrderedDict() # The dictionary of all existing agents in the GridWorld self.__environment_objects = OrderedDict() # The dictionary of all existing objects in the GridWorld + self.__obj_indices = {} # keeps track of all obj_ids added, indexed by their (preprocessed) obj name # Load about file and fetch MATRX version about = {} @@ -94,7 +97,8 @@ def __init__(self, shape, tick_duration, simulation_goal, rnd_seed=1, with open(about_file, 'r') as f: exec(f.read(), about) self.__matrx_version = about['__version__'] - print(f"Running MATRX version {self.__matrx_version}") + if self.__verbose: + print(f"Running MATRX version {self.__matrx_version}") # The simulation goal, the simulation ends when this/these are reached. # Copy and reset all simulation goals, this to make sure that this world has its own goals independent of any @@ -513,6 +517,8 @@ def _register_env_object(self, env_object: EnvObject): # check if the object can be succesfully placed at that location self.__validate_obj_placement(env_object) + env_object.obj_id = self.__ensure_unique_obj_name(env_object.obj_id) + # Assign id to environment sparse dictionary grid self.__environment_objects[env_object.obj_id] = env_object @@ -521,6 +527,31 @@ def _register_env_object(self, env_object: EnvObject): return env_object.obj_id + def __ensure_unique_obj_name(self, obj_id): + """ Make sure every obj ID is unique by adding an increasing count to objects with duplicate IDs. + Example: three objects named "drone". The object IDs will then become "drone", "drone_1", "drone_2", etc.""" + + # check if an object by this name was already added, and gen a unique ID if so + if obj_id in self.__obj_indices.keys(): + + # get the latest index we are at for this obj, e.g. wall #10 + n = self.__obj_indices[obj_id] + self.__obj_indices[obj_id] += 1 + + # double check that the new id is unique or increment until it is + while f"{obj_id}_{n}" in self.__obj_indices.keys(): + n = self.__obj_indices[obj_id] + self.__obj_indices[obj_id] += 1 + + # set the new id + obj_id = f"{obj_id}_{n}" + + # otherwise obj name can be used as obj id + else: + self.__obj_indices[obj_id] = 1 + + return obj_id + def _register_teams(self): """ Register all teams and who is in those teams. An agent is always in a team, if not set by the user, a team is created with name 'agent_id' with only that @@ -584,8 +615,11 @@ def __step(self): # Set tick start of current tick start_time_current_tick = datetime.datetime.now() + # Get the world state + world_state = self.__get_complete_state() + # Check if we are done based on our global goal assessment function - self.__is_done, goal_status = self.__check_simulation_goal() + self.__is_done, goal_status = self.__check_simulation_goal(world_state) # Log the data if we have any loggers for logger in self.__loggers: @@ -593,8 +627,13 @@ def __step(self): for agent_id, agent_body in self.__registered_agents.items(): agent_data_dict[agent_id] = agent_body.get_log_data() - logger._grid_world_log(grid_world=self, agent_data=agent_data_dict, - last_tick=self.__is_done, goal_status=goal_status) + # Check if the logger is an old or V2 version. + if isinstance(logger, GridWorldLoggerV2): + logger._grid_world_log(world_state=world_state, agent_data=agent_data_dict, grid_world=self, + last_tick=self.__is_done, goal_status=goal_status) + else: + logger._grid_world_log(agent_data=agent_data_dict, grid_world=self, + last_tick=self.__is_done, goal_status=goal_status) # If this grid_world is done, we return immediately if self.__is_done: @@ -632,8 +671,8 @@ def __step(self): # save the current agent's state for the api if self.__run_matrx_api: api._add_state(agent_id=agent_id, state=filtered_agent_state, - agent_inheritence_chain=agent_obj.class_inheritance, - world_settings=self.__get_complete_state()['World']) + agent_inheritence_chain=agent_obj.class_inheritance, + world_settings=world_state['World']) else: # agent is not busy @@ -683,8 +722,8 @@ def __step(self): # save the current agent's state for the api if self.__run_matrx_api: api._add_state(agent_id=agent_id, state=filtered_agent_state, - agent_inheritence_chain=agent_obj.class_inheritance, - world_settings=self.__get_complete_state()['World']) + agent_inheritence_chain=agent_obj.class_inheritance, + world_settings=world_state['World']) # if this agent is at its last tick of waiting on its action duration, we want to actually perform the # action @@ -704,8 +743,8 @@ def __step(self): # save the god view state if self.__run_matrx_api: - api._add_state(agent_id="god", state=self.__get_complete_state(), agent_inheritence_chain="god", - world_settings=self.__get_complete_state()['World']) + api._add_state(agent_id="god", state=world_state, agent_inheritence_chain="god", + world_settings=world_state['World']) # make the information of this tick available via the api, after all # agents have been updated @@ -725,7 +764,7 @@ def __step(self): action_kwargs = {} # Actually perform the action (if possible), also sets the result in the agent's brain - self.__perform_action(agent_id, action_class_name, action_kwargs) + self.__perform_action(agent_id, action_class_name, action_kwargs, world_state) # Update the grid self.__update_grid() @@ -765,16 +804,27 @@ def __step(self): return self.__is_done, self.__curr_tick_duration - def __check_simulation_goal(self): + def __check_simulation_goal(self, world_state): goal_status = {} if self.__simulation_goal is not None: if isinstance(self.__simulation_goal, (list, tuple)): # edited this check to include tuples for sim_goal in self.__simulation_goal: - is_done = sim_goal.goal_reached(self) + + # Check if the goal is a new V2 goal + if isinstance(sim_goal, WorldGoalV2): + is_done = sim_goal.goal_reached(world_state, self) + else: + is_done = sim_goal.goal_reached(self) + + # Store goal status goal_status[sim_goal] = is_done else: - is_done = self.__simulation_goal.goal_reached(self) + # Check if the goal is a new V2 goal + if isinstance(self.__simulation_goal, WorldGoalV2): + is_done = self.__simulation_goal.goal_reached(world_state, self) + else: + is_done = self.__simulation_goal.goal_reached(self) goal_status[self.__simulation_goal] = is_done is_done = np.array(list(goal_status.values())).all() @@ -807,15 +857,19 @@ def __get_complete_state(self): :return: state with all objects and agents on the grid """ - # create a state with all objects and agents - state = {} + # create a state dict with all objects and agents + state_dict = {} for obj_id, obj in self.__environment_objects.items(): - state[obj.obj_id] = obj.properties + state_dict[obj.obj_id] = obj.properties for agent_id, agent in self.__registered_agents.items(): - state[agent.obj_id] = agent.properties + state_dict[agent.obj_id] = agent.properties + + # Create State + state = State(own_id=None) + state.state_update(state_dict) # Append generic properties (e.g. number of ticks, size of grid, etc.} - state["World"] = { + world_info = { "nr_ticks": self.__current_nr_ticks, "curr_tick_timestamp": int(round(time.time() * 1000)), "grid_shape": self.__shape, @@ -827,6 +881,9 @@ def __get_complete_state(self): } } + # Add world info to State + state._add_world_info(world_info) + return state def __get_agent_state(self, agent_obj: AgentBody): @@ -859,15 +916,19 @@ def __get_agent_state(self, agent_obj: AgentBody): if type(wildcard_obj) not in sense_capabilities.keys(): objs_in_range[wildcard_obj_id] = wildcard_obj - state = {} + state_dict = {} # Save all properties of the sensed objects in a state dictionary for env_obj in objs_in_range: - state[env_obj] = objs_in_range[env_obj].properties + state_dict[env_obj] = objs_in_range[env_obj].properties + + # Create State object out of state dict + state = State(agent_obj.obj_id) + state.state_update(state_dict) # Append generic properties (e.g. number of ticks, fellow team members, etc.} team_members = [agent_id for agent_id, other_agent in self.__registered_agents.items() if agent_obj.team == other_agent.team] - state["World"] = { + world_info = { "nr_ticks": self.__current_nr_ticks, "curr_tick_timestamp": int(round(time.time() * 1000)), "grid_shape": self.__shape, @@ -880,9 +941,12 @@ def __get_agent_state(self, agent_obj: AgentBody): } } + # Add it to State + state._add_world_info(world_info) + return state - def __check_action_is_possible(self, agent_id, action_name, action_kwargs): + def __check_action_is_possible(self, agent_id, action_name, action_kwargs, world_state): # If the action_name is None, the agent idles if action_name is None: result = ActionResult(ActionResult.IDLE_ACTION, succeeded=True) @@ -909,7 +973,7 @@ def __check_action_is_possible(self, agent_id, action_name, action_kwargs): action = action_class() # Check if action is possible, if so we can perform the action otherwise we send an ActionResult that it was # not possible. - result = action.is_possible(self, agent_id, **action_kwargs) + result = action.is_possible(self, agent_id, world_state=world_state, **action_kwargs) else: # If the action is not known warnings.warn(f"The action with name {action_name} was not found when checking whether this action is " @@ -918,10 +982,10 @@ def __check_action_is_possible(self, agent_id, action_name, action_kwargs): return result - def __perform_action(self, agent_id, action_name, action_kwargs): + def __perform_action(self, agent_id, action_name, action_kwargs, world_state): # Check if the action will succeed - result = self.__check_action_is_possible(agent_id, action_name, action_kwargs) + result = self.__check_action_is_possible(agent_id, action_name, action_kwargs, world_state) # If it will succeed, perform it. if result.succeeded: @@ -935,7 +999,7 @@ def __perform_action(self, agent_id, action_name, action_kwargs): # Make instance of action action = action_class() # Apply world mutation - result = action.mutate(self, agent_id, **action_kwargs) + result = action.mutate(self, agent_id, world_state=world_state, **action_kwargs) # Update the grid self.__update_agent_location(agent_id) @@ -1048,3 +1112,7 @@ def tick_duration(self): """float: the desired duration of one tick. The real tick_duration might be longer due to a large amount of processing that needs to be done each tick by one or multiple agents. """ return self.__tick_duration + + @property + def loggers(self): + return self.__loggers diff --git a/matrx/logger/log_agent_actions.py b/matrx/logger/log_agent_actions.py index 88d0c25b..43ea846e 100644 --- a/matrx/logger/log_agent_actions.py +++ b/matrx/logger/log_agent_actions.py @@ -1,10 +1,11 @@ -from matrx.logger.logger import GridWorldLogger +from matrx.logger.logger import GridWorldLogger, GridWorldLoggerV2 class LogActions(GridWorldLogger): """ Logs per agent the action performed per tick""" def __init__(self, save_path="", file_name_prefix="", file_extension=".csv", delimeter=";"): + super().__init__(save_path=save_path, file_name=file_name_prefix, file_extension=file_extension, delimiter=delimeter, log_strategy=1) @@ -14,3 +15,18 @@ def log(self, grid_world, agent_data): log_data[agent_id] = agent_body.current_action return log_data + + +class LogActionsV2(GridWorldLoggerV2): + """ Logs per agent the action performed per tick""" + + def __init__(self, save_path="", file_name_prefix="", file_extension=".csv", delimeter=";"): + super().__init__(save_path=save_path, file_name=file_name_prefix, file_extension=file_extension, + delimiter=delimeter, log_strategy=1) + + def log(self, world_state, agent_data, grid_world): + log_data = {} + for agent_id, agent_body in grid_world.registered_agents.items(): + log_data[agent_id] = agent_body.current_action + + return log_data diff --git a/matrx/logger/log_idle_agents.py b/matrx/logger/log_idle_agents.py index c1a1abe2..681acecd 100644 --- a/matrx/logger/log_idle_agents.py +++ b/matrx/logger/log_idle_agents.py @@ -1,4 +1,4 @@ -from matrx.logger.logger import GridWorldLogger +from matrx.logger.logger import GridWorldLogger, GridWorldLoggerV2 class LogIdleAgents(GridWorldLogger): @@ -19,3 +19,23 @@ def log(self, grid_world, agent_data): return log_statement return None + + +class LogIdleAgentsV2(GridWorldLoggerV2): + """ Logs the number of idle agents per tick """ + + def __init__(self, log_strategy=1, save_path="", file_name_prefix="", file_extension=".csv", delimeter=";"): + super().__init__(log_strategy=log_strategy, save_path=save_path, file_name=file_name_prefix, + file_extension=file_extension, delimiter=delimeter) + + def log(self, world_state, agent_data, grid_world): + log_statement = {} + for agent_id, agent_obj in grid_world.registered_agents.items(): + idle = agent_obj.properties['current_action'] is None + log_statement[agent_id] = int(idle) + + for agent_id, agent_obj in grid_world.registered_agents.items(): + if log_statement[agent_id] != 0: + return log_statement + + return None diff --git a/matrx/logger/log_messages.py b/matrx/logger/log_messages.py index 35409e19..819c8b5a 100644 --- a/matrx/logger/log_messages.py +++ b/matrx/logger/log_messages.py @@ -1,4 +1,4 @@ -from matrx.logger.logger import GridWorldLogger +from matrx.logger.logger import GridWorldLogger, GridWorldLoggerV2 import copy import json @@ -52,29 +52,49 @@ def log(self, grid_world, agent_data): return log_statement +class MessageLoggerV2(GridWorldLoggerV2): + """ Logs messages send and received by (all) agents """ + def __init__(self, save_path="", file_name_prefix="", file_extension=".csv", delimeter=";"): + super().__init__(save_path=save_path, file_name=file_name_prefix, file_extension=file_extension, + delimiter=delimeter, log_strategy=1) + # IDs of the agents we want to log messages of + self.agent_ids = [] + self.agent_ids_initialized = False + self.log_statement_template = {'correct_tick': 0} + def log(self, world_state, agent_data, grid_world): + # find the IDs of the agents we need to log and create a template log statement + if not self.agent_ids_initialized: + for agent_id in grid_world.registered_agents.keys(): + self.agent_ids.append(agent_id) + # create a field for messages sent and messages received + self.log_statement_template[agent_id + "_sent"] = None + self.log_statement_template[agent_id + "_received"] = None + # field specific for logging the entire message as json + self.log_statement_template[agent_id + "_mssg_json"] = None + self.agent_ids_initialized = True + # create a copy of the log template for the messages of the tick we are now processing + log_statement = copy.copy(self.log_statement_template) + # we check the messages of the previous tick, as the messages of this tick haven't been processed yet + tick_to_check = grid_world.current_nr_ticks-1 + log_statement['correct_tick'] = tick_to_check + if tick_to_check in grid_world.message_manager.preprocessed_messages.keys(): + # loop through all messages of this tick + for message in grid_world.message_manager.preprocessed_messages[tick_to_check]: + # optional: filter only specific types of messages or specific agents here + # Log the message content for the sender and receiver + log_statement[message.from_id + "_sent"] = message.content + log_statement[message.to_id + "_received"] = message.content + # log the entire message to json as a dict + log_statement[message.from_id + "_mssg_json"] = json.dumps(message.__dict__) - - - - - - - - - - - - - - - + return log_statement diff --git a/matrx/logger/log_tick.py b/matrx/logger/log_tick.py index c1abd10f..2fe7b5d0 100644 --- a/matrx/logger/log_tick.py +++ b/matrx/logger/log_tick.py @@ -1,4 +1,4 @@ -from matrx.logger.logger import GridWorldLogger +from matrx.logger.logger import GridWorldLogger, GridWorldLoggerV2 class LogDuration(GridWorldLogger): @@ -14,3 +14,17 @@ def log(self, grid_world, agent_data): } return log_statement + +class LogDurationV2(GridWorldLoggerV2): + """ Log the number of ticks the Gridworld was running on completion """ + + def __init__(self, save_path="", file_name_prefix="", file_extension=".csv", delimeter=";"): + super().__init__(save_path=save_path, file_name=file_name_prefix, file_extension=file_extension, + delimiter=delimeter, log_strategy=self.LOG_ON_LAST_TICK) + + def log(self, world_state, agent_data, grid_world): + log_statement = { + "tick": grid_world.current_nr_ticks + } + + return log_statement diff --git a/matrx/logger/logger.py b/matrx/logger/logger.py index 05880c69..0a301119 100644 --- a/matrx/logger/logger.py +++ b/matrx/logger/logger.py @@ -1,15 +1,65 @@ import csv import datetime import os +import warnings class GridWorldLogger: - """ Base logger class for any MATRX Gridworld logger """ + """ A class to log data during a running world. + + Loggers are meant to collect, process and write data to files during a running world. They can be added through + the :func:`matrx.world_builder.WorldBuilder.add_logger` method. We refer to that method on how to add a logger + to your world. + + Note that a world can have multiple loggers, each resulting in their own unique log file. So you have the + option to create a single large log file with a single logger, or segment the data in some way over different + files with multiple loggers. Another reason for more then one logger is a difference in logging strategy. For + instance to have a logger that logs only at the start or end of the world and a logger that logs at every tick. + + Parameters + ---------- + log_strategy : int, str (default: 1) + When an integer, the logger is called every that many ticks. When a string, should be + GridWorldLogger.LOG_ON_LAST_TICK, GridWorldLogger.LOG_ON_FIRST_TICK or GridWorldLogger.LOG_ON_GOAL_REACHED. + Respectively for only logging on the last tick of the world, the first tick of the world or every time a + goal is reached. + save_path : str (default: "/logs") + The default path were log files are stored. If the path does not exist, it is created. Otherwise log files + are added to that path. If multiple worlds are ran from the same builder, the directory will contain + sub-folders depicting the world's number (e.g., "world_1" for the first world, "world_2" for the second, + etc.). + file_name : str (default: "") + The file name prefix. Every log file is always appended by the timestamp (Y-m-d_H:M:S) and the file + extension. When an empty string, log file names will thus be only the timestamp. + file_extension : str (default: ".csv") + The file name extension to be used. + delimiter : str (default: ";") + The column delimiter to be used in the log files. + + .. deprecated:: 2.1.0 + `GridWorldLogger` will be removed in the future, it is replaced by + `GridWorldLoggerV2` because the latter works with the + :class:`matrx.agents.agent_utils.state.State` object. + + """ + + """Log strategy to log on the last tick of a world.""" LOG_ON_LAST_TICK = "log_last_tick" + + """Log strategy to log on the first tick of a world.""" LOG_ON_FIRST_TICK = "log_first_tick" + + """Log strategy to log every time a goal is reached or completed.""" LOG_ON_GOAL_REACHED = "log_on_reached_goal" def __init__(self, log_strategy=1, save_path="/logs", file_name="", file_extension=".csv", delimiter=";"): + + warnings.warn( + f"{self.__class__.__name__} will be updated in the future towards {self.__class__.__name__}V2. Switch to " + f"the usage of {self.__class__.__name__}V2 to prevent future problems.", + DeprecationWarning, + ) + self.__log_strategy = log_strategy self.__save_path = save_path self.__file_name_prefix = file_name @@ -31,9 +81,47 @@ def file_name(self): return self.__file_name def log(self, grid_world, agent_data): + """ The main method to be overwritten by your own logger class. + + This method is called according to the `log_strategy` set when the logger was added the world builder. For more + details on this see :func:`matrx.world_builder.WorldBuilder.add_logger`. + + Parameters + ---------- + grid_world : GridWorld + The entire grid world instance. Use this to log anything you require to log from the grid world. + agent_data : dict + A dictionary of all data coming from all agents' :func:`matrx.agents.agent_brain.get_log_data` methods. + This dictionary has as keys the agent ids of all agents. As value there is the dictionary their log methods + returned. + Returns + ------- + dict + This method should return a dictionary where the keys are the columns and their value a single row. The + keys (e.g., columns) should be always the same every consecutive call. If this is not the case an exception + is raised. + + """ return {} def _grid_world_log(self, grid_world, agent_data, last_tick=False, goal_status=None): + """ A private MATRX method. + + This is the actual method a grid world calls for each logger. It also performs a data check on the columns and + writes the dictionary to the file. + + Parameters + ---------- + grid_world : GridWorld + The grid world instance passed to the log method. + agent_data : dict + The agent data passed to the log method. + last_tick : bool (default: False) + Whether this is the last tick of the grid world or not. + goal_status : dict + A dictionary with all world goals (keys, WorldGoal) and whether they completed or not (value, bool) + + """ if not self._needs_to_log(grid_world, last_tick, goal_status): return @@ -46,6 +134,30 @@ def _grid_world_log(self, grid_world, agent_data, last_tick=False, goal_status=N self.__write_data(data, grid_world.current_nr_ticks) def _needs_to_log(self, grid_world, last_tick, goal_status): + """ A private MAtRX method. + + Checks if this logger's log method needs to be called given its log strategy. + + Parameters + ---------- + grid_world : GridWorld + The world instance to be logger or not. + last_tick : bool + Whether the world is at its last tick. + goal_status : dict + A dictionary with all world goals (keys, WorldGoal) and whether they completed or not (value, bool) + + Returns + ------- + bool + Returns True if the log method needs to be called, otherwise False. + + Raises + ------ + Exception + Raises a generic exception when the log strategy is not recognized as integer or of a predefined string. + + """ current_tick = grid_world.current_nr_ticks # If the strategy is a tick frequency, check if enough ticks have passed @@ -82,6 +194,16 @@ def _needs_to_log(self, grid_world, last_tick, goal_status): return to_log def _set_world_nr(self, world_nr): + """ A private MATRX method. + + Sets the current world number by creating a sub-folder in the save path with the given world number. + + Parameters + ---------- + world_nr : int + The current world number. + + """ # Set the world number self.__world_nr = world_nr @@ -95,6 +217,28 @@ def _set_world_nr(self, world_nr): self.__file_name = f"{self.__save_path}{os.sep}{self.__file_name}" def __check_data(self, data): + """ A private MATRX method. + + Checks if the to be logged data uses consistent columns (e.g., if the keys of the data dict are the same as the + one given the very first time data was logged). + + Parameters + ---------- + data : dict + The data to be logged from the log method. + + Returns + ------- + bool + Returns True if the columns are consistent with the very first time data was logged or when this is the + first time data is being logged. False otherwise. + + Raises + ------ + Exception + Raises two potential exceptions: When the columns are not consistent, or when the data is not a dictionary. + + """ if isinstance(data, dict): # Check if the data contains new columns, if so raise an exception that we cannot add columns on the fly @@ -109,6 +253,303 @@ def __check_data(self, data): raise Exception(f"The data in this {self.__class__} should be a dictionary.") def __write_data(self, data, tick_nr): + """ A private MATRX method. + + Writes the data to the correct file. + + Parameters + ---------- + data : dict + The data to be logged from the log method. + tick_nr : int + The tick number which is being logged. + + """ + + # We always include the world number and the tick number + if "world_nr" not in data.keys(): + data["world_nr"] = self.__world_nr + if "tick_nr" not in data.keys(): + data["tick_nr"] = tick_nr + + # Check if we have columns to write to, this will be the order in which we write them + if len(self.__columns) == 0: + # Then we set the keys as column names + self.__columns = list(data.keys()) + + # Write the data to the file, create it when it does not exist and in that case write the columns as well + if not os.path.isfile(self.__file_name): + mode = "w+" + write_columns = True + else: + mode = "a" + write_columns = False + + with open(self.__file_name, mode=mode, newline='') as data_file: + csv_writer = csv.DictWriter(data_file, delimiter=self.__delimiter, quotechar='"', + quoting=csv.QUOTE_MINIMAL, fieldnames=self.__columns) + + # Write columns if we need to + if write_columns: + csv_writer.writeheader() + + csv_writer.writerow(data) + + +class GridWorldLoggerV2: + """ A class to log data during a running world. + + Loggers are meant to collect, process and write data to files during a running world. They can be added through + the :func:`matrx.world_builder.WorldBuilder.add_logger` method. We refer to that method on how to add a logger + to your world. + + Note that a world can have multiple loggers, each resulting in their own unique log file. So you have the + option to create a single large log file with a single logger, or segment the data in some way over different + files with multiple loggers. Another reason for more then one logger is a difference in logging strategy. For + instance to have a logger that logs only at the start or end of the world and a logger that logs at every tick. + + Parameters + ---------- + log_strategy : int, str (default: 1) + When an integer, the logger is called every that many ticks. When a string, should be + GridWorldLogger.LOG_ON_LAST_TICK, GridWorldLogger.LOG_ON_FIRST_TICK or GridWorldLogger.LOG_ON_GOAL_REACHED. + Respectively for only logging on the last tick of the world, the first tick of the world or every time a + goal is reached. + save_path : str (default: "/logs") + The default path were log files are stored. If the path does not exist, it is created. Otherwise log files + are added to that path. If multiple worlds are ran from the same builder, the directory will contain + sub-folders depicting the world's number (e.g., "world_1" for the first world, "world_2" for the second, + etc.). + file_name : str (default: "") + The file name prefix. Every log file is always appended by the timestamp (Y-m-d_H:M:S) and the file + extension. When an empty string, log file names will thus be only the timestamp. + file_extension : str (default: ".csv") + The file name extension to be used. + delimiter : str (default: ";") + The column delimiter to be used in the log files. + + """ + + """Log strategy to log on the last tick of a world.""" + LOG_ON_LAST_TICK = "log_last_tick" + + """Log strategy to log on the first tick of a world.""" + LOG_ON_FIRST_TICK = "log_first_tick" + + """Log strategy to log every time a goal is reached or completed.""" + LOG_ON_GOAL_REACHED = "log_on_reached_goal" + + def __init__(self, log_strategy=1, save_path="/logs", file_name="", file_extension=".csv", delimiter=";"): + + self.__log_strategy = log_strategy + self.__save_path = save_path + self.__file_name_prefix = file_name + self.__file_extension = file_extension + self.__delimiter = delimiter + + # Create the file name + current_time = datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S") + self.__file_name = f"{file_name}_{current_time}{file_extension}" + + self.__last_logged_tick = -1 # to track when we logged last + self.__columns = [] # place holder for the columns in our data file + self.__prev_goal_status = {} # to track if a goal was accomplished since last call + + @property + def file_name(self): + """ The file name the loggers writes data to. """ + return self.__file_name + + def log(self, world_state, agent_data, grid_world): + """ The main method to be overwritten by your own logger class. + + This method is called according to the `log_strategy` set when the logger was added the world builder. For more + details on this see :func:`matrx.world_builder.WorldBuilder.add_logger`. + + Parameters + ---------- + world_state : State + The entire world state as a `State` object. Use this to quickly find and locate data you want to log. + agent_data : dict + A dictionary of all data coming from all agents' :func:`matrx.agents.agent_brain.get_log_data` methods. + This dictionary has as keys the agent ids of all agents. As value there is the dictionary their log methods + returned. + grid_world : GridWorld + The entire grid world instance. Use this to log anything not included into the state, such as the messages + send between agents. + Returns + ------- + dict + This method should return a dictionary where the keys are the columns and their value a single row. The + keys (e.g., columns) should be always the same every consecutive call. If this is not the case an exception + is raised. + + """ + return {} + + def _grid_world_log(self, world_state, agent_data, grid_world, last_tick=False, goal_status=None): + """ A private MATRX method. + + This is the actual method a grid world calls for each logger. It also performs a data check on the columns and + writes the dictionary to the file. + + Parameters + ---------- + world_state : State + The entire world state as a `State` object. + agent_data : dict + The agent data passed to the log method. + grid_world: GridWorld + The current grid world instance. + last_tick : bool (default: False) + Whether this is the last tick of the grid world or not. + goal_status : dict + A dictionary with all world goals (keys, WorldGoal) and whether they completed or not (value, bool) + + """ + if not self._needs_to_log(grid_world, last_tick, goal_status): + return + + data = self.log(world_state, agent_data, grid_world) + if data is None or data == {}: + return + + self.__check_data(data) + + self.__write_data(data, grid_world.current_nr_ticks) + + def _needs_to_log(self, grid_world, last_tick, goal_status): + """ A private MAtRX method. + + Checks if this logger's log method needs to be called given its log strategy. + + Parameters + ---------- + grid_world : GridWorld + The world instance to be logger or not. + last_tick : bool + Whether the world is at its last tick. + goal_status : dict + A dictionary with all world goals (keys, WorldGoal) and whether they completed or not (value, bool) + + Returns + ------- + bool + Returns True if the log method needs to be called, otherwise False. + + Raises + ------ + Exception + Raises a generic exception when the log strategy is not recognized as integer or of a predefined string. + + """ + current_tick = grid_world.current_nr_ticks + + # If the strategy is a tick frequency, check if enough ticks have passed + if isinstance(self.__log_strategy, int): + # the current nr ticks minus the tick we last logged should be smaller or equal to our frequency + to_log = (current_tick - self.__last_logged_tick) >= self.__log_strategy + if to_log: + self.__last_logged_tick = current_tick + + # if the strategy is to log at the first tick, we do so if the current tick is zero + elif self.__log_strategy == self.LOG_ON_FIRST_TICK: + to_log = current_tick == 0 + + # if the strategy is to log whenever one of the goals was reached + elif self.__log_strategy == self.LOG_ON_GOAL_REACHED: + to_log = False + # we loop over all goals and see it's status became True whereas it was the previous time False + for goal, status in goal_status.items(): + if goal in self.__prev_goal_status.keys(): + if status and not self.__prev_goal_status[goal]: + to_log = True + break + self.__prev_goal_status = goal_status.copy() + + # If we log on the last tick, only if the GridWorld says it is done + elif self.__log_strategy == self.LOG_ON_LAST_TICK: + to_log = last_tick + + # If the strategy is not found, we return an exception + else: + raise Exception(f"The log strategy {self.__log_strategy} is not recognized. Should be an integer or one of" + f"the GridWorld.ON_LOG_<...> values.") + + return to_log + + def _set_world_nr(self, world_nr): + """ A private MATRX method. + + Sets the current world number by creating a sub-folder in the save path with the given world number. + + Parameters + ---------- + world_nr : int + The current world number. + + """ + # Set the world number + self.__world_nr = world_nr + + # Create the total file name path based on the world number (set by the WorldBuilder on logger creation) + self.__save_path = f"{self.__save_path}{os.sep}world_{world_nr}" + + # Create the directory if not given + if not os.path.exists(self.__save_path): + os.makedirs(self.__save_path) + + self.__file_name = f"{self.__save_path}{os.sep}{self.__file_name}" + + def __check_data(self, data): + """ A private MATRX method. + + Checks if the to be logged data uses consistent columns (e.g., if the keys of the data dict are the same as the + one given the very first time data was logged). + + Parameters + ---------- + data : dict + The data to be logged from the log method. + + Returns + ------- + bool + Returns True if the columns are consistent with the very first time data was logged or when this is the + first time data is being logged. False otherwise. + + Raises + ------ + Exception + Raises two potential exceptions: When the columns are not consistent, or when the data is not a dictionary. + + """ + if isinstance(data, dict): + + # Check if the data contains new columns, if so raise an exception that we cannot add columns on the fly + if len(self.__columns) > 0: + new_columns = set(data.keys()) - set(self.__columns) + if len(new_columns) > 0: + raise Exception(f"Cannot append columns to the log file when we already logged with different " + f"columns. THe following columns are new; {list(new_columns)}") + + return True + else: + raise Exception(f"The data in this {self.__class__} should be a dictionary.") + + def __write_data(self, data, tick_nr): + """ A private MATRX method. + + Writes the data to the correct file. + + Parameters + ---------- + data : dict + The data to be logged from the log method. + tick_nr : int + The tick number which is being logged. + + """ # We always include the world number and the tick number if "world_nr" not in data.keys(): diff --git a/matrx/objects/agent_body.py b/matrx/objects/agent_body.py index 28157887..d523ba5c 100644 --- a/matrx/objects/agent_body.py +++ b/matrx/objects/agent_body.py @@ -418,26 +418,48 @@ def properties(self, property_dictionary: dict): @property def current_action(self): + """The current action the agent is performing.""" return self.__current_action @property def current_action_duration_in_ticks(self): + """The duration as number of ticks of the current action this agent is performing.""" return self.__last_action_duration_data["duration_in_ticks"] @property def current_action_tick_started(self): + """The tick number at which the agent started its current action.""" return self.__last_action_duration_data["tick"] @property def current_action_args(self): + """The arguments used for the current action this agent is performing.""" return self.__current_action_args @property def is_blocked(self): + """Whether this agent is busy performing an action, thus not being able to select a new action.""" return self.__is_blocked def _get_all_classes(class_, omit_super_class=False): + """ A private MATRX method. + + Returns all classes inheriting from the given class that are currently imported. + + Parameters + ---------- + class_ : Class + The class object to search for its children. + omit_super_class : bool (Default: False) + Whether the given parent class should be included or not. + + Returns + ------- + dict + A dictionary of class names (keys, strings) and the actual class object (values, Class). + + """ # Include given class or not if omit_super_class: subclasses = set() diff --git a/matrx/objects/env_object.py b/matrx/objects/env_object.py index 555d37ac..6ee6786a 100644 --- a/matrx/objects/env_object.py +++ b/matrx/objects/env_object.py @@ -74,6 +74,8 @@ class EnvObject: is used by the Visualizer to draw objects in layers. visualize_opacity : Integer. Optional, default obtained from defaults.py Opacity of the object. From 0.0 to 1.0. + visualize_from_center: Boolean. Optional, by default True. + Whether an object should be visualized and scaled from its center point, or top left point. **custom_properties : Dict. Optional Any other keyword arguments. All these are treated as custom attributes. For example the property 'heat'=2.4 of an EnvObject representing a fire. @@ -82,7 +84,7 @@ class EnvObject: def __init__(self, location, name, class_callable, customizable_properties=None, is_traversable=None, is_movable=None, visualize_size=None, visualize_shape=None, visualize_colour=None, visualize_depth=None, - visualize_opacity=None, **custom_properties): + visualize_opacity=None, visualize_from_center=None, **custom_properties): # Set the object's name. self.obj_name = name @@ -92,8 +94,8 @@ def __init__(self, location, name, class_callable, customizable_properties=None, if not hasattr(self, "obj_id"): # remove double spaces tmp_obj_name = " ".join(name.split()) - # append a object ID to the end of the object name - self.obj_id = _ensure_unique_obj_ID(f"{tmp_obj_name}".replace(" ", "_")) + # create the object ID based on the object name + self.obj_id = f"{tmp_obj_name}".replace(" ", "_") # prevent breaking of the frontend if "#" in self.obj_id: @@ -136,6 +138,8 @@ def __init__(self, location, name, class_callable, customizable_properties=None, visualize_depth = defaults.ENVOBJECT_VIS_DEPTH if is_movable is None: is_movable = defaults.ENVOBJECT_IS_MOVABLE + if visualize_from_center is None: + visualize_from_center = defaults.ENVOBJECT_VIS_FROM_CENTER # Set the mandatory properties @@ -146,6 +150,7 @@ def __init__(self, location, name, class_callable, customizable_properties=None, self.visualize_size = visualize_size self.is_traversable = is_traversable self.is_movable = is_movable + self.visualize_from_center = visualize_from_center # Since carried_by cannot be defined beforehand (it contains the unique id's of objects that carry this object) # we set it to an empty list by default. @@ -228,6 +233,8 @@ def change_property(self, property_name, property_value): elif property_name == "is_movable": assert isinstance(property_value, bool) self.is_movable = property_value + elif property_name == visualize_from_center: + self.visualize_from_center = property_value return self.properties @@ -306,7 +313,8 @@ def properties(self): "shape": self.visualize_shape, "colour": self.visualize_colour, "depth": self.visualize_depth, - "opacity": self.visualize_opacity + "opacity": self.visualize_opacity, + "visualize_from_center": self.visualize_from_center } return properties @@ -318,30 +326,20 @@ def properties(self, property_dictionary: dict): """ pass +def _get_inheritence_path(callable_class): + """ Returns the parent's class names of the given class. + Parameters + ---------- + callable_class : Class + The class object for which to return its parent classes. -# keep track of all obj IDs added so far -added_obj_ids = [] - -def _ensure_unique_obj_ID(obj_id): - """ Make sure every obj ID is unique by adding an increasing count to objects with duplicate names. - Example: three objects named "drone". The object IDs will then become "drone", "drone_1", "drone_2", etc.""" - if obj_id in added_obj_ids: - - # found the next obj ID based on the obj name + count - i = 1 - while f"{obj_id}_{i}" in added_obj_ids: - i += 1 - - # warnings.warn(f"There already exists an object with name {obj_id}, new object ID is: {obj_id}_{i}") - obj_id = f"{obj_id}_{i}" - - # keep track of all object IDs we added - added_obj_ids.append(obj_id) - return obj_id - + Returns + ------- + list + The list of names of the parent classes. -def _get_inheritence_path(callable_class): + """ parents = callable_class.mro() parents = [str(p.__name__) for p in parents] return parents diff --git a/matrx/objects/standard_objects.py b/matrx/objects/standard_objects.py index d6f64446..ba03b901 100644 --- a/matrx/objects/standard_objects.py +++ b/matrx/objects/standard_objects.py @@ -14,13 +14,17 @@ class SquareBlock(EnvObject): Location of door. name : string. Optional, default "Block" Name of block, defaults to "Block" - **custom_properties: + **kwargs: Additional properties that should be added to the object. """ - def __init__(self, location, name="Block", visualize_colour="#4286f4", **custom_properties): - super().__init__(name=name, location=location, visualize_shape=0, is_traversable=False, - class_callable=SquareBlock, visualize_colour=visualize_colour, **custom_properties) + def __init__(self, location, name="Block", visualize_colour="#4286f4", **kwargs): + # hardcoded props + kwargs['is_traversable'] = False + kwargs['visualize_shape'] = 0 + + super().__init__(name=name, location=location, class_callable=SquareBlock, + visualize_colour=visualize_colour, **kwargs) class Door(EnvObject): @@ -61,9 +65,11 @@ def __init__(self, location, is_open, name="Door", open_colour="#006400", closed # If the door is open or closed also determines its is_traversable property is_traversable = self.is_open + # hardcoded prop + kwargs['is_movable'] = False + super().__init__(location=location, name=name, is_traversable=is_traversable, visualize_colour=current_color, - is_open=self.is_open, class_callable=Door, is_movable=False, - customizable_properties=['is_open'], **kwargs) + is_open=self.is_open, class_callable=Door, customizable_properties=['is_open'], **kwargs) def open_door(self): """ Opens the door, changes the colour and sets the properties as such. @@ -103,13 +109,20 @@ class Wall(EnvObject): The location of the wall. name : string. Optional, default "Wall" The name, default "Wall". + visualize_colour: string. Optional, default "#000000" (black) + A Hex string indicating the colour of the wall. + kwargs: dict (optional) + A dictionary of keyword arguments that can be used to add additional properties """ def __init__(self, location, name="Wall", visualize_colour="#000000", **kwargs): - is_traversable = False # All walls are always not traversable - super().__init__(name=name, location=location, visualize_colour=visualize_colour, - is_traversable=is_traversable, class_callable=Wall, is_movable=False, - **kwargs) + # a wall is immovable and impassable + kwargs['is_traversable'] = False + kwargs['is_movable'] = False + + is_traversable = False # Walls are never traversable + super().__init__(name=name, location=location, visualize_colour=visualize_colour, class_callable=Wall, + **kwargs) class AreaTile(EnvObject): @@ -134,8 +147,11 @@ class AreaTile(EnvObject): """ def __init__(self, location, name="AreaTile", visualize_colour="#8ca58c", visualize_depth=None, visualize_opacity=1.0, **kwargs): - super().__init__(name=name, location=location, visualize_colour=visualize_colour, - is_traversable=True, is_movable=False, class_callable=AreaTile, + # a floor is always passable and immovable + kwargs['is_traversable'] = True + kwargs['is_movable'] = False + + super().__init__(name=name, location=location, visualize_colour=visualize_colour, class_callable=AreaTile, visualize_depth=visualize_depth, visualize_opacity=visualize_opacity, **kwargs) @@ -157,12 +173,14 @@ class SmokeTile(AreaTile): Opacity of the object. By default 0.8 visualize_depth : int. Optional, default=101 depth of visualization. By default 101: just above agent and other objects Higher means higher priority. + kwargs: dict (optional) + A dictionary of keyword arguments that can be used to add additional properties """ def __init__(self, location, name="SmokeTile", visualize_colour="#b7b7b7", visualize_opacity=0.8, - visualize_depth=101): - visualize_depth = 101 if visualize_depth is None else visualize_depth + visualize_depth=101, **kwargs): super().__init__(name=name, location=location, visualize_colour=visualize_colour, - visualize_opacity=visualize_opacity, visualize_depth=visualize_depth) + visualize_opacity=visualize_opacity, visualize_depth=visualize_depth, + **kwargs) class Battery(EnvObject): @@ -310,6 +328,8 @@ class CollectionDropOffTile(AreaTile): The colour of this tile. visualize_opacity : Float (default is 1.0) The opacity of this tile. Should be between 0.0 and 1.0. + kwargs: dict (optional) + A dictionary of keyword arguments that can be used to add additional properties See also -------- @@ -322,6 +342,9 @@ class CollectionDropOffTile(AreaTile): """ def __init__(self, location, name="Collection_zone", collection_area_name="Collection zone", visualize_colour="#64a064", visualize_opacity=1.0, **kwargs): - super().__init__(location, name=name, visualize_colour=visualize_colour, visualize_depth=None, - visualize_opacity=visualize_opacity, is_drop_off=True, - collection_area_name=collection_area_name, **kwargs) + # hardcoded props + kwargs['is_drop_off'] = True + kwargs['visualize_depth'] = None + super().__init__(location, name=name, visualize_colour=visualize_colour, + visualize_opacity=visualize_opacity, collection_area_name=collection_area_name, + **kwargs) diff --git a/matrx/world_builder.py b/matrx/world_builder.py index c8b60fad..5e16391f 100644 --- a/matrx/world_builder.py +++ b/matrx/world_builder.py @@ -12,7 +12,7 @@ from matrx.agents.capabilities.capability import SenseCapability from matrx.agents.agent_types.human_agent import HumanAgentBrain from matrx.grid_world import GridWorld -from matrx.logger.logger import GridWorldLogger +from matrx.logger.logger import GridWorldLogger, GridWorldLoggerV2 from matrx.objects.agent_body import AgentBody from matrx.objects.env_object import EnvObject, _get_inheritence_path from matrx import utils @@ -352,7 +352,7 @@ def add_logger(self, logger_class, log_strategy=None, save_path=None, """ - if issubclass(logger_class, GridWorldLogger): + if issubclass(logger_class, GridWorldLogger) or issubclass(logger_class, GridWorldLoggerV2): set_params = {'log_strategy': log_strategy, 'save_path': save_path, 'file_name': file_name, @@ -1123,10 +1123,6 @@ def add_object(self, location, name, callable_class=None, f"{(int(location[0]), int(location[1]))}") location = (int(location[0]), int(location[1])) - # Load default parameters if not passed - if is_movable is None: - is_movable = defaults.ENVOBJECT_IS_MOVABLE - # If default variables are not given, assign them (most empty, except # of sense_capability that defaults to all objects with infinite # range). @@ -1904,6 +1900,9 @@ def add_line(self, start, end, name, callable_class=None, def add_room(self, top_left_location, width, height, name, door_locations=None, with_area_tiles=False, doors_open=False, + door_open_colour=None, + door_closed_colour=None, + door_visualization_opacity=None, wall_visualize_colour=None, wall_visualize_opacity=None, wall_custom_properties=None, wall_customizable_properties=None, @@ -1944,6 +1943,15 @@ def add_room(self, top_left_location, width, height, name, doors_open : bool (optional, False) Whether the doors are initially open or closed. + door_open_colour: str (optional, "#006400") + Colour, as hexidecimal string, when a room door is closed. Defaults to a shade of green. + + door_closed_colour: str (optional, "#640000") + Colour, as hexidecimal string, when a room door is open. Defaults to a shade of red. + + door_visualization_opacity: str (optional, 1.0) + Opacity of the object, as a percentge from 0.0 (fully opaque) to 1.0 (no opacity). Defaults to 1.0. + wall_visualize_colour : string (optional, default None) The colour of the walls. @@ -2071,6 +2079,8 @@ def add_room(self, top_left_location, width, height, name, # Add all doors for door_loc in door_locations: self.add_object(location=door_loc, name=f"{name} - door@{door_loc}", callable_class=Door, + open_colour=door_open_colour, closed_colour=door_closed_colour, + visualize_opacity=door_visualization_opacity, is_open=doors_open, **{"room_name": name}) # Add all area tiles if required @@ -2088,44 +2098,6 @@ def add_room(self, top_left_location, width, height, name, customizable_properties=area_customizable_properties, **{**area_custom_properties, "room_name": name}) - @staticmethod - def get_room_locations(room_top_left, room_width, room_height): - """ Returns the locations within a room, excluding walls. - - .. deprecated:: 1.1.0 - `get_room_locations` will be removed in MATRX 1.2.0, it is replaced by - `matrx.utils.get_room_locations`. - - This is a helper function for adding objects to a room. It returns a - list of all (x,y) coordinates that fall within the room excluding the - walls. - - Parameters - ---------- - room_top_left : tuple, (x, y) - The top left coordinates of a room, as used to add that room with - methods such as `add_room`. - room_width : int - The width of the room. - room_height : int - The height of the room. - - Returns - ------- - list, [(x,y), ...] - A list of (x, y) coordinates that are encapsulated in the - rectangle, excluding walls. - - See Also - -------- - WorldBuilder.add_room - - """ - warnings.warn("This method is deprecated and will be removed in v1.2.0. It is replaced by" - "`matrx.utils.get_room_locations` as of v1.1.0.", DeprecationWarning) - locs = utils.get_room_locations(room_top_left, room_width, room_height) - return locs - def __set_world_settings(self, shape, tick_duration, simulation_goal, rnd_seed, visualization_bg_clr, visualization_bg_img, verbose): @@ -2173,7 +2145,8 @@ def __create_world(self): for idx, obj_settings in enumerate(self.object_settings): # Print progress (so user knows what is going on) if idx % max(10, int(len(self.object_settings) * 0.1)) == 0: - print(f"Creating objects... @{np.round(idx / len(self.object_settings) * 100, 0)}%") + if self.verbose: + print(f"Creating objects... @{np.round(idx / len(self.object_settings) * 100, 0)}%") env_object = self.__create_env_object(obj_settings) if env_object is not None: @@ -2184,7 +2157,8 @@ def __create_world(self): for idx, agent_settings in enumerate(self.agent_settings): # Print progress (so user knows what is going on) if idx % max(10, int(len(self.agent_settings) * 0.1)) == 0: - print(f"Creating agents... @{np.round(idx / len(self.agent_settings) * 100, 0)}%") + if self.verbose: + print(f"Creating agents... @{np.round(idx / len(self.agent_settings) * 100, 0)}%") agent, agent_avatar = self.__create_agent_avatar(agent_settings) if agent_avatar is not None: @@ -2194,7 +2168,8 @@ def __create_world(self): for idx, env_object in enumerate(objs): # Print progress (so user knows what is going on) if idx % max(10, int(len(objs) * 0.1)) == 0: - print(f"Adding objects... @{np.round(idx / len(objs) * 100, 0)}%") + if self.verbose: + print(f"Adding objects... @{np.round(idx / len(objs) * 100, 0)}%") world._register_env_object(env_object) @@ -2202,7 +2177,8 @@ def __create_world(self): for idx, agent in enumerate(avatars): # Print progress (so user knows what is going on) if idx % max(10, int(len(objs) * 0.1)) == 0: - print(f"Adding agents... @{np.round(idx / len(avatars) * 100, 0)}%") + if self.verbose: + print(f"Adding agents... @{np.round(idx / len(avatars) * 100, 0)}%") world._register_agent(agent[0], agent[1]) # Register all teams and who is in them @@ -2255,52 +2231,42 @@ def __create_env_object(self, settings): **custom_props} else: # else we need to check what this object's constructor requires and obtain those properties only - # Get all variables required by constructor + + # get required arguments of this class argspecs = inspect.getfullargspec(callable_class) args = argspecs.args # does not give *args or **kwargs names def_args = argspecs.defaults # defaults (if any) of the last n elements in args varkw = argspecs.varkw # **kwargs names - # Now assign the default values to kwargs dictionary + # assign specified default values to any of the required arguments args = OrderedDict({arg: "not_set" for arg in reversed(args[1:])}) if def_args is not None: for idx, default in enumerate(reversed(def_args)): k = list(args.keys())[idx] args[k] = default - # Check if all arguments are present (fails if a required argument without a default value is not given) + # get arguments given by the user + given_args = {**mandatory_props, **custom_props} + + # make sure all required arguments are present for arg, default in args.items(): - if arg not in custom_props.keys() and arg not in mandatory_props.keys() and default == "not_set": + if default == "not_set" and arg not in given_args.keys(): raise Exception(f"Cannot create environment object of type {callable_class.__name__} with name " f"{mandatory_props['name']}, as its constructor requires the argument named {arg} " f"which is not given as a property.") - elif arg in custom_props.keys() and custom_props[arg] is not None: - # an argument is present in custom_props, which overrides constructor defaults - args[arg] = custom_props[arg] - elif arg in mandatory_props.keys() and mandatory_props[arg] is not None: - # an argument is present in mandatory_props, which overrides constructor defaults - args[arg] = mandatory_props[arg] - - # We provide a warning if some custom properties are given which are not used for this class - kwargs = [prop_name for prop_name in custom_props.keys() if prop_name not in args.keys()] - if varkw is None and len(kwargs) > 0: - warnings.warn(f"The following properties are not used in the creation of environment object of type " - f"{callable_class.__name__} with name {mandatory_props['name']}; {kwargs}, because " - f"the class does not have a **kwargs argument in the constructor.") - - # if a **kwargs argument was defined in the object constructor, pass all custom properties to the object - elif varkw is not None and len(kwargs) > 0: - for arg in kwargs: - args[arg] = custom_props[arg] + + # combine the given arguments and any required arguments with defaults that were not specified by the user + for arg, val in given_args.items(): + if val is not None: + args[arg] = val args = self.__instantiate_random_properties(args) env_object = callable_class(**args) - # make the ID unique if it is not - return env_object + def __create_agent_avatar(self, settings): agent = settings['agent'] diff --git a/matrx_visualizer/static/js/gen_grid.js b/matrx_visualizer/static/js/gen_grid.js index e8c17975..19899b55 100644 --- a/matrx_visualizer/static/js/gen_grid.js +++ b/matrx_visualizer/static/js/gen_grid.js @@ -119,7 +119,8 @@ function draw(state, world_settings, new_messages, accessible_chatrooms, new_tic "opacity": obj['visualization']['opacity'], "dimension": tile_size, // width / height of the tile "busy": (show_busy_condition ? obj['is_blocked_by_action'] : false), // show busy if available and requested - "selected": (object_selected == objID ? true : false) + "selected": (object_selected == objID ? true : false), + "visualize_from_center": obj['visualization']['visualize_from_center'] }; // Check if any subtiles have been defined and include them in the ob_vis_settings if so @@ -457,9 +458,12 @@ function gen_rectangle(obj_vis_settings, obj_element, element_type = "div") { // no subtiles defined, place the rectangle in the usual manner } else { - // coords of top left corner, such that it is centered in our tile - shape.style.left = ((1 - size) * 0.5 * tile_size) + "px"; - shape.style.top = ((1 - size) * 0.5 * tile_size) + "px"; + // there is a specific setting for rendering objects from the top left instead of center + if (!("visualize_from_center" in obj_vis_settings && obj_vis_settings['visualize_from_center'] == false)) { + // coords of top left corner, such that it is centered in our tile + shape.style.left = ((1 - size) * 0.5 * tile_size) + "px"; + shape.style.top = ((1 - size) * 0.5 * tile_size) + "px"; + } // width and height of rectangle shape.style.width = size * tile_size + "px"; diff --git a/matrx_visualizer/static/js/startscreen.js b/matrx_visualizer/static/js/startscreen.js index f218e5a8..37f48c58 100644 --- a/matrx_visualizer/static/js/startscreen.js +++ b/matrx_visualizer/static/js/startscreen.js @@ -1,4 +1,6 @@ -var ss_update_url = 'http://127.0.0.1:3001/get_latest_state/'; +var ss_base_url = window.location.hostname, + ss_update_url = 'http://' + ss_base_url + ':3001/get_latest_state/'; + var ss_state = null, ss_world_settings = null,