diff --git a/docs/apis/experimental.md b/docs/apis/experimental.md index 4115feeb502..d2d16fdcf32 100644 --- a/docs/apis/experimental.md +++ b/docs/apis/experimental.md @@ -25,3 +25,15 @@ This namespace contains experimental features. These are under development, and .. automodule:: experimental.continuous_space.continuous_space_agents :members: ``` + +## Continuous Space + +```{eval-rst} +.. automodule:: experimental.continuous_space.continuous_space + :members: +``` + +```{eval-rst} +.. automodule:: experimental.continuous_space.continuous_space_agents + :members: +``` diff --git a/mesa/agent.py b/mesa/agent.py index 7ed39ffe5d5..432b06b441e 100644 --- a/mesa/agent.py +++ b/mesa/agent.py @@ -53,13 +53,12 @@ def __init__(self, model: Model, *args, **kwargs) -> None: Args: model (Model): The model instance in which the agent exists. - args: passed on to super - kwargs: passed on to super + args: Passed on to super. + kwargs: Passed on to super. Notes: to make proper use of python's super, in each class remove the arguments and keyword arguments you need and pass on the rest to super - """ super().__init__(*args, **kwargs) @@ -103,7 +102,10 @@ def create_agents(cls, model: Model, n: int, *args, **kwargs) -> AgentSet[Agent] """ class ListLike: - """Helper class to make default arguments act as if they are in a list of length N.""" + """Make default arguments act as if they are in a list of length N. + + This is a helper class. + """ def __init__(self, value): self.value = value diff --git a/mesa/examples/__init__.py b/mesa/examples/__init__.py index beecd6a1b6a..90e6cd1e2a3 100644 --- a/mesa/examples/__init__.py +++ b/mesa/examples/__init__.py @@ -1,3 +1,4 @@ +from mesa.examples.advanced.alliance_formation.model import MultiLevelAllianceModel from mesa.examples.advanced.epstein_civil_violence.model import EpsteinCivilViolence from mesa.examples.advanced.pd_grid.model import PdGrid from mesa.examples.advanced.sugarscape_g1mt.model import SugarscapeG1mt @@ -13,6 +14,7 @@ "BoltzmannWealth", "ConwaysGameOfLife", "EpsteinCivilViolence", + "MultiLevelAllianceModel", "PdGrid", "Schelling", "SugarscapeG1mt", diff --git a/mesa/examples/advanced/alliance_formation/Readme.md b/mesa/examples/advanced/alliance_formation/Readme.md new file mode 100644 index 00000000000..bb04dea94d0 --- /dev/null +++ b/mesa/examples/advanced/alliance_formation/Readme.md @@ -0,0 +1,50 @@ +# Alliance Formation Model (Meta-Agent Example) + +## Summary + +This model demonstrates Mesa's meta agent capability. + +**Overview of meta agent:** Complex systems often have multiple levels of components. A city is not a single entity, but it is made of districts,neighborhoods, buildings, and people. A forest comprises an ecosystem of trees, plants, animals, and microorganisms. An organization is not one entity, but is made of departments, sub-departments, and people. A person is not a single entity, but it is made of micro biomes, organs and cells. + +This reality is the motivation for meta-agents. It allows users to represent these multiple levels, where each level can have agents with sub-agents. + +This model demonstrates Mesa's ability to dynamically create new classes of agents that are composed of existing agents. These meta-agents inherits functions and attributes from their sub-agents and users can specify new functionality or attributes they want the meta agent to have. For example, if a user is doing a factory simulation with autonomous systems, each major component of that system can be a sub-agent of the overall robot agent. Or, if someone is doing a simulation of an organization, individuals can be part of different organizational units that are working for some purpose. + +To provide a simple demonstration of this capability is an alliance formation model. + +In this simulation n agents are created, who have two attributes (1) power and (2) preference. Each attribute is a number between 0 and 1 over a gaussian distribution. Agents then randomly select other agents and use the [bilateral shapley value](https://en.wikipedia.org/wiki/Shapley_value) to determine if they should form an alliance. If the expected utility support an alliances, the agent creates a meta-agent. Subsequent steps may add agents to the meta-agent, create new instances of similar hierarchy, or create a new hierarchy level where meta-agents form an alliance of meta-agents. In this visualization of this model a new meta-agent hierarchy will be a larger node and a new color. + +In MetaAgents current configuration, agents being part of multiple meta-agents is not supported. + +If you would like to see an example of explicit meta-agent formation see the [warehouse model in the Mesa example's repository](https://github.com/projectmesa/mesa-examples/tree/main/examples/warehouse) + + +## Installation + +This model requires Mesa's recommended install and scipy + +``` + $ pip install mesa[rec] +``` + +## How to Run + +To run the model interactively, in this directory, run the following command + +``` + $ solara run app.py +``` + +## Files + +- `model.py`: Contains creation of agents, the network and management of agent execution. +- `agents.py`: Contains logic for forming alliances and creation of new agents +- `app.py`: Contains the code for the interactive Solara visualization. + +## Further Reading + +The full tutorial describing how the model is built can be found at: +https://mesa.readthedocs.io/en/latest/tutorials/intro_tutorial.html + +An example of the bilateral shapley value in another model: +[Techno-Social Energy Infrastructure Siting: Sustainable Energy Modeling Programming (SEMPro)](https://www.jasss.org/16/3/6.html) diff --git a/mesa/examples/advanced/alliance_formation/__init__ .py b/mesa/examples/advanced/alliance_formation/__init__ .py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/mesa/examples/advanced/alliance_formation/agents.py b/mesa/examples/advanced/alliance_formation/agents.py new file mode 100644 index 00000000000..4f33bb5b9a3 --- /dev/null +++ b/mesa/examples/advanced/alliance_formation/agents.py @@ -0,0 +1,20 @@ +import mesa + + +class AllianceAgent(mesa.Agent): + """ + Agent has three attributes power (float), position (float) and level (int) + + """ + + def __init__(self, model, power, position, level=0): + super().__init__(model) + self.power = power + self.position = position + self.level = level + + """ + For this demo model agent only need attributes. + + More complex models could have functions that define agent behavior. + """ diff --git a/mesa/examples/advanced/alliance_formation/app.py b/mesa/examples/advanced/alliance_formation/app.py new file mode 100644 index 00000000000..d9faff91c40 --- /dev/null +++ b/mesa/examples/advanced/alliance_formation/app.py @@ -0,0 +1,71 @@ +import matplotlib.pyplot as plt +import networkx as nx +import solara +from matplotlib.figure import Figure + +from mesa.examples.advanced.alliance_formation.model import MultiLevelAllianceModel +from mesa.visualization import SolaraViz +from mesa.visualization.utils import update_counter + +model_params = { + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "n": { + "type": "SliderInt", + "value": 50, + "label": "Number of agents:", + "min": 10, + "max": 100, + "step": 1, + }, +} + +# Create visualization elements. The visualization elements are solara components +# that receive the model instance as a "prop" and display it in a certain way. +# Under the hood these are just classes that receive the model instance. +# You can also author your own visualization elements, which can also be functions +# that receive the model instance and return a valid solara component. + + +@solara.component +def plot_network(model): + update_counter.get() + g = model.network + pos = nx.fruchterman_reingold_layout(g) + fig = Figure() + ax = fig.subplots() + labels = {agent.unique_id: agent.unique_id for agent in model.agents} + node_sizes = [g.nodes[node]["size"] for node in g.nodes] + node_colors = [g.nodes[node]["size"] for node in g.nodes()] + + nx.draw( + g, + pos, + node_size=node_sizes, + node_color=node_colors, + cmap=plt.cm.coolwarm, + labels=labels, + ax=ax, + ) + + solara.FigureMatplotlib(fig) + + +# Create initial model instance +model = MultiLevelAllianceModel(50) + +# Create the SolaraViz page. This will automatically create a server and display the +# visualization elements in a web browser. +# Display it using the following command in the example directory: +# solara run app.py +# It will automatically update and display any changes made to this file +page = SolaraViz( + model, + components=[plot_network], + model_params=model_params, + name="Alliance Formation Model", +) +page # noqa diff --git a/mesa/examples/advanced/alliance_formation/model.py b/mesa/examples/advanced/alliance_formation/model.py new file mode 100644 index 00000000000..6eaa21ab414 --- /dev/null +++ b/mesa/examples/advanced/alliance_formation/model.py @@ -0,0 +1,184 @@ +import networkx as nx +import numpy as np + +import mesa +from mesa import Agent +from mesa.examples.advanced.alliance_formation.agents import AllianceAgent +from mesa.experimental.meta_agents.meta_agent import ( + create_meta_agent, + find_combinations, +) + + +class MultiLevelAllianceModel(mesa.Model): + """ + Model for simulating multi-level alliances among agents. + """ + + def __init__(self, n=50, mean=0.5, std_dev=0.1, seed=42): + """ + Initialize the model. + + Args: + n (int): Number of agents. + mean (float): Mean value for normal distribution. + std_dev (float): Standard deviation for normal distribution. + seed (int): Random seed. + """ + super().__init__(seed=seed) + self.population = n + self.network = nx.Graph() # Initialize the network + self.datacollector = mesa.DataCollector(model_reporters={"Network": "network"}) + + # Create Agents + power = self.rng.normal(mean, std_dev, n) + power = np.clip(power, 0, 1) + position = self.rng.normal(mean, std_dev, n) + position = np.clip(position, 0, 1) + AllianceAgent.create_agents(self, n, power, position) + agent_ids = [ + (agent.unique_id, {"size": 300, "level": 0}) for agent in self.agents + ] + self.network.add_nodes_from(agent_ids) + + def add_link(self, meta_agent, agents): + """ + Add links between a meta agent and its constituent agents in the network. + + Args: + meta_agent (MetaAgent): The meta agent. + agents (list): List of agents. + """ + for agent in agents: + self.network.add_edge(meta_agent.unique_id, agent.unique_id) + + def calculate_shapley_value(self, agents): + """ + Calculate the Shapley value of the two agents. + + Args: + agents (list): List of agents. + + Returns: + tuple: Potential utility, new position, and level. + """ + positions = agents.get("position") + new_position = 1 - (max(positions) - min(positions)) + potential_utility = agents.agg("power", sum) * 1.2 * new_position + + value_0 = 0.5 * agents[0].power + 0.5 * (potential_utility - agents[1].power) + value_1 = 0.5 * agents[1].power + 0.5 * (potential_utility - agents[0].power) + + if value_0 > agents[0].power and value_1 > agents[1].power: + if agents[0].level > agents[1].level: + level = agents[0].level + elif agents[0].level == agents[1].level: + level = agents[0].level + 1 + else: + level = agents[1].level + + return potential_utility, new_position, level + + def only_best_combination(self, combinations): + """ + Filter to keep only the best combination for each agent. + + Args: + combinations (list): List of combinations. + + Returns: + dict: Unique combinations. + """ + best = {} + # Determine best option for EACH agent + for group, value in combinations: + agent_ids = sorted(group.get("unique_id")) # by default is bilateral + # Deal with all possibilities + if ( + agent_ids[0] not in best and agent_ids[1] not in best + ): # if neither in add both + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + elif ( + agent_ids[0] in best and agent_ids[1] in best + ): # if both in, see if both would be trading up + if ( + value[0] > best[agent_ids[0]][1][0] + and value[0] > best[agent_ids[1]][1][0] + ): + # Remove the old alliances + del best[best[agent_ids[0]][2][1]] + del best[best[agent_ids[1]][2][0]] + # Add the new alliance + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + elif ( + agent_ids[0] in best + ): # if only agent_ids[0] in, see if it would be trading up + if value[0] > best[agent_ids[0]][1][0]: + # Remove the old alliance for agent_ids[0] + del best[best[agent_ids[0]][2][1]] + # Add the new alliance + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + elif ( + agent_ids[1] in best + ): # if only agent_ids[1] in, see if it would be trading up + if value[0] > best[agent_ids[1]][1][0]: + # Remove the old alliance for agent_ids[1] + del best[best[agent_ids[1]][2][0]] + # Add the new alliance + best[agent_ids[0]] = [group, value, agent_ids] + best[agent_ids[1]] = [group, value, agent_ids] + + # Create a unique dictionary of the best combinations + unique_combinations = {} + for group, value, agents_nums in best.values(): + unique_combinations[tuple(agents_nums)] = [group, value] + + return unique_combinations.values() + + def step(self): + """ + Execute one step of the model. + """ + # Get all other agents of the same type + agent_types = list(self.agents_by_type.keys()) + + for agent_type in agent_types: + similar_agents = self.agents_by_type[agent_type] + + # Find the best combinations using find_combinations + if ( + len(similar_agents) > 1 + ): # only form alliances if there are more than 1 agent + combinations = find_combinations( + self, + similar_agents, + size=2, + evaluation_func=self.calculate_shapley_value, + filter_func=self.only_best_combination, + ) + + for alliance, attributes in combinations: + class_name = f"MetaAgentLevel{attributes[2]}" + meta = create_meta_agent( + self, + class_name, + alliance, + Agent, + meta_attributes={ + "level": attributes[2], + "power": attributes[0], + "position": attributes[1], + }, + ) + + # Update the network if a new meta agent instance created + if meta: + self.network.add_node( + meta.unique_id, + size=(meta.level + 1) * 300, + level=meta.level, + ) + self.add_link(meta, meta.agents) diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 014dc7c7637..0e5950affc6 100644 --- a/mesa/experimental/__init__.py +++ b/mesa/experimental/__init__.py @@ -15,6 +15,6 @@ - Features graduate from experimental status once their APIs are stabilized """ -from mesa.experimental import continuous_space, devs, mesa_signals +from mesa.experimental import continuous_space, devs, mesa_signals, meta_agents -__all__ = ["continuous_space", "devs", "mesa_signals"] +__all__ = ["continuous_space", "devs", "mesa_signals", "meta_agents"] diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py new file mode 100644 index 00000000000..f4f3231295b --- /dev/null +++ b/mesa/experimental/meta_agents/__init__.py @@ -0,0 +1,25 @@ +"""This method is for dynamically creating new agents (meta-agents). + +Meta-agents are defined as agents composed of existing agents. + +Meta-agents are created dynamically with a pointer to the model, name of the meta-agent,, +iterable of agents to belong to the new meta-agents, any new functions for the meta-agent, +any new attributes for the meta-agent, whether to retain sub-agent functions, +whether to retain sub-agent attributes. + +Examples of meta-agents: +- An autonomous car where the subagents are the wheels, sensors, +battery, computer etc. and the meta-agent is the car itself. +- A company where the subagents are employees, departments, buildings, etc. +- A city where the subagents are people, buildings, streets, etc. + +Currently meta-agents are restricted to one parent agent for each subagent/ +one meta-agent per subagent. + +Goal is to assess usage and expand functionality. + +""" + +from .meta_agent import MetaAgent + +__all__ = ["MetaAgent"] diff --git a/mesa/experimental/meta_agents/meta_agent.py b/mesa/experimental/meta_agents/meta_agent.py new file mode 100644 index 00000000000..7a5ffe049cf --- /dev/null +++ b/mesa/experimental/meta_agents/meta_agent.py @@ -0,0 +1,387 @@ +"""Implementation of Mesa's meta agent capability. + +Overview: Complex systems often have multiple levels of components. An +organization is not one entity, but is made of departments, sub-departments, +and people. A person is not a single entity, but it is made of micro biomes, +organs and cells. A city is not a single entity, but it is made of districts, +neighborhoods, buildings, and people. A forest comprises an ecosystem of +trees, plants, animals, and microorganisms. + +This reality is the motivation for meta-agents. It allows users to represent +these multiple levels, where each level can have agents with constituting_agents. + +To demonstrate meta-agents capability there are two examples: +1 - Alliance formation which shows emergent meta-agent formation in +advanced examples: +https://github.com/projectmesa/mesa/tree/main/mesa/examples/advanced/alliance_formation +2 - Warehouse model in the Mesa example's repository +https://github.com/projectmesa/mesa-examples/tree/main/examples/warehouse + +To accomplish this the MetaAgent module is as follows: + +This contains four helper functions and a MetaAgent class that can be used to +create agents that contain other agents as components. + +Helper methods: +1 - find_combinations: Find combinations of agents to create a meta-agent +constituting_set. +2- evaluate_combination: Evaluate combinations of agents by some user based +criteria to determine if it should be a constituting_set of agents. +3- extract_class: Helper function for create_meta-agent. Extracts the types of +agent being created to create a new instance of that agent type. +4- create_meta_agent: Create a new meta-agent class and instantiate +agents in that class. + +Meta-Agent class (MetaAgent): An agent that contains other agents +as components. + +. +""" + +import itertools +from collections.abc import Callable, Iterable +from types import MethodType +from typing import Any + +from mesa.agent import Agent, AgentSet + + +def evaluate_combination( + candidate_group: tuple[Agent, ...], + model, + evaluation_func: Callable[[AgentSet], float] | None, +) -> tuple[AgentSet, float] | None: + """Evaluate a combination of agents. + + Args: + candidate_group (Tuple[Agent, ...]): The group of agents to evaluate. + model: The model instance. + evaluation_func (Optional[Callable[[AgentSet], float]]): The function + to evaluate the group. + + Returns: + Optional[Tuple[AgentSet, float]]: The evaluated group and its value, + or None. + """ + group_set = AgentSet(candidate_group, random=model.random) + if evaluation_func: + value = evaluation_func(group_set) + return group_set, value + return None + + +def find_combinations( + model, + group: AgentSet, + size: int | tuple[int, int] = (2, 5), + evaluation_func: Callable[[AgentSet], float] | None = None, + filter_func: Callable[[list[tuple[AgentSet, float]]], list[tuple[AgentSet, float]]] + | None = None, +) -> list[tuple[AgentSet, float]]: + """Find valuable combinations of agents in this set. + + Args: + model: The model instance. + group (AgentSet): The set of agents to find combinations in. + size (Union[int, Tuple[int, int]], optional): The size or range of + sizes for combinations. Defaults to (2, 5). + evaluation_func (Optional[Callable[[AgentSet], float]], optional): The + function to evaluate combinations. Defaults to None. + filter_func (Optional[Callable[[List[Tuple[AgentSet, float]]]): Allows + the user to specify how agents are filtered to form groups. + Defaults to None. + List[Tuple[AgentSet, float]]]], optional): The function to filter + combinations. Defaults to None. + + Returns: + List[Tuple[AgentSet, float]]: The list of valuable combinations, in + a tuple first agentset of valuable combination and then the value of + the combination. + """ + combinations = [] + # Allow one size or range of sizes to be passed + size_range = (size, size + 1) if isinstance(size, int) else size + + for candidate_group in itertools.chain.from_iterable( + itertools.combinations(group, size) for size in range(*size_range) + ): + group_set, result = evaluate_combination( + candidate_group, model, evaluation_func + ) + if result: + combinations.append((group_set, result)) + + if len(combinations) > 0 and filter_func: + filtered_combinations = filter_func(combinations) + return filtered_combinations + + return combinations + + +def extract_class(agents_by_type: dict, new_agent_class: object) -> type[Agent] | None: + """Helper function for create_meta_agents extracts the types of agents. + + Args: + agents_by_type (dict): The dictionary of agents by type. + new_agent_class (str): The name of the agent class to be created + + Returns: + type(Agent) if agent type exists + None otherwise + """ + agent_type_names = {} + for agent in agents_by_type: + agent_type_names[agent.__name__] = agent + + if new_agent_class in agent_type_names: + return type(agents_by_type[agent_type_names[new_agent_class]][0]) + return None + + +def create_meta_agent( + model: Any, + new_agent_class: str, + agents: Iterable[Any], + mesa_agent_type: type[Agent] | None, + meta_attributes: dict[str, Any] | None = None, + meta_methods: dict[str, Callable] | None = None, + assume_constituting_agent_methods: bool = False, + assume_constituting_agent_attributes: bool = False, +) -> Any | None: + """Create a new meta-agent class and instantiate agents. + + Parameters: + model (Any): The model instance. + new_agent_class (str): The name of the new meta-agent class. + agents (Iterable[Any]): The agents to be included in the meta-agent. + meta_attributes (Dict[str, Any]): Attributes to be added to the meta-agent. + meta_methods (Dict[str, Callable]): Methods to be added to the meta-agent. + assume_constituting_agent_methods (bool): Whether to assume methods from + constituting_-agents as meta_agent methods. + assume_constituting_agent_attributes (bool): Whether to retain attributes + from constituting_-agents. + + Returns: + - MetaAgent Instance + """ + # Convert agents to set to ensure uniqueness + agents = set(agents) + + # Ensure there is at least one agent base class + if not mesa_agent_type: + mesa_agent_type = (Agent,) + elif not isinstance(mesa_agent_type, tuple): + mesa_agent_type = (mesa_agent_type,) + + def add_methods( + meta_agent_instance: Any, + agents: Iterable[Any], + meta_methods: dict[str, Callable], + ) -> None: + """Add methods to the meta-agent instance. + + Parameters: + meta_agent_instance (Any): The meta-agent instance. + agents (Iterable[Any]): The agents to derive methods from. + meta_methods (Dict[str, Callable]): methods to be added to the meta-agent. + """ + if assume_constituting_agent_methods: + agent_classes = {type(agent) for agent in agents} + if meta_methods is None: + # Initialize meta_methods if not provided + meta_methods = {} + for agent_class in agent_classes: + for name in agent_class.__dict__: + if callable(getattr(agent_class, name)) and not name.startswith( + "__" + ): + original_method = getattr(agent_class, name) + meta_methods[name] = original_method + + if meta_methods is not None: + for name, meth in meta_methods.items(): + bound_method = MethodType(meth, meta_agent_instance) + setattr(meta_agent_instance, name, bound_method) + + def add_attributes( + meta_agent_instance: Any, agents: Iterable[Any], meta_attributes: dict[str, Any] + ) -> None: + """Add attributes to the meta-agent instance. + + Parameters: + meta_agent_instance (Any): The meta-agent instance. + agents (Iterable[Any]): The agents to derive attributes from. + meta_attributes (Dict[str, Any]): Attributes to be added to the + meta-agent. + """ + if assume_constituting_agent_attributes: + if meta_attributes is None: + # Initialize meta_attributes if not provided + meta_attributes = {} + for agent in agents: + for name, value in agent.__dict__.items(): + if not callable(value): + meta_attributes[name] = value + + if meta_attributes is not None: + for key, value in meta_attributes.items(): + setattr(meta_agent_instance, key, value) + + # Path 1 - Add agents to existing meta-agent + constituting_agents = [a for a in agents if hasattr(a, "meta_agent")] + if len(constituting_agents) > 0: + if len(constituting_agents) == 1: + add_attributes(constituting_agents[0].meta_agent, agents, meta_attributes) + add_methods(constituting_agents[0].meta_agent, agents, meta_methods) + constituting_agents[0].meta_agent.add_constituting_agents(agents) + + return constituting_agents[0].meta_agent # Return the existing meta-agent + + else: + constituting_agent = model.random.choice(constituting_agents) + agents = set(agents) - set(constituting_agents) + add_attributes(constituting_agent.meta_agent, agents, meta_attributes) + add_methods(constituting_agent.meta_agent, agents, meta_methods) + constituting_agent.meta_agent.add_constituting_agents(agents) + # TODO: Add way for user to specify how agents join meta-agent + # instead of random choice + return constituting_agent.meta_agent + + else: + # Path 2 - Create a new instance of an existing meta-agent class + agent_class = extract_class(model.agents_by_type, new_agent_class) + + if agent_class: + meta_agent_instance = agent_class(model, agents) + add_attributes(meta_agent_instance, agents, meta_attributes) + add_methods(meta_agent_instance, agents, meta_methods) + return meta_agent_instance + else: + # Path 3 - Create a new meta-agent class + meta_agent_class = type( + new_agent_class, + (MetaAgent, *mesa_agent_type), # Inherit Mesa Agent Classes + { + "unique_id": None, + "_constituting_set": None, + }, + ) + meta_agent_instance = meta_agent_class(model, agents) + add_attributes(meta_agent_instance, agents, meta_attributes) + add_methods(meta_agent_instance, agents, meta_methods) + return meta_agent_instance + + +class MetaAgent(Agent): + """A MetaAgent is an agent that contains other agents as components.""" + + def __init__( + self, model, agents: set[Agent] | None = None, name: str = "MetaAgent" + ): + """Create a new MetaAgent. + + Args: + model: The model instance. + agents (Optional[set[Agent]], optional): The set of agents to + include in the MetaAgent. Defaults to None. + name (str, optional): The name of the MetaAgent. Defaults to "MetaAgent". + """ + super().__init__(model) + self._constituting_set = AgentSet(agents or [], random=model.random) + self.name = name + + # Add ref to meta_agent in constituting_agents + for agent in self._constituting_set: + agent.meta_agent = self # TODO: Make a set for meta_agents + + def __len__(self) -> int: + """Return the number of components.""" + return len(self._constituting_set) + + def __iter__(self): + """Iterate over components.""" + return iter(self._constituting_set) + + def __contains__(self, agent: Agent) -> bool: + """Check if an agent is a component.""" + return agent in self._constituting_set + + @property + def agents(self) -> AgentSet: + """Get list of Meta-Agent constituting_agents.""" + return self._constituting_set + + @property + def constituting_agents_by_type(self) -> dict[type, list[Agent]]: + """Get the constituting_agents grouped by type. + + Returns: + dict[type, list[Agent]]: A dictionary of constituting_agents grouped by type. + """ + constituting_agents_by_type = {} + for agent in self._constituting_set: + agent_type = type(agent) + if agent_type not in constituting_agents_by_type: + constituting_agents_by_type[agent_type] = [] + constituting_agents_by_type[agent_type].append(agent) + return constituting_agents_by_type + + @property + def constituting_agent_types(self) -> set[type]: + """Get the types of all constituting_agents. + + Returns: + set[type]: A set of unique types of the constituting_agents. + """ + return {type(agent) for agent in self._constituting_set} + + def get_constituting_agent_instance(self, agent_type) -> set[type]: + """Get the instance of a constituting_agent of the specified type. + + Args: + agent_type: The type of the constituting_agent to retrieve. + + Returns: + The first instance of the specified constituting_agent type. + + Raises: + ValueError: If no constituting_agent of the specified type is found. + """ + try: + return self.constituting_agents_by_type[agent_type][0] + except KeyError: + raise ValueError( + f"No constituting_agent of type {agent_type} found." + ) from None + + def add_constituting_agents( + self, + new_agents: set[Agent], + ): + """Add agents as components. + + Args: + new_agents (set[Agent]): The agents to add to MetaAgent constituting_set. + """ + for agent in new_agents: + self._constituting_set.add(agent) + agent.meta_agent = self # TODO: Make a set for meta_agents + self.model.register_agent(agent) + + def remove_constituting_agents(self, remove_agents: set[Agent]): + """Remove agents as components. + + Args: + remove_agents (set[Agent]): The agents to remove from MetaAgent. + """ + for agent in remove_agents: + self._constituting_set.discard(agent) + agent.meta_agent = None # TODO: Remove meta_agent from set + self.model.deregister_agent(agent) + + def step(self): + """Perform the agent's step. + + Override this method to define the meta agent's behavior. + By default, does nothing. + """ diff --git a/mesa/model.py b/mesa/model.py index c507ed73a10..f53d92b1633 100644 --- a/mesa/model.py +++ b/mesa/model.py @@ -151,9 +151,9 @@ def register_agent(self, agent): agent: The agent to register. Notes: - This method is called automatically by ``Agent.__init__``, so there is no need to use this - if you are subclassing Agent and calling its super in the ``__init__`` method. - + This method is called automatically by ``Agent.__init__``, so there + is no need to use this if you are subclassing Agent and calling its + super in the ``__init__`` method. """ self._agents[agent] = None diff --git a/tests/test_examples.py b/tests/test_examples.py index 0e8a7edce42..6a901e49122 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -4,6 +4,7 @@ BoltzmannWealth, ConwaysGameOfLife, EpsteinCivilViolence, + MultiLevelAllianceModel, PdGrid, Schelling, SugarscapeG1mt, @@ -106,3 +107,16 @@ def test_wolf_sheep(): # noqa: D103 simulator = ABMSimulator() WolfSheep(seed=42, simulator=simulator) simulator.run_for(10) + + +def test_alliance_formation_model(): # noqa: D103 + from mesa.examples.advanced.alliance_formation import app + + app.page # noqa: B018 + + model = MultiLevelAllianceModel(50, seed=42) + + for _i in range(10): + model.step() + + assert len(model.agents) == len(model.network.nodes) diff --git a/tests/test_meta_agents.py b/tests/test_meta_agents.py new file mode 100644 index 00000000000..564c669979c --- /dev/null +++ b/tests/test_meta_agents.py @@ -0,0 +1,307 @@ +"""Tests for the meta_agents module.""" + +import pytest + +from mesa import Agent, Model +from mesa.experimental.meta_agents.meta_agent import ( + MetaAgent, + create_meta_agent, + evaluate_combination, + find_combinations, +) + + +class CustomAgent(Agent): + """A custom agent with additional attributes and methods.""" + + def __init__(self, model): + """A custom agent constructor.""" + super().__init__(model) + self.custom_attribute = "custom_value" + + def custom_method(self): + """A custom agent method.""" + return "custom_method_value" + + +@pytest.fixture +def setup_agents(): + """Set up the model and agents for testing. + + Returns: + tuple: A tuple containing the model and a list of agents. + """ + model = Model() + agent1 = CustomAgent(model) + agent2 = Agent(model) + agent3 = Agent(model) + agent4 = Agent(model) + agent4.custom_attribute = "custom_value" + agents = [agent1, agent2, agent3, agent4] + return model, agents + + +def test_create_meta_agent_new_class(setup_agents): + """Test creating a new meta-agent class and test inclusion of attributes and methods. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = create_meta_agent( + model, + "MetaAgentClass", + agents, + Agent, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + assume_constituting_agent_attributes=True, + ) + assert meta_agent is not None + assert meta_agent.attribute1 == "value1" + assert meta_agent.function1() == "function1" + assert meta_agent.agents == set(agents) + assert hasattr(meta_agent, "custom_attribute") + assert meta_agent.custom_attribute == "custom_value" + + +def test_create_meta_agent_existing_class(setup_agents): + """Test creating new meta-agent instance with an existing meta-agent class. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + # Create Met Agent Class + meta_agent = create_meta_agent( + model, + "MetaAgentClass", + [agents[0], agents[2]], + Agent, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + ) + + # Create new meta-agent instance with existing class + meta_agent2 = create_meta_agent( + model, + "MetaAgentClass", + [agents[1], agents[3]], + Agent, + meta_attributes={"attribute2": "value2"}, + meta_methods={"function2": lambda self: "function2"}, + assume_constituting_agent_attributes=True, + ) + assert meta_agent is not None + assert meta_agent2.attribute2 == "value2" + assert meta_agent.function1() == "function1" + assert meta_agent.agents == {agents[2], agents[0]} + assert meta_agent2.function2() == "function2" + assert meta_agent2.agents == {agents[1], agents[3]} + assert hasattr(meta_agent2, "custom_attribute") + assert meta_agent2.custom_attribute == "custom_value" + + +def test_add_agents_to_existing_meta_agent(setup_agents): + """Test adding agents to an existing meta-agent instance. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + meta_agent1 = create_meta_agent( + model, + "MetaAgentClass", + [agents[0], agents[3]], + Agent, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + assume_constituting_agent_attributes=True, + ) + + create_meta_agent( + model, + "MetaAgentClass", + [agents[1], agents[0], agents[2]], + Agent, + assume_constituting_agent_attributes=True, + assume_constituting_agent_methods=True, + ) + assert meta_agent1.agents == {agents[0], agents[1], agents[2], agents[3]} + assert meta_agent1.function1() == "function1" + assert meta_agent1.attribute1 == "value1" + assert hasattr(meta_agent1, "custom_attribute") + assert meta_agent1.custom_attribute == "custom_value" + assert hasattr(meta_agent1, "custom_method") + assert meta_agent1.custom_method() == "custom_method_value" + + +def test_meta_agent_integration(setup_agents): + """Test the integration of MetaAgent with the model. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + meta_agent = create_meta_agent( + model, + "MetaAgentClass", + agents, + Agent, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + assume_constituting_agent_attributes=True, + assume_constituting_agent_methods=True, + ) + + model.step() + + assert meta_agent in model.agents + assert meta_agent.function1() == "function1" + assert meta_agent.attribute1 == "value1" + assert hasattr(meta_agent, "custom_attribute") + assert meta_agent.custom_attribute == "custom_value" + assert hasattr(meta_agent, "custom_method") + assert meta_agent.custom_method() == "custom_method_value" + + +def test_evaluate_combination(setup_agents): + """Test the evaluate_combination function. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + + def evaluation_func(agent_set): + return len(agent_set) + + result = evaluate_combination(tuple(agents), model, evaluation_func) + assert result is not None + assert result[1] == len(agents) + + +def test_find_combinations(setup_agents): + """Test the find_combinations function. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + agent_set = set(agents) + + def evaluation_func(agent_set): + return len(agent_set) + + def filter_func(combinations): + return [combo for combo in combinations if combo[1] > 2] + + combinations = find_combinations( + model, + agent_set, + size=(2, 4), + evaluation_func=evaluation_func, + filter_func=filter_func, + ) + assert len(combinations) > 0 + for combo in combinations: + assert combo[1] > 2 + + +def test_meta_agent_len(setup_agents): + """Test the __len__ method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + assert len(meta_agent) == len(agents) + + +def test_meta_agent_iter(setup_agents): + """Test the __iter__ method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + assert list(iter(meta_agent)) == list(meta_agent._constituting_set) + + +def test_meta_agent_contains(setup_agents): + """Test the __contains__ method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + for agent in agents: + assert agent in meta_agent + + +def test_meta_agent_add_constituting_agents(setup_agents): + """Test the add_constituting_agents method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, {agents[0], agents[1]}) + meta_agent.add_constituting_agents({agents[2], agents[3]}) + assert meta_agent._constituting_set == set(agents) + + +def test_meta_agent_remove_constituting_agents(setup_agents): + """Test the remove_constituting_agents method of MetaAgent. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + meta_agent.remove_constituting_agents({agents[2], agents[3]}) + assert meta_agent._constituting_set == {agents[0], agents[1]} + + +def test_meta_agent_constituting_agents_by_type(setup_agents): + """Test the constituting_agents_by_type property of MetaAgent.""" + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + constituting_agents_by_type = meta_agent.constituting_agents_by_type + assert isinstance(constituting_agents_by_type, dict) + for agent_type, agent_list in constituting_agents_by_type.items(): + assert all(isinstance(agent, agent_type) for agent in agent_list) + + +def test_meta_agent_constituting_agent_types(setup_agents): + """Test the constituting_agent_types property of MetaAgent.""" + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + constituting_agent_types = meta_agent.constituting_agent_types + assert isinstance(constituting_agent_types, set) + assert all(isinstance(agent_type, type) for agent_type in constituting_agent_types) + + +def test_meta_agent_get_constituting_agent_instance(setup_agents): + """Test the get_constituting_agent_instance method of MetaAgent.""" + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + agent_type = type(agents[0]) + constituting_agent_instance = meta_agent.get_constituting_agent_instance(agent_type) + assert isinstance(constituting_agent_instance, agent_type) + with pytest.raises(ValueError): + meta_agent.get_constituting_agent_instance(str) # Invalid type + + +def test_meta_agent_step(setup_agents): + """Test the step method of MetaAgent.""" + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + meta_agent.step() # Ensure no errors occur during step + # Add additional assertions if step behavior is defined in the future diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py index 4621e6a671f..0214300f57b 100644 --- a/tests/test_solara_viz.py +++ b/tests/test_solara_viz.py @@ -94,7 +94,8 @@ def Test(user_params): assert slider_int.step is None -def test_call_space_drawer(mocker): # noqa: D103 +def test_call_space_drawer(mocker): + """Test the call to space drawer.""" mock_space_matplotlib = mocker.spy( mesa.visualization.components.matplotlib_components, "SpaceMatplotlib" ) @@ -207,7 +208,8 @@ def drawer(model): ) -def test_slider(): # noqa: D103 +def test_slider(): + """Test the Slider component.""" slider_float = Slider("Agent density", 0.8, 0.1, 1.0, 0.1) assert slider_float.is_float_slider assert slider_float.value == 0.8 @@ -221,7 +223,9 @@ def test_slider(): # noqa: D103 assert slider_dtype_float.is_float_slider -def test_model_param_checks(): # noqa: D103 +def test_model_param_checks(): + """Test the model parameter checks.""" + class ModelWithOptionalParams: def __init__(self, required_param, optional_param=10): pass