diff --git a/mesa/examples/__init__.py b/mesa/examples/__init__.py index beecd6a1b6a..0422b232994 100644 --- a/mesa/examples/__init__.py +++ b/mesa/examples/__init__.py @@ -2,6 +2,7 @@ from mesa.examples.advanced.pd_grid.model import PdGrid from mesa.examples.advanced.sugarscape_g1mt.model import SugarscapeG1mt from mesa.examples.advanced.wolf_sheep.model import WolfSheep +from mesa.examples.basic.alliance_formation_model.model import MultiLevelAllianceModel from mesa.examples.basic.boid_flockers.model import BoidFlockers from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannWealth from mesa.examples.basic.conways_game_of_life.model import ConwaysGameOfLife @@ -13,6 +14,7 @@ "BoltzmannWealth", "ConwaysGameOfLife", "EpsteinCivilViolence", + "MultiLevelAllianceModel", "PdGrid", "Schelling", "SugarscapeG1mt", diff --git a/mesa/examples/basic/alliance_formation_model/Readme.md b/mesa/examples/basic/alliance_formation_model/Readme.md new file mode 100644 index 00000000000..cf60ab4a2f7 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/Readme.md @@ -0,0 +1,40 @@ +# Alliance Formation Model + +## Summary +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 its current configuration, agents being part of multiple meta-agents is not supported + +## 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/basic/alliance_formation_model/__init__.py b/mesa/examples/basic/alliance_formation_model/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/mesa/examples/basic/alliance_formation_model/agents.py b/mesa/examples/basic/alliance_formation_model/agents.py new file mode 100644 index 00000000000..4f33bb5b9a3 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/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/basic/alliance_formation_model/app.py b/mesa/examples/basic/alliance_formation_model/app.py new file mode 100644 index 00000000000..9df3116dc54 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/app.py @@ -0,0 +1,72 @@ +import matplotlib.pyplot as plt +import networkx as nx +import solara +from matplotlib.figure import Figure + +from mesa.examples.basic.alliance_formation_model.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/basic/alliance_formation_model/model.py b/mesa/examples/basic/alliance_formation_model/model.py new file mode 100644 index 00000000000..d1f8e891881 --- /dev/null +++ b/mesa/examples/basic/alliance_formation_model/model.py @@ -0,0 +1,180 @@ +import networkx as nx +import numpy as np + +import mesa +from mesa.examples.basic.alliance_formation_model.agents import AllianceAgent +from mesa.experimental.meta_agents.meta_agent import find_combinations +from mesa.experimental.meta_agents.multi_levels import multi_level_agents + + +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 = multi_level_agents( + self, + class_name, + alliance, + 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.subset) diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 946b2ba53fc..2f73264608e 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 cell_space, devs, mesa_signals +from mesa.experimental import cell_space, devs, mesa_signals, meta_agents -__all__ = ["cell_space", "devs", "mesa_signals"] +__all__ = ["cell_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..420f4ad34b1 --- /dev/null +++ b/mesa/experimental/meta_agents/__init__.py @@ -0,0 +1,26 @@ +"""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 +from .multi_levels import multi_level_agents + +__all__ = ["MetaAgent", "multi_level_agents"] diff --git a/mesa/experimental/meta_agents/meta_agent.py b/mesa/experimental/meta_agents/meta_agent.py new file mode 100644 index 00000000000..b1882c51195 --- /dev/null +++ b/mesa/experimental/meta_agents/meta_agent.py @@ -0,0 +1,148 @@ +"""Implementation of Mesa's meta agent capability. + +This contains two helper functions and a MetaAgent class that can be used to create agents that contain other agents as components. + +Helper functions: +1 - find_combinations: Find combinations of agents to create a meta-agent subset. +2- evaluate_combination: Evaluate combinations of agents by some user based criteria to determine if it should be a subset. + +Meta-Agent class (MetaAgent): An agent that contains other agents as components. +""" + +import itertools +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor + +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]]], List[Tuple[AgentSet, float]]]], optional): The function to filter combinations. Defaults to None. + + Returns: + List[Tuple[AgentSet, float]]: The list of valuable combinations. + """ + combinations = [] + with ThreadPoolExecutor() as executor: + futures = [] + # Allow one size or range of sizes to be passed + size_range = (size, size + 1) if isinstance(size, int) else size + + candidate_groups = itertools.chain.from_iterable( + itertools.combinations(group, size) for size in range(*size_range) + ) + for candidate_group in candidate_groups: + futures.append( + executor.submit( + evaluate_combination, candidate_group, model, evaluation_func + ) + ) + + for future in futures: + group_set, result = future.result() + if result: + combinations.append((group_set, result)) + + if len(combinations) > 0 and filter_func: + filtered_combinations = filter_func(combinations) + return filtered_combinations + + return combinations + + +class MetaAgent(Agent): + """A MetaAgent is an agent that contains other agents as components.""" + + def __init__(self, model, agents: set[Agent] | None = None): + """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. + """ + super().__init__(model) + self._subset = AgentSet(agents or [], random=model.random) + + # Add ref to meta_agent in subagents + for agent in self._subset: + agent.meta_agent = self # TODO: Make a set for meta_agents + + def __len__(self) -> int: + """Return the number of components.""" + return len(self._subset) + + def __iter__(self): + """Iterate over components.""" + return iter(self._subset) + + def __contains__(self, agent: Agent) -> bool: + """Check if an agent is a component.""" + return agent in self._subset + + @property + def subset(self) -> AgentSet: + """Read-only access to components as an AgentSet.""" + return self._subset + + def add_subagents(self, new_agents: set[Agent]): + """Add agents as components. + + Args: + new_agents (set[Agent]): The agents to add to MetaAgent subset. + """ + for agent in new_agents: + self._subset.add(agent) + agent.meta_agent = self # TODO: Make a set for meta_agents + + def remove_subagents(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._subset.discard(agent) + agent.meta_agent = None # TODO: Remove meta_agent from set + + 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/experimental/meta_agents/multi_levels.py b/mesa/experimental/meta_agents/multi_levels.py new file mode 100644 index 00000000000..6238c53b9d6 --- /dev/null +++ b/mesa/experimental/meta_agents/multi_levels.py @@ -0,0 +1,144 @@ +"""This function is for dynamically growing multiple levels of meta-agents. + +Each new level creates a new meta-agent class that is created dynamically using the provided name and +unique attributes and methods. + +Currently restricted to one parent agent and one meta-agent per agent. +Goal is to assess usage and expand functionality. + +Function has three paths of execution: +1. Add agents to existing meta-agent +2. Create new meta-agent instance of existing meta-agent class +3. Create new meta-agent class + +See multi-level alliance formation model in basic examples for usage. + +""" + +from collections.abc import Callable, Iterable +from types import MethodType +from typing import Any + +from mesa.experimental.meta_agents.meta_agent import MetaAgent + + +def multi_level_agents( + model: Any, + new_agent_class: str, + agents: Iterable[Any], + meta_attributes: dict[str, Any] = dict(), # noqa B006 + meta_methods: dict[str, Callable] = dict(), # noqa B006 + retain_subagent_methods: bool = False, + retain_subagent_attributes: bool = False, +) -> Any | None: + """Dynamically create a new meta-agent class and instantiate agents in that class. + + 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. + retain_subagent_functions (bool): Whether to retain functions from sub-agents. + retain_subagent_attributes (bool): Whether to retain attributes from sub-agents. + + Returns: + Optional[Any]: + - None if adding agent(s) to existing class + - New class instance if created a new instance of a dynamically created agent type + - New class instance if created a new dynamically created agent type + """ + # Convert agents to set to ensure uniqueness + agents = set(agents) + + def add_methods( + meta_agent_instance: Any, + agents: Iterable[Any], + meta_methods: dict[str, Callable], + ) -> None: + """Add functions to the meta-agent instance. + + Parameters: + meta_agent_instance (Any): The meta-agent instance. + agents (Iterable[Any]): The agents to derive functions from. + meta_methods (Dict[str, Callable]): Functions to be added to the meta-agent. + """ + if retain_subagent_methods: + agent_classes = {type(agent) for agent in agents} + 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 + + 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 retain_subagent_attributes: + for agent in agents: + for name, value in agent.__dict__.items(): + if not callable(value): + meta_attributes[name] = value + + for key, value in meta_attributes.items(): + setattr(meta_agent_instance, key, value) + + # Path 1 - Add agents to existing meta-agent + subagents = [a for a in agents if hasattr(a, "meta_agent")] + if len(subagents) > 0: + if len(subagents) == 1: + add_attributes(subagents[0].meta_agent, agents, meta_attributes) + add_methods(subagents[0].meta_agent, agents, meta_methods) + subagents[0].meta_agent.add_subagents(agents) + + else: + subagent = model.random.choice(subagents) + agents = set(agents) - set(subagents) + add_attributes(subagent.meta_agent, agents, meta_attributes) + add_methods(subagent.meta_agent, agents, meta_methods) + subagent.meta_agent.add_subagents(agents) + # TODO: Add way for user to specify how agents join meta-agent instead of random choice + else: + # Path 2 - Create a new instance of an existing meta-agent class + agent_class = next( + ( + agent_type + for agent_type in model.agent_types + if agent_type.__name__ == new_agent_class + ), + None, + ) + + 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,), + { + "unique_id": None, + "_subset": 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 diff --git a/tests/test_examples.py b/tests/test_examples.py index 0e8a7edce42..f9e020cbde2 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.basic.alliance_formation_model 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..6f8dff8208e --- /dev/null +++ b/tests/test_meta_agents.py @@ -0,0 +1,269 @@ +"""Tests for the meta_agents module.""" + +import pytest + +from mesa import Agent, Model +from mesa.experimental.meta_agents.meta_agent import ( + MetaAgent, + evaluate_combination, + find_combinations, +) +from mesa.experimental.meta_agents.multi_levels import multi_level_agents + + +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 = Agent(model) + agent2 = Agent(model) + agent3 = Agent(model) + agent4 = CustomAgent(model) + 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 functions. + + Args: + setup_agents (tuple): The model and agents fixture. + """ + model, agents = setup_agents + meta_agent = multi_level_agents( + model, + "MetaAgentClass", + agents, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + retain_subagent_attributes=True, + retain_subagent_methods=True, + ) + assert meta_agent is not None + assert meta_agent.attribute1 == "value1" + assert meta_agent.function1() == "function1" + assert meta_agent._subset == set(agents) + 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_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 Meta Agent Class + meta_agent = multi_level_agents( + model, + "MetaAgentClass", + [agents[0], agents[2]], + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + ) + + # Create new meta-agent instance with existing class + meta_agent2 = multi_level_agents( + model, + "MetaAgentClass", + [agents[1], agents[3]], + meta_attributes={"attribute2": "value2"}, + meta_methods={"function2": lambda self: "function2"}, + retain_subagent_attributes=True, + retain_subagent_methods=True, + ) + assert meta_agent is not None + assert meta_agent2.attribute2 == "value2" + assert meta_agent.function1() == "function1" + assert meta_agent._subset == {agents[2], agents[0]} + assert meta_agent2.function2() == "function2" + assert meta_agent2._subset == {agents[1], agents[3]} + assert hasattr(meta_agent2, "custom_attribute") + assert meta_agent2.custom_attribute == "custom_value" + assert hasattr(meta_agent2, "custom_method") + assert meta_agent2.custom_method() == "custom_method_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 = multi_level_agents( + model, + "MetaAgentClass", + [agents[0], agents[3]], + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + retain_subagent_attributes=True, + retain_subagent_methods=True, + ) + + multi_level_agents( + model, + "MetaAgentClass", + [agents[1], agents[0], agents[2]], + retain_subagent_attributes=True, + retain_subagent_methods=True, + ) + assert meta_agent1._subset == {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 = multi_level_agents( + model, + "MetaAgentClass", + agents, + meta_attributes={"attribute1": "value1"}, + meta_methods={"function1": lambda self: "function1"}, + retain_subagent_attributes=True, + retain_subagent_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._subset) + + +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_subagents(setup_agents): + """Test the add_subagents 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_subagents({agents[2], agents[3]}) + assert meta_agent._subset == set(agents) + + +def test_meta_agent_remove_subagents(setup_agents): + """Test the remove_subagents 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_subagents({agents[2], agents[3]}) + assert meta_agent._subset == {agents[0], agents[1]} diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py index 3b8d82fb7bc..a84fc910364 100644 --- a/tests/test_solara_viz.py +++ b/tests/test_solara_viz.py @@ -92,7 +92,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" ) @@ -153,7 +154,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 @@ -167,7 +169,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